From 7635739e9ba79a932e5b0e1e708d92eb6ce1ba16 Mon Sep 17 00:00:00 2001 From: SulfurNitride Date: Wed, 29 Apr 2026 01:28:24 -0500 Subject: Finalize Linux build cleanup --- src/src/CMakeLists.txt | 39 +- src/src/app_icon.rc | 1 - src/src/archivefiletree.cpp | 558 +- src/src/categories.cpp | 1037 ++-- src/src/categoriesdialog.cpp | 798 +-- src/src/categoriestable.cpp | 155 +- src/src/commandline.cpp | 2150 ++++---- src/src/createinstancedialogpages.cpp | 2670 +++++----- src/src/datatab.cpp | 548 +- src/src/directoryrefresher.cpp | 1027 ++-- src/src/dlls.manifest.debug.qt6 | 34 - src/src/dlls.manifest.qt6 | 34 - src/src/downloadlist.cpp | 691 +-- src/src/downloadmanager.cpp | 5111 +++++++++--------- src/src/downloadmanager.h | 1292 ++--- src/src/downloadstab.cpp | 220 +- src/src/envmodule.cpp | 16 +- src/src/envshortcut.cpp | 1138 ++-- src/src/executableslist.cpp | 1050 ++-- src/src/filetree.cpp | 1663 +++--- src/src/filetreemodel.cpp | 2480 ++++----- src/src/filterlist.cpp | 890 ++-- src/src/forcedloaddialog.cpp | 142 +- src/src/fuseconnector.cpp | 6 +- src/src/github.cpp | 408 +- src/src/iconextractor.cpp | 8 +- src/src/installationmanager.cpp | 1919 ++++--- src/src/installationmanager.h | 708 +-- src/src/instancemanager.cpp | 1916 +++---- src/src/instancemanagerdialog.cpp | 2038 ++++---- src/src/knowngames.h | 96 +- src/src/loglist.cpp | 850 +-- src/src/mainwindow.cpp | 8352 +++++++++++++++--------------- src/src/moapplication.cpp | 1927 ++++--- src/src/modelutils.cpp | 191 +- src/src/modinfo.cpp | 1174 +++-- src/src/modinfodialog.cpp | 1644 +++--- src/src/modinfodialogcategories.cpp | 278 +- src/src/modinfodialogconflicts.cpp | 2261 ++++---- src/src/modinfodialogconflictsmodels.cpp | 622 +-- src/src/modinfodialogesps.cpp | 742 +-- src/src/modinfodialogimages.cpp | 2192 ++++---- src/src/modinfodialognexus.cpp | 936 ++-- src/src/modinforegular.cpp | 2086 ++++---- src/src/modinfowithconflictinfo.cpp | 744 +-- src/src/modlist.cpp | 3035 +++++------ src/src/modlistbypriorityproxy.cpp | 824 +-- src/src/modlistcontextmenu.cpp | 1280 ++--- src/src/modlistdropinfo.cpp | 235 +- src/src/modlistproxy.cpp | 226 +- src/src/modlistsortproxy.cpp | 1372 ++--- src/src/modlistversiondelegate.cpp | 135 +- src/src/modlistview.cpp | 3100 +++++------ src/src/modlistviewactions.cpp | 3027 ++++++----- src/src/nexusinterface.cpp | 2653 +++++----- src/src/nxmaccessmanager.cpp | 1970 +++---- src/src/nxmhandler_linux.cpp | 4 +- src/src/organizer_en.ts | 6 +- src/src/organizercore.cpp | 6008 ++++++++++----------- src/src/organizerproxy.cpp | 1027 ++-- src/src/plugincontainer.cpp | 2834 +++++----- src/src/pluginlist.cpp | 3955 +++++++------- src/src/pluginlistcontextmenu.cpp | 330 +- src/src/pluginlistview.cpp | 740 +-- src/src/prefixsetuprunner.cpp | 32 +- src/src/previewgenerator.cpp | 156 +- src/src/processrunner.cpp | 10 +- src/src/profile.cpp | 2555 ++++----- src/src/protonlauncher.cpp | 4 +- src/src/qt.conf | 6 +- src/src/qtgroupingproxy.cpp | 1894 ++++--- src/src/savestab.cpp | 2 +- src/src/selfupdater.cpp | 708 +-- src/src/settings.cpp | 5184 +++++++++---------- src/src/settings.h | 1990 +++---- src/src/settingsdialoggeneral.cpp | 360 +- src/src/settingsdialogmodlist.cpp | 170 +- src/src/settingsdialogplugins.cpp | 788 +-- src/src/settingsdialogworkarounds.cpp | 479 +- src/src/shared/appconfig.cpp | 142 +- src/src/shared/appconfig.h | 116 +- src/src/shared/directoryentry.cpp | 1912 +++---- src/src/shared/fileentry.cpp | 512 +- src/src/shared/fileregisterfwd.h | 242 +- src/src/spawn.cpp | 16 +- src/src/uilocker.cpp | 1270 ++--- src/src/vdfparser.h | 2 +- src/src/version.rc | 37 - src/src/vfs/mo2filesystem.cpp | 10 +- src/src/vfs/vfstree.cpp | 2 +- 90 files changed, 53029 insertions(+), 53173 deletions(-) delete mode 100644 src/src/app_icon.rc delete mode 100644 src/src/dlls.manifest.debug.qt6 delete mode 100644 src/src/dlls.manifest.qt6 delete mode 100644 src/src/version.rc (limited to 'src') diff --git a/src/src/CMakeLists.txt b/src/src/CMakeLists.txt index e59c624..b003677 100644 --- a/src/src/CMakeLists.txt +++ b/src/src/CMakeLists.txt @@ -2,8 +2,9 @@ cmake_minimum_required(VERSION 3.16) find_package(Qt6 REQUIRED COMPONENTS Widgets WebSockets Network Concurrent) -if(WIN32) - find_package(Qt6 REQUIRED COMPONENTS WebEngineWidgets) +option(MO2_ENABLE_WEBENGINE "Build optional Qt WebEngine browser support" OFF) +if(MO2_ENABLE_WEBENGINE) + find_package(Qt6 QUIET COMPONENTS WebEngineWidgets) endif() find_package(Boost CONFIG REQUIRED COMPONENTS program_options) @@ -66,12 +67,6 @@ if(NOT Qt6WebEngineWidgets_FOUND) ${CMAKE_CURRENT_SOURCE_DIR}/browserview.h ${CMAKE_CURRENT_SOURCE_DIR}/browserdialog.h) endif() -if(WIN32) - list(REMOVE_ITEM ORGANIZER_SOURCES - ${CMAKE_CURRENT_SOURCE_DIR}/nxmhandler_linux.cpp) - list(REMOVE_ITEM ORGANIZER_HEADERS - ${CMAKE_CURRENT_SOURCE_DIR}/nxmhandler_linux.h) -endif() add_executable(organizer ${ORGANIZER_SOURCES} @@ -127,28 +122,25 @@ target_compile_definitions(organizer PRIVATE FUSE_USE_VERSION=312 $<$:MO2_WEBENGINE>) -if(NOT WIN32) - option(MO2_BUNDLE_7Z_RUNTIME "Copy a Linux 7z module into organizer/dlls" ON) - option(MO2_STAGE_PYTHON_PLUGIN_PAYLOAD - "Stage shipped Python plugin payload into build plugins/ for Linux runs" OFF) - option(MO2_STAGE_INSTALLER_WIZARD - "Stage installer_wizard python plugin payload (requires additional wizard package)" OFF) - set(MO2_7Z_SO_PATH "" CACHE FILEPATH - "Path to a compatible 7z shared module (exports CreateObject/GetHandlerProperty2)") - set(MO2_UPSTREAM_PLUGIN_PAYLOAD_DIR - "$ENV{HOME}/Downloads/Mod.Organizer-2.5.2/plugins" - CACHE PATH "Optional path to upstream MO2 plugins folder for pyCfg.py/data payload") +option(MO2_BUNDLE_7Z_RUNTIME "Copy a Linux 7z module into organizer/lib" ON) +option(MO2_STAGE_PYTHON_PLUGIN_PAYLOAD + "Stage shipped Python plugin payload into build plugins/ for Linux runs" OFF) +option(MO2_STAGE_INSTALLER_WIZARD + "Stage installer_wizard python plugin payload (requires additional wizard package)" OFF) +set(MO2_7Z_SO_PATH "" CACHE FILEPATH + "Path to a compatible 7z shared module (exports CreateObject/GetHandlerProperty2)") +set(MO2_UPSTREAM_PLUGIN_PAYLOAD_DIR + "$ENV{HOME}/Downloads/Mod.Organizer-2.5.2/plugins" + CACHE PATH "Optional path to upstream MO2 plugins folder for pyCfg.py/data payload") if(MO2_BUNDLE_7Z_RUNTIME) if(TARGET 7z) # Use the 7z.so built from source by libs/7zip/CMakeLists.txt add_dependencies(organizer 7z) add_custom_command(TARGET organizer POST_BUILD - COMMAND ${CMAKE_COMMAND} -E make_directory "$/dlls" + COMMAND ${CMAKE_COMMAND} -E make_directory "$/lib" COMMAND ${CMAKE_COMMAND} -E copy_if_different - "$" "$/dlls/7z.so" - COMMAND ${CMAKE_COMMAND} -E copy_if_different - "$" "$/dlls/7zip.dll") + "$" "$/lib/7z.so") message(STATUS "[MO2] Will bundle 7z.so built from source") else() message(WARNING @@ -221,4 +213,3 @@ if(NOT WIN32) # Keep only the canonical Fallout NV plugin filename on Linux (case-sensitive FS). add_custom_command(TARGET organizer POST_BUILD COMMAND ${CMAKE_COMMAND} -E rm -f "$/plugins/libgame_falloutnv.so") -endif() diff --git a/src/src/app_icon.rc b/src/src/app_icon.rc deleted file mode 100644 index 6e79ef7..0000000 --- a/src/src/app_icon.rc +++ /dev/null @@ -1 +0,0 @@ -IDI_ICON1 ICON DISCARDABLE "mo_icon.ico" diff --git a/src/src/archivefiletree.cpp b/src/src/archivefiletree.cpp index e8f487d..b2bdaf9 100644 --- a/src/src/archivefiletree.cpp +++ b/src/src/archivefiletree.cpp @@ -1,280 +1,278 @@ -/* -Copyright (C) MO2 Team. All rights reserved. - -This file is part of Mod Organizer. - -Mod Organizer is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -Mod Organizer is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with Mod Organizer. If not, see . -*/ - -// For QObject::tr: -#include - -#include "archivefiletree.h" - -#include "log.h" - -using namespace MOBase; - -/** - * We use custom file entries to store the index. - */ -class ArchiveFileEntry : public virtual FileTreeEntry -{ -public: - /** - * @brief Create a new entry. - * - * @param parent The tree containing this entry. - * @param name The name of this entry. - * @param index The index of the entry in the archive. - */ - ArchiveFileEntry(std::shared_ptr parent, QString name, int index) - : FileTreeEntry(parent, name), m_Index(index) - {} - - std::shared_ptr clone() const override - { - return std::make_shared(nullptr, name(), m_Index); - } - - // No private since we are in an implementation file: - const int m_Index; -}; - -/** - * - */ -class ArchiveFileTreeImpl : public virtual ArchiveFileTree, - public virtual ArchiveFileEntry -{ -public: - using File = std::tuple; - -public - : // Public for make_shared (but not accessible by other since not exposed in .h): - ArchiveFileTreeImpl(std::shared_ptr parent, QString name, int index, - std::vector files) - : FileTreeEntry(parent, name), ArchiveFileEntry(parent, name, index), - m_Files(std::move(files)) - {} - -public: // Override to avoid VS warnings: - std::shared_ptr astree() override { return IFileTree::astree(); } - - std::shared_ptr astree() const override - { - return IFileTree::astree(); - } - -protected: - std::shared_ptr clone() const override - { - return IFileTree::clone(); - } - -public: // Overrides: - /** - * - */ - static void mapToArchive(IFileTree const& tree, QString path, - std::vector const& data) - { - if (path.length() > 0) { - // when using a long windows path (starting with \\?\) we apparently can have - // redundant . components in the path. This wasn't a problem with "regular" path - // names. - if (path == ".") { - path.clear(); - } else { - path.append("/"); - } - } - - for (auto const& entry : tree) { - if (entry->isDir()) { - const ArchiveFileTreeImpl& archiveEntry = - dynamic_cast(*entry); - QString const tmp = path + archiveEntry.name(); - if (archiveEntry.m_Index != -1) { - data[archiveEntry.m_Index]->addOutputFilePath(tmp.toStdWString()); - } - mapToArchive(*archiveEntry.astree(), tmp, data); - } else { - const ArchiveFileEntry& archiveFileEntry = - dynamic_cast(*entry); - if (archiveFileEntry.m_Index != -1) { - data[archiveFileEntry.m_Index]->addOutputFilePath( - (path + archiveFileEntry.name()).toStdWString()); - } - } - } - } - - /** - * - */ - void mapToArchive(Archive& archive) const override - { - mapToArchive(*this, "", archive.getFileList()); - } - -protected: - /** - * Overriding makeDirectory and makeFile to create file tree or file entry with index - * -1. - * - */ - std::shared_ptr - makeDirectory(std::shared_ptr parent, QString name) const override - { - return std::make_shared(parent, name, -1, std::vector{}); - } - - std::shared_ptr - makeFile(std::shared_ptr parent, QString name) const override - { - return std::make_shared(parent, name, -1); - } - - bool - doPopulate(std::shared_ptr parent, - std::vector>& entries) const override - { - - // Sort by name: - std::sort(std::begin(m_Files), std::end(m_Files), [](const auto& a, const auto& b) { - return std::get<0>(a)[0].compare(std::get<0>(b)[0], Qt::CaseInsensitive) < 0; - }); - - // We know that the files are sorted: - QString currentName = ""; - int currentIndex = -1; - std::vector currentFiles; - for (auto& p : m_Files) { - - // At the start or if we have reset, just retrieve the current name: - if (currentName == "") { - currentName = std::get<0>(p)[0]; - } - - // If the name is different, we need to create a directory from what we have - // accumulated: - if (currentName != std::get<0>(p)[0]) { - - // We may or may not have an index here, it depends on the type of archive (some - // archives list intermediate non-empty folders, some don't): - entries.push_back(std::make_shared( - parent, currentName, currentIndex, std::move(currentFiles))); - - currentFiles.clear(); // Back to a valid state. - - // Reset the index: - currentIndex = -1; - } - - // We can always override the current name: - currentName = std::get<0>(p)[0]; - - // If the current path contains only one components: - if (std::get<0>(p).size() == 1) { - - // If it is not a directory, then it is a file in directly under this tree: - if (!std::get<1>(p)) { - entries.push_back( - std::make_shared(parent, currentName, std::get<2>(p))); - currentName = ""; - } else { - // Otherwize, it is the actual "file" corresponding to the directory we are - // listing, so we can retrieve the index here: - currentIndex = std::get<2>(p); - } - } else { - currentFiles.push_back( - {QStringList(std::get<0>(p).begin() + 1, std::get<0>(p).end()), - std::get<1>(p), std::get<2>(p)}); - } - } - - if (currentName != "") { - entries.push_back(std::make_shared( - parent, currentName, currentIndex, std::move(currentFiles))); - } - - // Let the parent class sort the entries: - return false; - } - - std::shared_ptr doClone() const override - { - return std::make_shared(nullptr, name(), m_Index, m_Files); - } - -private: - mutable std::vector m_Files; -}; - -std::shared_ptr ArchiveFileTree::makeTree(Archive const& archive) -{ - auto const& data = archive.getFileList(); - - std::vector files; - files.reserve(data.size()); - - for (size_t i = 0; i < data.size(); ++i) { - // Ignore "." and ".." as they're useless and muck things up - if (data[i]->getArchiveFilePath().compare(L".") == 0 || - data[i]->getArchiveFilePath().compare(L"..") == 0) { - continue; - } - - files.push_back( - std::make_tuple(QString::fromStdWString(data[i]->getArchiveFilePath()) - .replace("\\", "/") - .split("/", Qt::SkipEmptyParts), - data[i]->isDirectory(), (int)i)); - } - - auto tree = std::make_shared(nullptr, "", -1, std::move(files)); - return tree; -} - -/** - * @brief Recursive function for the ArchiveFileTree::mapToArchive method. Need a - * template here because iterators from a vector of entries are not exactly the same as - * the iterators returned by a IFileTree. - * - */ -template -void mapToArchive(std::vector const& data, It begin, It end) -{ - for (auto it = begin; it != end; ++it) { - auto entry = *it; - auto* aentry = dynamic_cast(entry.get()); - - if (aentry->m_Index != -1) { - data[aentry->m_Index]->addOutputFilePath(aentry->path().toStdWString()); - } - - if (entry->isDir()) { - auto tree = entry->astree(); - mapToArchive(data, tree->begin(), tree->end()); - } - } -} - -void ArchiveFileTree::mapToArchive( - Archive& archive, std::vector> const& entries) -{ - ::mapToArchive(archive.getFileList(), entries.cbegin(), entries.cend()); -} +/* +Copyright (C) MO2 Team. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +// For QObject::tr: +#include + +#include "archivefiletree.h" + +#include "log.h" + +using namespace MOBase; + +/** + * We use custom file entries to store the index. + */ +class ArchiveFileEntry : public virtual FileTreeEntry +{ +public: + /** + * @brief Create a new entry. + * + * @param parent The tree containing this entry. + * @param name The name of this entry. + * @param index The index of the entry in the archive. + */ + ArchiveFileEntry(std::shared_ptr parent, QString name, int index) + : FileTreeEntry(parent, name), m_Index(index) + {} + + std::shared_ptr clone() const override + { + return std::make_shared(nullptr, name(), m_Index); + } + + // No private since we are in an implementation file: + const int m_Index; +}; + +/** + * + */ +class ArchiveFileTreeImpl : public virtual ArchiveFileTree, + public virtual ArchiveFileEntry +{ +public: + using File = std::tuple; + +public + : // Public for make_shared (but not accessible by other since not exposed in .h): + ArchiveFileTreeImpl(std::shared_ptr parent, QString name, int index, + std::vector files) + : FileTreeEntry(parent, name), ArchiveFileEntry(parent, name, index), + m_Files(std::move(files)) + {} + +public: // Override to avoid VS warnings: + std::shared_ptr astree() override { return IFileTree::astree(); } + + std::shared_ptr astree() const override + { + return IFileTree::astree(); + } + +protected: + std::shared_ptr clone() const override + { + return IFileTree::clone(); + } + +public: // Overrides: + /** + * + */ + static void mapToArchive(IFileTree const& tree, QString path, + std::vector const& data) + { + if (path.length() > 0) { + // when using a long windows path (starting with \\?\) we apparently can have + // redundant . components in the path. This wasn't a problem with "regular" path + // names. + if (path == ".") { + path.clear(); + } else { + path.append("/"); + } + } + + for (auto const& entry : tree) { + if (entry->isDir()) { + const ArchiveFileTreeImpl& archiveEntry = + dynamic_cast(*entry); + QString const tmp = path + archiveEntry.name(); + if (archiveEntry.m_Index != -1) { + data[archiveEntry.m_Index]->addOutputFilePath(tmp.toStdWString()); + } + mapToArchive(*archiveEntry.astree(), tmp, data); + } else { + const ArchiveFileEntry& archiveFileEntry = + dynamic_cast(*entry); + if (archiveFileEntry.m_Index != -1) { + data[archiveFileEntry.m_Index]->addOutputFilePath( + (path + archiveFileEntry.name()).toStdWString()); + } + } + } + } + + /** + * + */ + void mapToArchive(Archive& archive) const override + { + mapToArchive(*this, "", archive.getFileList()); + } + +protected: + /** + * Overriding makeDirectory and makeFile to create file tree or file entry with index + * -1. + * + */ + std::shared_ptr + makeDirectory(std::shared_ptr parent, QString name) const override + { + return std::make_shared(parent, name, -1, std::vector{}); + } + + std::shared_ptr + makeFile(std::shared_ptr parent, QString name) const override + { + return std::make_shared(parent, name, -1); + } + + bool + doPopulate(std::shared_ptr parent, + std::vector>& entries) const override + { + + // Sort by name: + std::sort(std::begin(m_Files), std::end(m_Files), [](const auto& a, const auto& b) { + return std::get<0>(a)[0].compare(std::get<0>(b)[0], Qt::CaseInsensitive) < 0; + }); + + // We know that the files are sorted: + QString currentName = ""; + int currentIndex = -1; + std::vector currentFiles; + for (auto& p : m_Files) { + + // At the start or if we have reset, just retrieve the current name: + if (currentName == "") { + currentName = std::get<0>(p)[0]; + } + + // If the name is different, we need to create a directory from what we have + // accumulated: + if (currentName != std::get<0>(p)[0]) { + + // We may or may not have an index here, it depends on the type of archive (some + // archives list intermediate non-empty folders, some don't): + entries.push_back(std::make_shared( + parent, currentName, currentIndex, std::move(currentFiles))); + + currentFiles.clear(); // Back to a valid state. + + // Reset the index: + currentIndex = -1; + } + + // We can always override the current name: + currentName = std::get<0>(p)[0]; + + // If the current path contains only one components: + if (std::get<0>(p).size() == 1) { + + // If it is not a directory, then it is a file in directly under this tree: + if (!std::get<1>(p)) { + entries.push_back( + std::make_shared(parent, currentName, std::get<2>(p))); + currentName = ""; + } else { + // Otherwize, it is the actual "file" corresponding to the directory we are + // listing, so we can retrieve the index here: + currentIndex = std::get<2>(p); + } + } else { + currentFiles.emplace_back(QStringList(std::get<0>(p).begin() + 1, std::get<0>(p).end()), + std::get<1>(p), std::get<2>(p)); + } + } + + if (currentName != "") { + entries.push_back(std::make_shared( + parent, currentName, currentIndex, std::move(currentFiles))); + } + + // Let the parent class sort the entries: + return false; + } + + std::shared_ptr doClone() const override + { + return std::make_shared(nullptr, name(), m_Index, m_Files); + } + +private: + mutable std::vector m_Files; +}; + +std::shared_ptr ArchiveFileTree::makeTree(Archive const& archive) +{ + auto const& data = archive.getFileList(); + + std::vector files; + files.reserve(data.size()); + + for (size_t i = 0; i < data.size(); ++i) { + // Ignore "." and ".." as they're useless and muck things up + if (data[i]->getArchiveFilePath().compare(L".") == 0 || + data[i]->getArchiveFilePath().compare(L"..") == 0) { + continue; + } + + files.emplace_back(QString::fromStdWString(data[i]->getArchiveFilePath()) + .replace("\\", "/") + .split("/", Qt::SkipEmptyParts), + data[i]->isDirectory(), (int)i); + } + + auto tree = std::make_shared(nullptr, "", -1, std::move(files)); + return tree; +} + +/** + * @brief Recursive function for the ArchiveFileTree::mapToArchive method. Need a + * template here because iterators from a vector of entries are not exactly the same as + * the iterators returned by a IFileTree. + * + */ +template +void mapToArchive(std::vector const& data, It begin, It end) +{ + for (auto it = begin; it != end; ++it) { + auto entry = *it; + const auto* aentry = dynamic_cast(entry.get()); + + if (aentry->m_Index != -1) { + data[aentry->m_Index]->addOutputFilePath(aentry->path().toStdWString()); + } + + if (entry->isDir()) { + auto tree = entry->astree(); + mapToArchive(data, tree->begin(), tree->end()); + } + } +} + +void ArchiveFileTree::mapToArchive( + Archive& archive, std::vector> const& entries) +{ + ::mapToArchive(archive.getFileList(), entries.cbegin(), entries.cend()); +} diff --git a/src/src/categories.cpp b/src/src/categories.cpp index 64d5b0d..d17db8e 100644 --- a/src/src/categories.cpp +++ b/src/src/categories.cpp @@ -1,519 +1,518 @@ -/* -Copyright (C) 2012 Sebastian Herbord. All rights reserved. - -This file is part of Mod Organizer. - -Mod Organizer is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -Mod Organizer is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with Mod Organizer. If not, see . -*/ - -#include "categories.h" - -#include -#include -#include - -#include -#include -#include -#include -#include - -#include "nexusinterface.h" - -using namespace MOBase; - -CategoryFactory* CategoryFactory::s_Instance = nullptr; - -QString CategoryFactory::categoriesFilePath() -{ - return qApp->property("dataPath").toString() + "/categories.dat"; -} - -CategoryFactory::CategoryFactory() -{ - atexit(&cleanup); -} - -QString CategoryFactory::nexusMappingFilePath() -{ - return qApp->property("dataPath").toString() + "/nexuscatmap.dat"; -} - -void CategoryFactory::loadCategories() -{ - reset(); - - QFile categoryFile(categoriesFilePath()); - bool needLoad = false; - - if (!categoryFile.open(QIODevice::ReadOnly)) { - needLoad = true; - } else { - int lineNum = 0; - while (!categoryFile.atEnd()) { - QByteArray const line = categoryFile.readLine(); - ++lineNum; - QList cells = line.split('|'); - if (cells.count() == 4) { - std::vector nexusCats; - if (cells[2].length() > 0) { - QList nexusIDStrings = cells[2].split(','); - for (QList::iterator iter = nexusIDStrings.begin(); - iter != nexusIDStrings.end(); ++iter) { - bool ok = false; - int const temp = iter->toInt(&ok); - if (!ok) { - log::error(tr("invalid category id {0}"), iter->constData()); - } - nexusCats.push_back(NexusCategory("Unknown", temp)); - } - } - bool cell0Ok = true; - bool cell3Ok = true; - int const id = cells[0].toInt(&cell0Ok); - int const parentID = cells[3].trimmed().toInt(&cell3Ok); - if (!cell0Ok || !cell3Ok) { - log::error(tr("invalid category line {0}: {1}"), lineNum, line.constData()); - } - addCategory(id, QString::fromUtf8(cells[1].constData()), nexusCats, parentID); - } else if (cells.count() == 3) { - bool cell0Ok = true; - bool cell3Ok = true; - int const id = cells[0].toInt(&cell0Ok); - int const parentID = cells[2].trimmed().toInt(&cell3Ok); - if (!cell0Ok || !cell3Ok) { - log::error(tr("invalid category line {0}: {1}"), lineNum, line.constData()); - } - - addCategory(id, QString::fromUtf8(cells[1].constData()), - std::vector(), parentID); - } else { - log::error(tr("invalid category line {0}: {1} ({2} cells)"), lineNum, - line.constData(), cells.count()); - } - } - categoryFile.close(); - - QFile nexusMapFile(nexusMappingFilePath()); - if (!nexusMapFile.open(QIODevice::ReadOnly)) { - needLoad = true; - } else { - int nexLineNum = 0; - while (!nexusMapFile.atEnd()) { - QByteArray const nexLine = nexusMapFile.readLine(); - ++nexLineNum; - QList nexCells = nexLine.split('|'); - if (nexCells.count() == 3) { - std::vector const nexusCats; - QString const nexName = nexCells[1]; - bool ok = false; - int const nexID = nexCells[2].toInt(&ok); - if (!ok) { - log::error(tr("invalid nexus ID {}"), nexCells[2].constData()); - } - int const catID = nexCells[0].toInt(&ok); - if (!ok) { - log::error(tr("invalid category id {}"), nexCells[0].constData()); - } - m_NexusMap.insert_or_assign(nexID, NexusCategory(nexName, nexID)); - m_NexusMap.at(nexID).setCategoryID(catID); - } else { - log::error(tr("invalid nexus category line {0}: {1} ({2} cells)"), lineNum, - nexLine.constData(), nexCells.count()); - } - } - } - nexusMapFile.close(); - } - std::sort(m_Categories.begin(), m_Categories.end()); - setParents(); - if (needLoad) - loadDefaultCategories(); -} - -CategoryFactory& CategoryFactory::instance() -{ - static CategoryFactory s_Instance; - return s_Instance; -} - -void CategoryFactory::reset() -{ - m_Categories.clear(); - m_NexusMap.clear(); - m_IDMap.clear(); - addCategory(0, "None", std::vector(), 0); -} - -void CategoryFactory::setParents() -{ - for (auto& category : m_Categories) { - category.setHasChildren(false); - } - - for (const auto& category : m_Categories) { - if (category.parentID() != 0) { - std::map::const_iterator const iter = - m_IDMap.find(category.parentID()); - if (iter != m_IDMap.end()) { - m_Categories[iter->second].setHasChildren(true); - } - } - } -} - -void CategoryFactory::cleanup() -{ - delete s_Instance; - s_Instance = nullptr; -} - -void CategoryFactory::saveCategories() -{ - QFile categoryFile(categoriesFilePath()); - - if (!categoryFile.open(QIODevice::WriteOnly)) { - reportError(tr("Failed to save custom categories")); - return; - } - - categoryFile.resize(0); - for (const auto& category : m_Categories) { - if (category.ID() == 0) { - continue; - } - QByteArray line; - line.append(QByteArray::number(category.ID())) - .append("|") - .append(category.name().toUtf8()) - .append("|") - .append(QByteArray::number(category.parentID())) - .append("\n"); - categoryFile.write(line); - } - categoryFile.close(); - - QFile nexusMapFile(nexusMappingFilePath()); - - if (!nexusMapFile.open(QIODevice::WriteOnly)) { - reportError(tr("Failed to save nexus category mappings")); - return; - } - - nexusMapFile.resize(0); - for (const auto& nexMap : m_NexusMap) { - QByteArray line; - line.append(QByteArray::number(nexMap.second.categoryID())).append("|"); - line.append(nexMap.second.name().toUtf8()).append("|"); - line.append(QByteArray::number(nexMap.second.ID())).append("\n"); - nexusMapFile.write(line); - } - nexusMapFile.close(); - - emit categoriesSaved(); -} - -unsigned int -CategoryFactory::countCategories(std::function filter) -{ - unsigned int result = 0; - for (const Category& cat : m_Categories) { - if (filter(cat)) { - ++result; - } - } - return result; -} - -int CategoryFactory::addCategory(const QString& name, - const std::vector& nexusCats, - int parentID) -{ - int id = 1; - while (m_IDMap.contains(id)) { - ++id; - } - addCategory(id, name, nexusCats, parentID); - - saveCategories(); - return id; -} - -void CategoryFactory::addCategory(int id, const QString& name, int parentID) -{ - int const index = static_cast(m_Categories.size()); - m_Categories.push_back( - Category(index, id, name, parentID, std::vector())); - m_IDMap[id] = index; -} - -void CategoryFactory::addCategory(int id, const QString& name, - const std::vector& nexusCats, - int parentID) -{ - for (const auto& nexusCat : nexusCats) { - m_NexusMap.insert_or_assign(nexusCat.ID(), nexusCat); - m_NexusMap.at(nexusCat.ID()).setCategoryID(id); - } - int const index = static_cast(m_Categories.size()); - m_Categories.push_back(Category(index, id, name, parentID, nexusCats)); - m_IDMap[id] = index; -} - -void CategoryFactory::setNexusCategories( - const std::vector& nexusCats) -{ - for (const auto& nexusCat : nexusCats) { - m_NexusMap.emplace(nexusCat.ID(), nexusCat); - } - - saveCategories(); -} - -void CategoryFactory::refreshNexusCategories(CategoriesDialog* dialog) -{ - emit nexusCategoryRefresh(dialog); -} - -void CategoryFactory::loadDefaultCategories() -{ - // the order here is relevant as it defines the order in which the - // mods appear in the combo box - addCategory(1, "Animations", 0); - addCategory(52, "Poses", 1); - addCategory(2, "Armour", 0); - addCategory(53, "Power Armor", 2); - addCategory(3, "Audio", 0); - addCategory(38, "Music", 0); - addCategory(39, "Voice", 0); - addCategory(5, "Clothing", 0); - addCategory(41, "Jewelry", 5); - addCategory(42, "Backpacks", 5); - addCategory(6, "Collectables", 0); - addCategory(28, "Companions", 0); - addCategory(7, "Creatures, Mounts, & Vehicles", 0); - addCategory(8, "Factions", 0); - addCategory(9, "Gameplay", 0); - addCategory(27, "Combat", 9); - addCategory(43, "Crafting", 9); - addCategory(48, "Overhauls", 9); - addCategory(49, "Perks", 9); - addCategory(54, "Radio", 9); - addCategory(55, "Shouts", 9); - addCategory(22, "Skills & Levelling", 9); - addCategory(58, "Weather & Lighting", 9); - addCategory(44, "Equipment", 43); - addCategory(45, "Home/Settlement", 43); - addCategory(10, "Body, Face, & Hair", 0); - addCategory(39, "Tattoos", 10); - addCategory(40, "Character Presets", 0); - addCategory(11, "Items", 0); - addCategory(32, "Mercantile", 0); - addCategory(37, "Ammo", 11); - addCategory(19, "Weapons", 11); - addCategory(36, "Weapon & Armour Sets", 11); - addCategory(23, "Player Homes", 0); - addCategory(25, "Castles & Mansions", 23); - addCategory(51, "Settlements", 23); - addCategory(12, "Locations", 0); - addCategory(4, "Cities", 12); - addCategory(31, "Landscape Changes", 0); - addCategory(29, "Environment", 0); - addCategory(30, "Immersion", 0); - addCategory(20, "Magic", 0); - addCategory(21, "Models & Textures", 0); - addCategory(33, "Modders resources", 0); - addCategory(13, "NPCs", 0); - addCategory(24, "Bugfixes", 0); - addCategory(14, "Patches", 24); - addCategory(35, "Utilities", 0); - addCategory(26, "Cheats", 0); - addCategory(15, "Quests", 0); - addCategory(16, "Races & Classes", 0); - addCategory(34, "Stealth", 0); - addCategory(17, "UI", 0); - addCategory(18, "Visuals", 0); - addCategory(50, "Pip-Boy", 18); - addCategory(46, "Shader Presets", 0); - addCategory(47, "Miscellaneous", 0); -} - -int CategoryFactory::getParentID(unsigned int index) const -{ - if (index >= m_Categories.size()) { - throw MyException(tr("invalid category index: %1").arg(index)); - } - - return m_Categories[index].parentID(); -} - -bool CategoryFactory::categoryExists(int id) const -{ - return m_IDMap.contains(id); -} - -bool CategoryFactory::isDescendantOf(int id, int parentID) const -{ - // handles cycles - std::set seen; - return isDescendantOfImpl(id, parentID, seen); -} - -bool CategoryFactory::isDescendantOfImpl(int id, int parentID, - std::set& seen) const -{ - if (!seen.insert(id).second) { - log::error("cycle in category: {}", id); - return false; - } - - std::map::const_iterator const iter = m_IDMap.find(id); - - if (iter != m_IDMap.end()) { - unsigned int const index = iter->second; - if (m_Categories[index].parentID() == 0) { - return false; - } else if (m_Categories[index].parentID() == parentID) { - return true; - } else { - return isDescendantOfImpl(m_Categories[index].parentID(), parentID, seen); - } - } else { - log::warn(tr("{} is no valid category id"), id); - return false; - } -} - -bool CategoryFactory::hasChildren(unsigned int index) const -{ - if (index >= m_Categories.size()) { - throw MyException(tr("invalid category index: %1").arg(index)); - } - - return m_Categories[index].hasChildren(); -} - -QString CategoryFactory::getCategoryName(unsigned int index) const -{ - if (index >= m_Categories.size()) { - throw MyException(tr("invalid category index: %1").arg(index)); - } - - return m_Categories[index].name(); -} - -QString CategoryFactory::getSpecialCategoryName(SpecialCategories type) -{ - QString label; - switch (type) { - case Checked: - label = QObject::tr("Active"); - break; - case UpdateAvailable: - label = QObject::tr("Update available"); - break; - case HasCategory: - label = QObject::tr("Has category"); - break; - case Conflict: - label = QObject::tr("Conflicted"); - break; - case HasHiddenFiles: - label = QObject::tr("Has hidden files"); - break; - case Endorsed: - label = QObject::tr("Endorsed"); - break; - case Backup: - label = QObject::tr("Has backup"); - break; - case Managed: - label = QObject::tr("Managed"); - break; - case HasGameData: - label = QObject::tr("Has valid game data"); - break; - case HasNexusID: - label = QObject::tr("Has Nexus ID"); - break; - case Tracked: - label = QObject::tr("Tracked on Nexus"); - break; - default: - return {}; - } - return QString("<%1>").arg(label); -} - -QString CategoryFactory::getCategoryNameByID(int id) const -{ - auto itor = m_IDMap.find(id); - - if (itor == m_IDMap.end()) { - return getSpecialCategoryName(static_cast(id)); - } else { - const auto index = itor->second; - if (index >= m_Categories.size()) { - return {}; - } - - return m_Categories[index].name(); - } -} - -int CategoryFactory::getCategoryID(unsigned int index) const -{ - if (index >= m_Categories.size()) { - throw MyException(tr("invalid category index: %1").arg(index)); - } - - return m_Categories[index].ID(); -} - -int CategoryFactory::getCategoryIndex(int ID) const -{ - std::map::const_iterator const iter = m_IDMap.find(ID); - if (iter == m_IDMap.end()) { - throw MyException(tr("invalid category id: %1").arg(ID)); - } - return iter->second; -} - -int CategoryFactory::getCategoryID(const QString& name) const -{ - auto iter = std::find_if(m_Categories.begin(), m_Categories.end(), - [name](const Category& cat) -> bool { - return cat.name() == name; - }); - - if (iter != m_Categories.end()) { - return iter->ID(); - } else { - return -1; - } -} - -unsigned int CategoryFactory::resolveNexusID(int nexusID) const -{ - auto result = m_NexusMap.find(nexusID); - if (result != m_NexusMap.end()) { - if (m_IDMap.contains(result->second.categoryID())) { - log::debug(tr("nexus category id {0} maps to internal {1}"), nexusID, - m_IDMap.at(result->second.categoryID())); - return m_IDMap.at(result->second.categoryID()); - } - } - log::debug(tr("nexus category id {} not mapped"), nexusID); - return 0U; -} +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "categories.h" + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "nexusinterface.h" + +using namespace MOBase; + +CategoryFactory* CategoryFactory::s_Instance = nullptr; + +QString CategoryFactory::categoriesFilePath() +{ + return qApp->property("dataPath").toString() + "/categories.dat"; +} + +CategoryFactory::CategoryFactory() +{ + atexit(&cleanup); +} + +QString CategoryFactory::nexusMappingFilePath() +{ + return qApp->property("dataPath").toString() + "/nexuscatmap.dat"; +} + +void CategoryFactory::loadCategories() +{ + reset(); + + QFile categoryFile(categoriesFilePath()); + bool needLoad = false; + + if (!categoryFile.open(QIODevice::ReadOnly)) { + needLoad = true; + } else { + int lineNum = 0; + while (!categoryFile.atEnd()) { + QByteArray const line = categoryFile.readLine(); + ++lineNum; + QList cells = line.split('|'); + if (cells.count() == 4) { + std::vector nexusCats; + if (cells[2].length() > 0) { + QList nexusIDStrings = cells[2].split(','); + for (QList::iterator iter = nexusIDStrings.begin(); + iter != nexusIDStrings.end(); ++iter) { + bool ok = false; + int const temp = iter->toInt(&ok); + if (!ok) { + log::error(tr("invalid category id {0}"), iter->constData()); + } + nexusCats.emplace_back("Unknown", temp); + } + } + bool cell0Ok = true; + bool cell3Ok = true; + int const id = cells[0].toInt(&cell0Ok); + int const parentID = cells[3].trimmed().toInt(&cell3Ok); + if (!cell0Ok || !cell3Ok) { + log::error(tr("invalid category line {0}: {1}"), lineNum, line.constData()); + } + addCategory(id, QString::fromUtf8(cells[1].constData()), nexusCats, parentID); + } else if (cells.count() == 3) { + bool cell0Ok = true; + bool cell3Ok = true; + int const id = cells[0].toInt(&cell0Ok); + int const parentID = cells[2].trimmed().toInt(&cell3Ok); + if (!cell0Ok || !cell3Ok) { + log::error(tr("invalid category line {0}: {1}"), lineNum, line.constData()); + } + + addCategory(id, QString::fromUtf8(cells[1].constData()), + std::vector(), parentID); + } else { + log::error(tr("invalid category line {0}: {1} ({2} cells)"), lineNum, + line.constData(), cells.count()); + } + } + categoryFile.close(); + + QFile nexusMapFile(nexusMappingFilePath()); + if (!nexusMapFile.open(QIODevice::ReadOnly)) { + needLoad = true; + } else { + int nexLineNum = 0; + while (!nexusMapFile.atEnd()) { + QByteArray const nexLine = nexusMapFile.readLine(); + ++nexLineNum; + QList nexCells = nexLine.split('|'); + if (nexCells.count() == 3) { + std::vector const nexusCats; + QString const nexName = nexCells[1]; + bool ok = false; + int const nexID = nexCells[2].toInt(&ok); + if (!ok) { + log::error(tr("invalid nexus ID {}"), nexCells[2].constData()); + } + int const catID = nexCells[0].toInt(&ok); + if (!ok) { + log::error(tr("invalid category id {}"), nexCells[0].constData()); + } + m_NexusMap.insert_or_assign(nexID, NexusCategory(nexName, nexID)); + m_NexusMap.at(nexID).setCategoryID(catID); + } else { + log::error(tr("invalid nexus category line {0}: {1} ({2} cells)"), lineNum, + nexLine.constData(), nexCells.count()); + } + } + } + nexusMapFile.close(); + } + std::sort(m_Categories.begin(), m_Categories.end()); + setParents(); + if (needLoad) + loadDefaultCategories(); +} + +CategoryFactory& CategoryFactory::instance() +{ + static CategoryFactory s_Instance; + return s_Instance; +} + +void CategoryFactory::reset() +{ + m_Categories.clear(); + m_NexusMap.clear(); + m_IDMap.clear(); + addCategory(0, "None", std::vector(), 0); +} + +void CategoryFactory::setParents() +{ + for (auto& category : m_Categories) { + category.setHasChildren(false); + } + + for (const auto& category : m_Categories) { + if (category.parentID() != 0) { + std::map::const_iterator const iter = + m_IDMap.find(category.parentID()); + if (iter != m_IDMap.end()) { + m_Categories[iter->second].setHasChildren(true); + } + } + } +} + +void CategoryFactory::cleanup() +{ + delete s_Instance; + s_Instance = nullptr; +} + +void CategoryFactory::saveCategories() +{ + QFile categoryFile(categoriesFilePath()); + + if (!categoryFile.open(QIODevice::WriteOnly)) { + reportError(tr("Failed to save custom categories")); + return; + } + + categoryFile.resize(0); + for (const auto& category : m_Categories) { + if (category.ID() == 0) { + continue; + } + QByteArray line; + line.append(QByteArray::number(category.ID())) + .append("|") + .append(category.name().toUtf8()) + .append("|") + .append(QByteArray::number(category.parentID())) + .append("\n"); + categoryFile.write(line); + } + categoryFile.close(); + + QFile nexusMapFile(nexusMappingFilePath()); + + if (!nexusMapFile.open(QIODevice::WriteOnly)) { + reportError(tr("Failed to save nexus category mappings")); + return; + } + + nexusMapFile.resize(0); + for (const auto& nexMap : m_NexusMap) { + QByteArray line; + line.append(QByteArray::number(nexMap.second.categoryID())).append("|"); + line.append(nexMap.second.name().toUtf8()).append("|"); + line.append(QByteArray::number(nexMap.second.ID())).append("\n"); + nexusMapFile.write(line); + } + nexusMapFile.close(); + + emit categoriesSaved(); +} + +unsigned int +CategoryFactory::countCategories(std::function filter) +{ + unsigned int result = 0; + for (const Category& cat : m_Categories) { + if (filter(cat)) { + ++result; + } + } + return result; +} + +int CategoryFactory::addCategory(const QString& name, + const std::vector& nexusCats, + int parentID) +{ + int id = 1; + while (m_IDMap.contains(id)) { + ++id; + } + addCategory(id, name, nexusCats, parentID); + + saveCategories(); + return id; +} + +void CategoryFactory::addCategory(int id, const QString& name, int parentID) +{ + int const index = static_cast(m_Categories.size()); + m_Categories.emplace_back(index, id, name, parentID, std::vector()); + m_IDMap[id] = index; +} + +void CategoryFactory::addCategory(int id, const QString& name, + const std::vector& nexusCats, + int parentID) +{ + for (const auto& nexusCat : nexusCats) { + m_NexusMap.insert_or_assign(nexusCat.ID(), nexusCat); + m_NexusMap.at(nexusCat.ID()).setCategoryID(id); + } + int const index = static_cast(m_Categories.size()); + m_Categories.emplace_back(index, id, name, parentID, nexusCats); + m_IDMap[id] = index; +} + +void CategoryFactory::setNexusCategories( + const std::vector& nexusCats) +{ + for (const auto& nexusCat : nexusCats) { + m_NexusMap.emplace(nexusCat.ID(), nexusCat); + } + + saveCategories(); +} + +void CategoryFactory::refreshNexusCategories(CategoriesDialog* dialog) +{ + emit nexusCategoryRefresh(dialog); +} + +void CategoryFactory::loadDefaultCategories() +{ + // the order here is relevant as it defines the order in which the + // mods appear in the combo box + addCategory(1, "Animations", 0); + addCategory(52, "Poses", 1); + addCategory(2, "Armour", 0); + addCategory(53, "Power Armor", 2); + addCategory(3, "Audio", 0); + addCategory(38, "Music", 0); + addCategory(39, "Voice", 0); + addCategory(5, "Clothing", 0); + addCategory(41, "Jewelry", 5); + addCategory(42, "Backpacks", 5); + addCategory(6, "Collectables", 0); + addCategory(28, "Companions", 0); + addCategory(7, "Creatures, Mounts, & Vehicles", 0); + addCategory(8, "Factions", 0); + addCategory(9, "Gameplay", 0); + addCategory(27, "Combat", 9); + addCategory(43, "Crafting", 9); + addCategory(48, "Overhauls", 9); + addCategory(49, "Perks", 9); + addCategory(54, "Radio", 9); + addCategory(55, "Shouts", 9); + addCategory(22, "Skills & Levelling", 9); + addCategory(58, "Weather & Lighting", 9); + addCategory(44, "Equipment", 43); + addCategory(45, "Home/Settlement", 43); + addCategory(10, "Body, Face, & Hair", 0); + addCategory(39, "Tattoos", 10); + addCategory(40, "Character Presets", 0); + addCategory(11, "Items", 0); + addCategory(32, "Mercantile", 0); + addCategory(37, "Ammo", 11); + addCategory(19, "Weapons", 11); + addCategory(36, "Weapon & Armour Sets", 11); + addCategory(23, "Player Homes", 0); + addCategory(25, "Castles & Mansions", 23); + addCategory(51, "Settlements", 23); + addCategory(12, "Locations", 0); + addCategory(4, "Cities", 12); + addCategory(31, "Landscape Changes", 0); + addCategory(29, "Environment", 0); + addCategory(30, "Immersion", 0); + addCategory(20, "Magic", 0); + addCategory(21, "Models & Textures", 0); + addCategory(33, "Modders resources", 0); + addCategory(13, "NPCs", 0); + addCategory(24, "Bugfixes", 0); + addCategory(14, "Patches", 24); + addCategory(35, "Utilities", 0); + addCategory(26, "Cheats", 0); + addCategory(15, "Quests", 0); + addCategory(16, "Races & Classes", 0); + addCategory(34, "Stealth", 0); + addCategory(17, "UI", 0); + addCategory(18, "Visuals", 0); + addCategory(50, "Pip-Boy", 18); + addCategory(46, "Shader Presets", 0); + addCategory(47, "Miscellaneous", 0); +} + +int CategoryFactory::getParentID(unsigned int index) const +{ + if (index >= m_Categories.size()) { + throw MyException(tr("invalid category index: %1").arg(index)); + } + + return m_Categories[index].parentID(); +} + +bool CategoryFactory::categoryExists(int id) const +{ + return m_IDMap.contains(id); +} + +bool CategoryFactory::isDescendantOf(int id, int parentID) const +{ + // handles cycles + std::set seen; + return isDescendantOfImpl(id, parentID, seen); +} + +bool CategoryFactory::isDescendantOfImpl(int id, int parentID, + std::set& seen) const +{ + if (!seen.insert(id).second) { + log::error("cycle in category: {}", id); + return false; + } + + std::map::const_iterator const iter = m_IDMap.find(id); + + if (iter != m_IDMap.end()) { + unsigned int const index = iter->second; + if (m_Categories[index].parentID() == 0) { + return false; + } else if (m_Categories[index].parentID() == parentID) { + return true; + } else { + return isDescendantOfImpl(m_Categories[index].parentID(), parentID, seen); + } + } else { + log::warn(tr("{} is no valid category id"), id); + return false; + } +} + +bool CategoryFactory::hasChildren(unsigned int index) const +{ + if (index >= m_Categories.size()) { + throw MyException(tr("invalid category index: %1").arg(index)); + } + + return m_Categories[index].hasChildren(); +} + +QString CategoryFactory::getCategoryName(unsigned int index) const +{ + if (index >= m_Categories.size()) { + throw MyException(tr("invalid category index: %1").arg(index)); + } + + return m_Categories[index].name(); +} + +QString CategoryFactory::getSpecialCategoryName(SpecialCategories type) +{ + QString label; + switch (type) { + case Checked: + label = QObject::tr("Active"); + break; + case UpdateAvailable: + label = QObject::tr("Update available"); + break; + case HasCategory: + label = QObject::tr("Has category"); + break; + case Conflict: + label = QObject::tr("Conflicted"); + break; + case HasHiddenFiles: + label = QObject::tr("Has hidden files"); + break; + case Endorsed: + label = QObject::tr("Endorsed"); + break; + case Backup: + label = QObject::tr("Has backup"); + break; + case Managed: + label = QObject::tr("Managed"); + break; + case HasGameData: + label = QObject::tr("Has valid game data"); + break; + case HasNexusID: + label = QObject::tr("Has Nexus ID"); + break; + case Tracked: + label = QObject::tr("Tracked on Nexus"); + break; + default: + return {}; + } + return QString("<%1>").arg(label); +} + +QString CategoryFactory::getCategoryNameByID(int id) const +{ + auto itor = m_IDMap.find(id); + + if (itor == m_IDMap.end()) { + return getSpecialCategoryName(static_cast(id)); + } else { + const auto index = itor->second; + if (index >= m_Categories.size()) { + return {}; + } + + return m_Categories[index].name(); + } +} + +int CategoryFactory::getCategoryID(unsigned int index) const +{ + if (index >= m_Categories.size()) { + throw MyException(tr("invalid category index: %1").arg(index)); + } + + return m_Categories[index].ID(); +} + +int CategoryFactory::getCategoryIndex(int ID) const +{ + std::map::const_iterator const iter = m_IDMap.find(ID); + if (iter == m_IDMap.end()) { + throw MyException(tr("invalid category id: %1").arg(ID)); + } + return iter->second; +} + +int CategoryFactory::getCategoryID(const QString& name) const +{ + auto iter = std::find_if(m_Categories.begin(), m_Categories.end(), + [name](const Category& cat) -> bool { + return cat.name() == name; + }); + + if (iter != m_Categories.end()) { + return iter->ID(); + } else { + return -1; + } +} + +unsigned int CategoryFactory::resolveNexusID(int nexusID) const +{ + auto result = m_NexusMap.find(nexusID); + if (result != m_NexusMap.end()) { + if (m_IDMap.contains(result->second.categoryID())) { + log::debug(tr("nexus category id {0} maps to internal {1}"), nexusID, + m_IDMap.at(result->second.categoryID())); + return m_IDMap.at(result->second.categoryID()); + } + } + log::debug(tr("nexus category id {} not mapped"), nexusID); + return 0U; +} diff --git a/src/src/categoriesdialog.cpp b/src/src/categoriesdialog.cpp index 0d85f4d..0706768 100644 --- a/src/src/categoriesdialog.cpp +++ b/src/src/categoriesdialog.cpp @@ -1,399 +1,399 @@ -/* -Copyright (C) 2012 Sebastian Herbord. All rights reserved. - -This file is part of Mod Organizer. - -Mod Organizer is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -Mod Organizer is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with Mod Organizer. If not, see . -*/ - -#include "categoriesdialog.h" -#include "categories.h" -#include "categoryimportdialog.h" -#include "messagedialog.h" -#include "nexusinterface.h" -#include "settings.h" -#include "ui_categoriesdialog.h" -#include "utility.h" -#include -#include -#include -#include -#include - -class NewIDValidator : public QIntValidator -{ -public: - NewIDValidator(const std::set& ids) : m_UsedIDs(ids) {} - State validate(QString& input, int& pos) const override - { - State const intRes = QIntValidator::validate(input, pos); - if (intRes == Acceptable) { - bool ok = false; - int const id = input.toInt(&ok); - if (m_UsedIDs.contains(id)) { - return QValidator::Intermediate; - } - } - return intRes; - } - -private: - const std::set& m_UsedIDs; -}; - -class ExistingIDValidator : public QIntValidator -{ -public: - ExistingIDValidator(const std::set& ids) : m_UsedIDs(ids) {} - State validate(QString& input, int& pos) const override - { - State const intRes = QIntValidator::validate(input, pos); - if (intRes == Acceptable) { - bool ok = false; - int const id = input.toInt(&ok); - if ((id == 0) || (m_UsedIDs.contains(id))) { - return QValidator::Acceptable; - } else { - return QValidator::Intermediate; - } - } else { - return intRes; - } - } - -private: - const std::set& m_UsedIDs; -}; - -class ValidatingDelegate : public QItemDelegate -{ - -public: - ValidatingDelegate(QObject* parent, QValidator* validator) - : QItemDelegate(parent), m_Validator(validator) - {} - - QWidget* createEditor(QWidget* parent, const QStyleOptionViewItem&, - const QModelIndex&) const override - { - QLineEdit* edit = new QLineEdit(parent); - edit->setValidator(m_Validator); - return edit; - } - void setModelData(QWidget* editor, QAbstractItemModel* model, - const QModelIndex& index) const override - { - QLineEdit* edit = qobject_cast(editor); - int pos = 0; - QString editText = edit->text(); - if (m_Validator->validate(editText, pos) == QValidator::Acceptable) { - // Set ID and Parent ID as ints so sorting works as expected - if (index.column() == 0 || index.column() == 2) { - model->setData(index, editText.toInt()); - return; - } - QItemDelegate::setModelData(editor, model, index); - } - } - -private: - QValidator* m_Validator; -}; - -CategoriesDialog::CategoriesDialog(QWidget* parent) - : TutorableDialog("Categories", parent), ui(new Ui::CategoriesDialog) -{ - ui->setupUi(this); - fillTable(); - connect(ui->categoriesTable, SIGNAL(cellChanged(int, int)), this, - SLOT(cellChanged(int, int))); - if (Settings::instance().nexus().categoryMappings()) { - connect(ui->nexusRefresh, SIGNAL(clicked()), this, SLOT(nexusRefresh_clicked())); - connect(ui->nexusImportButton, SIGNAL(clicked()), this, - SLOT(nexusImport_clicked())); - ui->nexusCategoryList->setDisabled(false); - } else { - ui->nexusCategoryList->setDisabled(true); - } -} - -CategoriesDialog::~CategoriesDialog() -{ - delete ui; -} - -int CategoriesDialog::exec() -{ - GeometrySaver const gs(Settings::instance(), this); - return QDialog::exec(); -} - -void CategoriesDialog::cellChanged(int row, int) -{ - int const currentID = ui->categoriesTable->item(row, 0)->text().toInt(); - if (currentID > m_HighestID) { - m_HighestID = currentID; - } -} - -void CategoriesDialog::commitChanges() -{ - CategoryFactory& categories = CategoryFactory::instance(); - categories.reset(); - - for (int i = 0; i < ui->categoriesTable->rowCount(); ++i) { - int const index = ui->categoriesTable->verticalHeader()->logicalIndex(i); - QVariantList nexusData; - if (ui->categoriesTable->item(index, 3) != nullptr) - nexusData = ui->categoriesTable->item(index, 3)->data(Qt::UserRole).toList(); - std::vector nexusCats; - for (const auto& nexusCat : nexusData) { - nexusCats.push_back(CategoryFactory::NexusCategory( - nexusCat.toList()[0].toString(), nexusCat.toList()[1].toInt())); - } - - categories.addCategory(ui->categoriesTable->item(index, 0)->text().toInt(), - ui->categoriesTable->item(index, 1)->text(), nexusCats, - ui->categoriesTable->item(index, 2)->text().toInt()); - } - - categories.setParents(); - - std::vector nexusCats; - for (int i = 0; i < ui->nexusCategoryList->count(); ++i) { - nexusCats.push_back(CategoryFactory::NexusCategory( - ui->nexusCategoryList->item(i)->data(Qt::DisplayRole).toString(), - ui->nexusCategoryList->item(i)->data(Qt::UserRole).toInt())); - } - - categories.setNexusCategories(nexusCats); - - categories.saveCategories(); -} - -void CategoriesDialog::refreshIDs() -{ - m_HighestID = 0; - for (int i = 0; i < ui->categoriesTable->rowCount(); ++i) { - int const id = ui->categoriesTable->item(i, 0)->text().toInt(); - if (id > m_HighestID) { - m_HighestID = id; - } - m_IDs.insert(id); - } -} - -void CategoriesDialog::fillTable() -{ - CategoryFactory const& categories = CategoryFactory::instance(); - QTableWidget* table = ui->categoriesTable; - QListWidget* list = ui->nexusCategoryList; - - table->horizontalHeader()->setSectionResizeMode(0, QHeaderView::Fixed); - table->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch); - table->horizontalHeader()->setSectionResizeMode(2, QHeaderView::Fixed); - table->horizontalHeader()->setSectionResizeMode(3, QHeaderView::Stretch); - table->verticalHeader()->setSectionResizeMode(QHeaderView::Fixed); - table->verticalHeader()->setVisible(false); - table->setItemDelegateForColumn( - 0, new ValidatingDelegate(this, new NewIDValidator(m_IDs))); - table->setItemDelegateForColumn( - 2, new ValidatingDelegate(this, new ExistingIDValidator(m_IDs))); - table->setItemDelegateForColumn( - 3, new ValidatingDelegate(this, - new QRegularExpressionValidator( - QRegularExpression("([0-9]+)?(,[0-9]+)*"), this))); - - int row = 0; - for (const auto& category : categories.m_Categories) { - if (category.ID() == 0) { - --row; - continue; - } - ++row; - table->insertRow(row); - - auto idItem = std::make_unique(); - idItem->setData(Qt::DisplayRole, category.ID()); - - auto nameItem = std::make_unique(category.name()); - auto parentIDItem = std::make_unique(); - parentIDItem->setData(Qt::DisplayRole, category.parentID()); - auto nexusCatItem = std::make_unique(); - - table->setItem(row, 0, idItem.release()); - table->setItem(row, 1, nameItem.release()); - table->setItem(row, 2, parentIDItem.release()); - table->setItem(row, 3, nexusCatItem.release()); - } - - for (const auto& nexusCat : categories.m_NexusMap) { - auto nexusItem = std::make_unique(); - nexusItem->setData(Qt::DisplayRole, nexusCat.second.name()); - nexusItem->setData(Qt::UserRole, nexusCat.second.ID()); - list->addItem(nexusItem.release()); - auto item = table->item(categories.resolveNexusID(nexusCat.first) - 1, 3); - if (item != nullptr) { - auto itemData = item->data(Qt::UserRole).toList(); - QVariantList newData; - newData.append(nexusCat.second.name()); - newData.append(nexusCat.second.ID()); - itemData.insert(itemData.length(), newData); - QStringList names; - for (const auto& cat : itemData) { - names.append(cat.toList()[0].toString()); - } - item->setData(Qt::UserRole, itemData); - item->setData(Qt::DisplayRole, names.join(", ")); - } - } - - table->setSortingEnabled(true); - refreshIDs(); -} - -void CategoriesDialog::addCategory_clicked() -{ - int const row = m_ContextRow >= 0 ? m_ContextRow : 0; - ui->categoriesTable->setSortingEnabled(false); - ui->categoriesTable->insertRow(row); - - auto idItem = std::make_unique(); - idItem->setData(Qt::DisplayRole, ++m_HighestID); - auto parentIDItem = std::make_unique(); - parentIDItem->setData(Qt::DisplayRole, 0); - - ui->categoriesTable->setItem(row, 0, idItem.release()); - ui->categoriesTable->setItem(row, 1, new QTableWidgetItem("new")); - ui->categoriesTable->setItem(row, 2, parentIDItem.release()); - ui->categoriesTable->setItem(row, 3, new QTableWidgetItem("")); - ui->categoriesTable->setSortingEnabled(true); -} - -void CategoriesDialog::removeCategory_clicked() -{ - if (m_ContextRow >= 0) - ui->categoriesTable->removeRow(m_ContextRow); -} - -void CategoriesDialog::removeNexusMap_clicked() -{ - if (m_ContextRow >= 0) { - ui->categoriesTable->item(m_ContextRow, 3)->setData(Qt::UserRole, QVariantList()); - ui->categoriesTable->item(m_ContextRow, 3)->setData(Qt::DisplayRole, QString()); - } -} - -void CategoriesDialog::nexusRefresh_clicked() -{ - CategoryFactory::instance().refreshNexusCategories(this); -} - -void CategoriesDialog::nexusImport_clicked() -{ - auto importDialog = CategoryImportDialog(this); - if (importDialog.exec() && importDialog.strategy()) { - refreshIDs(); - QTableWidget* table = ui->categoriesTable; - QListWidget* list = ui->nexusCategoryList; - if (importDialog.strategy() == CategoryImportDialog::Overwrite) { - table->setRowCount(0); - m_HighestID = 0; - } - int row = 0; - table->setSortingEnabled(false); - for (int i = 0; i < list->count(); ++i) { - QString const name = list->item(i)->data(Qt::DisplayRole).toString(); - int const nexusID = list->item(i)->data(Qt::UserRole).toInt(); - QStringList nexusLabel; - QVariantList nexusData; - nexusLabel.append(name); - QVariantList data; - data.append(QVariant(name)); - data.append(QVariant(nexusID)); - nexusData.insert(nexusData.size(), data); - auto nexusCatItem = std::make_unique(nexusLabel.join(", ")); - nexusCatItem->setData(Qt::UserRole, nexusData); - if (table->findItems(name, Qt::MatchExactly).empty()) { - row = table->rowCount(); - table->insertRow(table->rowCount()); - - auto idItem = std::make_unique(); - idItem->setData(Qt::DisplayRole, ++m_HighestID); - - auto nameItem = std::make_unique(name); - auto parentIDItem = std::make_unique(); - parentIDItem->setData(Qt::DisplayRole, 0); // No parent - - table->setItem(row, 0, idItem.release()); - table->setItem(row, 1, nameItem.release()); - table->setItem(row, 2, parentIDItem.release()); - - if (importDialog.assign()) { - table->setItem(row, 3, nexusCatItem.release()); - } - } else { - for (auto item : table->findItems(name, Qt::MatchContains | Qt::MatchWrap)) { - if (item->column() == 1 && item->text() == name && importDialog.remap()) { - table->setItem(item->row(), 3, nexusCatItem.release()); - } else if (importDialog.remap()) { - auto blankItem = std::make_unique(); - blankItem->setData(Qt::UserRole, QVariantList()); - table->setItem(item->row(), 3, blankItem.release()); - } - } - } - } - table->setSortingEnabled(true); - refreshIDs(); - } -} - -void CategoriesDialog::nxmGameInfoAvailable(QString gameName, QVariant, - QVariant resultData, int) -{ - QVariantMap result = resultData.toMap(); - QVariantList const categories = result["categories"].toList(); - CategoryFactory const& catFactory = CategoryFactory::instance(); - QListWidget* list = ui->nexusCategoryList; - list->clear(); - for (const auto& category : categories) { - auto catMap = category.toMap(); - auto nexusItem = std::make_unique(); - nexusItem->setData(Qt::DisplayRole, catMap["name"].toString()); - nexusItem->setData(Qt::UserRole, catMap["category_id"].toInt()); - list->addItem(nexusItem.release()); - } -} - -void CategoriesDialog::nxmRequestFailed(QString, int, int, QVariant, int, int errorCode, - const QString& errorMessage) -{ - MessageDialog::showMessage( - tr("Error %1: Request to Nexus failed: %2").arg(errorCode).arg(errorMessage), - this); -} - -void CategoriesDialog::on_categoriesTable_customContextMenuRequested(const QPoint& pos) -{ - m_ContextRow = ui->categoriesTable->rowAt(pos.y()); - QMenu menu; - menu.addAction(tr("Add"), this, SLOT(addCategory_clicked())); - menu.addAction(tr("Remove"), this, SLOT(removeCategory_clicked())); - if (Settings::instance().nexus().categoryMappings()) { - menu.addAction(tr("Remove Nexus Mapping(s)"), this, SLOT(removeNexusMap_clicked())); - } - - menu.exec(ui->categoriesTable->mapToGlobal(pos)); -} +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "categoriesdialog.h" +#include "categories.h" +#include "categoryimportdialog.h" +#include "messagedialog.h" +#include "nexusinterface.h" +#include "settings.h" +#include "ui_categoriesdialog.h" +#include "utility.h" +#include +#include +#include +#include +#include + +class NewIDValidator : public QIntValidator +{ +public: + NewIDValidator(const std::set& ids) : m_UsedIDs(ids) {} + State validate(QString& input, int& pos) const override + { + State const intRes = QIntValidator::validate(input, pos); + if (intRes == Acceptable) { + bool ok = false; + int const id = input.toInt(&ok); + if (m_UsedIDs.contains(id)) { + return QValidator::Intermediate; + } + } + return intRes; + } + +private: + const std::set& m_UsedIDs; +}; + +class ExistingIDValidator : public QIntValidator +{ +public: + ExistingIDValidator(const std::set& ids) : m_UsedIDs(ids) {} + State validate(QString& input, int& pos) const override + { + State const intRes = QIntValidator::validate(input, pos); + if (intRes == Acceptable) { + bool ok = false; + int const id = input.toInt(&ok); + if ((id == 0) || (m_UsedIDs.contains(id))) { + return QValidator::Acceptable; + } else { + return QValidator::Intermediate; + } + } else { + return intRes; + } + } + +private: + const std::set& m_UsedIDs; +}; + +class ValidatingDelegate : public QItemDelegate +{ + +public: + ValidatingDelegate(QObject* parent, QValidator* validator) + : QItemDelegate(parent), m_Validator(validator) + {} + + QWidget* createEditor(QWidget* parent, const QStyleOptionViewItem&, + const QModelIndex&) const override + { + QLineEdit* edit = new QLineEdit(parent); + edit->setValidator(m_Validator); + return edit; + } + void setModelData(QWidget* editor, QAbstractItemModel* model, + const QModelIndex& index) const override + { + QLineEdit* edit = qobject_cast(editor); + int pos = 0; + QString editText = edit->text(); + if (m_Validator->validate(editText, pos) == QValidator::Acceptable) { + // Set ID and Parent ID as ints so sorting works as expected + if (index.column() == 0 || index.column() == 2) { + model->setData(index, editText.toInt()); + return; + } + QItemDelegate::setModelData(editor, model, index); + } + } + +private: + QValidator* m_Validator; +}; + +CategoriesDialog::CategoriesDialog(QWidget* parent) + : TutorableDialog("Categories", parent), ui(new Ui::CategoriesDialog) +{ + ui->setupUi(this); + fillTable(); + connect(ui->categoriesTable, SIGNAL(cellChanged(int, int)), this, + SLOT(cellChanged(int, int))); + if (Settings::instance().nexus().categoryMappings()) { + connect(ui->nexusRefresh, SIGNAL(clicked()), this, SLOT(nexusRefresh_clicked())); + connect(ui->nexusImportButton, SIGNAL(clicked()), this, + SLOT(nexusImport_clicked())); + ui->nexusCategoryList->setDisabled(false); + } else { + ui->nexusCategoryList->setDisabled(true); + } +} + +CategoriesDialog::~CategoriesDialog() +{ + delete ui; +} + +int CategoriesDialog::exec() +{ + GeometrySaver const gs(Settings::instance(), this); + return QDialog::exec(); +} + +void CategoriesDialog::cellChanged(int row, int) +{ + int const currentID = ui->categoriesTable->item(row, 0)->text().toInt(); + if (currentID > m_HighestID) { + m_HighestID = currentID; + } +} + +void CategoriesDialog::commitChanges() +{ + CategoryFactory& categories = CategoryFactory::instance(); + categories.reset(); + + for (int i = 0; i < ui->categoriesTable->rowCount(); ++i) { + int const index = ui->categoriesTable->verticalHeader()->logicalIndex(i); + QVariantList nexusData; + if (ui->categoriesTable->item(index, 3) != nullptr) + nexusData = ui->categoriesTable->item(index, 3)->data(Qt::UserRole).toList(); + std::vector nexusCats; + for (const auto& nexusCat : nexusData) { + nexusCats.emplace_back( + nexusCat.toList()[0].toString(), nexusCat.toList()[1].toInt()); + } + + categories.addCategory(ui->categoriesTable->item(index, 0)->text().toInt(), + ui->categoriesTable->item(index, 1)->text(), nexusCats, + ui->categoriesTable->item(index, 2)->text().toInt()); + } + + categories.setParents(); + + std::vector nexusCats; + for (int i = 0; i < ui->nexusCategoryList->count(); ++i) { + nexusCats.emplace_back( + ui->nexusCategoryList->item(i)->data(Qt::DisplayRole).toString(), + ui->nexusCategoryList->item(i)->data(Qt::UserRole).toInt()); + } + + categories.setNexusCategories(nexusCats); + + categories.saveCategories(); +} + +void CategoriesDialog::refreshIDs() +{ + m_HighestID = 0; + for (int i = 0; i < ui->categoriesTable->rowCount(); ++i) { + int const id = ui->categoriesTable->item(i, 0)->text().toInt(); + if (id > m_HighestID) { + m_HighestID = id; + } + m_IDs.insert(id); + } +} + +void CategoriesDialog::fillTable() +{ + CategoryFactory const& categories = CategoryFactory::instance(); + QTableWidget* table = ui->categoriesTable; + QListWidget* list = ui->nexusCategoryList; + + table->horizontalHeader()->setSectionResizeMode(0, QHeaderView::Fixed); + table->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch); + table->horizontalHeader()->setSectionResizeMode(2, QHeaderView::Fixed); + table->horizontalHeader()->setSectionResizeMode(3, QHeaderView::Stretch); + table->verticalHeader()->setSectionResizeMode(QHeaderView::Fixed); + table->verticalHeader()->setVisible(false); + table->setItemDelegateForColumn( + 0, new ValidatingDelegate(this, new NewIDValidator(m_IDs))); + table->setItemDelegateForColumn( + 2, new ValidatingDelegate(this, new ExistingIDValidator(m_IDs))); + table->setItemDelegateForColumn( + 3, new ValidatingDelegate(this, + new QRegularExpressionValidator( + QRegularExpression("([0-9]+)?(,[0-9]+)*"), this))); + + int row = 0; + for (const auto& category : categories.m_Categories) { + if (category.ID() == 0) { + --row; + continue; + } + ++row; + table->insertRow(row); + + auto idItem = std::make_unique(); + idItem->setData(Qt::DisplayRole, category.ID()); + + auto nameItem = std::make_unique(category.name()); + auto parentIDItem = std::make_unique(); + parentIDItem->setData(Qt::DisplayRole, category.parentID()); + auto nexusCatItem = std::make_unique(); + + table->setItem(row, 0, idItem.release()); + table->setItem(row, 1, nameItem.release()); + table->setItem(row, 2, parentIDItem.release()); + table->setItem(row, 3, nexusCatItem.release()); + } + + for (const auto& nexusCat : categories.m_NexusMap) { + auto nexusItem = std::make_unique(); + nexusItem->setData(Qt::DisplayRole, nexusCat.second.name()); + nexusItem->setData(Qt::UserRole, nexusCat.second.ID()); + list->addItem(nexusItem.release()); + auto *item = table->item(categories.resolveNexusID(nexusCat.first) - 1, 3); + if (item != nullptr) { + auto itemData = item->data(Qt::UserRole).toList(); + QVariantList newData; + newData.append(nexusCat.second.name()); + newData.append(nexusCat.second.ID()); + itemData.insert(itemData.length(), newData); + QStringList names; + for (const auto& cat : itemData) { + names.append(cat.toList()[0].toString()); + } + item->setData(Qt::UserRole, itemData); + item->setData(Qt::DisplayRole, names.join(", ")); + } + } + + table->setSortingEnabled(true); + refreshIDs(); +} + +void CategoriesDialog::addCategory_clicked() +{ + int const row = m_ContextRow >= 0 ? m_ContextRow : 0; + ui->categoriesTable->setSortingEnabled(false); + ui->categoriesTable->insertRow(row); + + auto idItem = std::make_unique(); + idItem->setData(Qt::DisplayRole, ++m_HighestID); + auto parentIDItem = std::make_unique(); + parentIDItem->setData(Qt::DisplayRole, 0); + + ui->categoriesTable->setItem(row, 0, idItem.release()); + ui->categoriesTable->setItem(row, 1, new QTableWidgetItem("new")); + ui->categoriesTable->setItem(row, 2, parentIDItem.release()); + ui->categoriesTable->setItem(row, 3, new QTableWidgetItem("")); + ui->categoriesTable->setSortingEnabled(true); +} + +void CategoriesDialog::removeCategory_clicked() +{ + if (m_ContextRow >= 0) + ui->categoriesTable->removeRow(m_ContextRow); +} + +void CategoriesDialog::removeNexusMap_clicked() +{ + if (m_ContextRow >= 0) { + ui->categoriesTable->item(m_ContextRow, 3)->setData(Qt::UserRole, QVariantList()); + ui->categoriesTable->item(m_ContextRow, 3)->setData(Qt::DisplayRole, QString()); + } +} + +void CategoriesDialog::nexusRefresh_clicked() +{ + CategoryFactory::instance().refreshNexusCategories(this); +} + +void CategoriesDialog::nexusImport_clicked() +{ + auto importDialog = CategoryImportDialog(this); + if (importDialog.exec() && importDialog.strategy()) { + refreshIDs(); + QTableWidget* table = ui->categoriesTable; + QListWidget* list = ui->nexusCategoryList; + if (importDialog.strategy() == CategoryImportDialog::Overwrite) { + table->setRowCount(0); + m_HighestID = 0; + } + int row = 0; + table->setSortingEnabled(false); + for (int i = 0; i < list->count(); ++i) { + QString const name = list->item(i)->data(Qt::DisplayRole).toString(); + int const nexusID = list->item(i)->data(Qt::UserRole).toInt(); + QStringList nexusLabel; + QVariantList nexusData; + nexusLabel.append(name); + QVariantList data; + data.append(QVariant(name)); + data.append(QVariant(nexusID)); + nexusData.insert(nexusData.size(), data); + auto nexusCatItem = std::make_unique(nexusLabel.join(", ")); + nexusCatItem->setData(Qt::UserRole, nexusData); + if (table->findItems(name, Qt::MatchExactly).empty()) { + row = table->rowCount(); + table->insertRow(table->rowCount()); + + auto idItem = std::make_unique(); + idItem->setData(Qt::DisplayRole, ++m_HighestID); + + auto nameItem = std::make_unique(name); + auto parentIDItem = std::make_unique(); + parentIDItem->setData(Qt::DisplayRole, 0); // No parent + + table->setItem(row, 0, idItem.release()); + table->setItem(row, 1, nameItem.release()); + table->setItem(row, 2, parentIDItem.release()); + + if (importDialog.assign()) { + table->setItem(row, 3, nexusCatItem.release()); + } + } else { + for (auto *item : table->findItems(name, Qt::MatchContains | Qt::MatchWrap)) { + if (item->column() == 1 && item->text() == name && importDialog.remap()) { + table->setItem(item->row(), 3, nexusCatItem.release()); + } else if (importDialog.remap()) { + auto blankItem = std::make_unique(); + blankItem->setData(Qt::UserRole, QVariantList()); + table->setItem(item->row(), 3, blankItem.release()); + } + } + } + } + table->setSortingEnabled(true); + refreshIDs(); + } +} + +void CategoriesDialog::nxmGameInfoAvailable(QString gameName, QVariant, + QVariant resultData, int) +{ + QVariantMap result = resultData.toMap(); + QVariantList const categories = result["categories"].toList(); + CategoryFactory const& catFactory = CategoryFactory::instance(); + QListWidget* list = ui->nexusCategoryList; + list->clear(); + for (const auto& category : categories) { + auto catMap = category.toMap(); + auto nexusItem = std::make_unique(); + nexusItem->setData(Qt::DisplayRole, catMap["name"].toString()); + nexusItem->setData(Qt::UserRole, catMap["category_id"].toInt()); + list->addItem(nexusItem.release()); + } +} + +void CategoriesDialog::nxmRequestFailed(QString, int, int, QVariant, int, int errorCode, + const QString& errorMessage) +{ + MessageDialog::showMessage( + tr("Error %1: Request to Nexus failed: %2").arg(errorCode).arg(errorMessage), + this); +} + +void CategoriesDialog::on_categoriesTable_customContextMenuRequested(const QPoint& pos) +{ + m_ContextRow = ui->categoriesTable->rowAt(pos.y()); + QMenu menu; + menu.addAction(tr("Add"), this, SLOT(addCategory_clicked())); + menu.addAction(tr("Remove"), this, SLOT(removeCategory_clicked())); + if (Settings::instance().nexus().categoryMappings()) { + menu.addAction(tr("Remove Nexus Mapping(s)"), this, SLOT(removeNexusMap_clicked())); + } + + menu.exec(ui->categoriesTable->mapToGlobal(pos)); +} diff --git a/src/src/categoriestable.cpp b/src/src/categoriestable.cpp index 0d18b03..83b368c 100644 --- a/src/src/categoriestable.cpp +++ b/src/src/categoriestable.cpp @@ -1,77 +1,78 @@ -/* -Copyright (C) 2012 Sebastian Herbord. All rights reserved. - -This file is part of Mod Organizer. - -Mod Organizer is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -Mod Organizer is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with Mod Organizer. If not, see . -*/ - -#include "categoriestable.h" - -CategoriesTable::CategoriesTable(QWidget* parent) : QTableWidget(parent) {} - -bool CategoriesTable::dropMimeData(int row, int column, const QMimeData* data, - Qt::DropAction action) -{ - if (row == -1) - return false; - - if (action == Qt::IgnoreAction) - return true; - - if (!data->hasFormat("application/x-qabstractitemmodeldatalist")) - return false; - - QByteArray encoded = data->data("application/x-qabstractitemmodeldatalist"); - QDataStream stream(&encoded, QIODevice::ReadOnly); - - while (!stream.atEnd()) { - int curRow, curCol; - QMap roleDataMap; - stream >> curRow >> curCol >> roleDataMap; - - for (auto item : findItems(roleDataMap.value(Qt::DisplayRole).toString(), - Qt::MatchContains | Qt::MatchWrap)) { - if (item->column() != 3) - continue; - QVariantList newData; - for (const auto& nexData : item->data(Qt::UserRole).toList()) { - if (nexData.toList()[1].toInt() != roleDataMap.value(Qt::UserRole)) { - newData.insert(newData.length(), nexData); - } - } - QStringList names; - for (const auto& nexData : newData) { - names.append(nexData.toList()[0].toString()); - } - item->setData(Qt::DisplayRole, names.join(", ")); - item->setData(Qt::UserRole, newData); - } - - auto nexusItem = item(row, 3); - auto itemData = nexusItem->data(Qt::UserRole).toList(); - QVariantList newData; - newData.append(roleDataMap.value(Qt::DisplayRole).toString()); - newData.append(roleDataMap.value(Qt::UserRole).toInt()); - itemData.insert(itemData.length(), newData); - QStringList names; - for (const auto& cat : itemData) { - names.append(cat.toList()[0].toString()); - } - nexusItem->setData(Qt::UserRole, itemData); - nexusItem->setData(Qt::DisplayRole, names.join(", ")); - } - - return true; -} +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "categoriestable.h" + +CategoriesTable::CategoriesTable(QWidget* parent) : QTableWidget(parent) {} + +bool CategoriesTable::dropMimeData(int row, int column, const QMimeData* data, + Qt::DropAction action) +{ + if (row == -1) + return false; + + if (action == Qt::IgnoreAction) + return true; + + if (!data->hasFormat("application/x-qabstractitemmodeldatalist")) + return false; + + QByteArray encoded = data->data("application/x-qabstractitemmodeldatalist"); + QDataStream stream(&encoded, QIODevice::ReadOnly); + + while (!stream.atEnd()) { + int curRow; + int curCol; + QMap roleDataMap; + stream >> curRow >> curCol >> roleDataMap; + + for (auto *item : findItems(roleDataMap.value(Qt::DisplayRole).toString(), + Qt::MatchContains | Qt::MatchWrap)) { + if (item->column() != 3) + continue; + QVariantList newData; + for (const auto& nexData : item->data(Qt::UserRole).toList()) { + if (nexData.toList()[1].toInt() != roleDataMap.value(Qt::UserRole)) { + newData.insert(newData.length(), nexData); + } + } + QStringList names; + for (const auto& nexData : newData) { + names.append(nexData.toList()[0].toString()); + } + item->setData(Qt::DisplayRole, names.join(", ")); + item->setData(Qt::UserRole, newData); + } + + auto *nexusItem = item(row, 3); + auto itemData = nexusItem->data(Qt::UserRole).toList(); + QVariantList newData; + newData.append(roleDataMap.value(Qt::DisplayRole).toString()); + newData.append(roleDataMap.value(Qt::UserRole).toInt()); + itemData.insert(itemData.length(), newData); + QStringList names; + for (const auto& cat : itemData) { + names.append(cat.toList()[0].toString()); + } + nexusItem->setData(Qt::UserRole, itemData); + nexusItem->setData(Qt::DisplayRole, names.join(", ")); + } + + return true; +} diff --git a/src/src/commandline.cpp b/src/src/commandline.cpp index cc57467..4ff408e 100644 --- a/src/src/commandline.cpp +++ b/src/src/commandline.cpp @@ -1,1075 +1,1075 @@ -#include "commandline.h" -#include "env.h" -#include "instancemanager.h" -#include "loglist.h" -#include "messagedialog.h" -#include "multiprocess.h" -#include "organizercore.h" -#include "shared/appconfig.h" -#include "shared/util.h" -#include -#include -#include -#include -#include -#include - -namespace cl -{ - -using namespace MOBase; - -std::string pad_right(std::string s, std::size_t n, char c = ' ') -{ - if (s.size() < n) - s.append(n - s.size(), c); - - return s; -} - -// formats the list of pairs in two columns -// -std::string table(const std::vector>& v, - std::size_t indent, std::size_t spacing) -{ - std::size_t longest = 0; - - for (auto&& p : v) - longest = std::max(longest, p.first.size()); - - std::string s; - - for (auto&& p : v) { - if (!s.empty()) - s += "\n"; - - s += std::string(indent, ' ') + pad_right(p.first, longest) + " " + - std::string(spacing, ' ') + p.second; - } - - return s; -} - -CommandLine::CommandLine() -{ - createOptions(); - - add(); -} - -std::optional CommandLine::process(const std::wstring& line) -{ - m_originalLine = line; - try { - // Convert wstring args to vector for compatibility with - // wcommand_line_parser. - auto narrow_args = po::split_unix(QString::fromStdWString(line).toStdString()); - std::vector args; - for (const auto& a : narrow_args) { - args.push_back(QString::fromStdString(a).toStdWString()); - } - if (!args.empty()) { - // remove program name - args.erase(args.begin()); - } - - // parsing the first part of the command line, including global options and - // command name, but not the rest, which will be collected below - - auto parsed = po::wcommand_line_parser(args) - .options(m_allOptions) - .positional(m_positional) - .allow_unregistered() - .run(); - - po::store(parsed, m_vm); - po::notify(m_vm); - - // collect options past the command name - auto opts = po::collect_unrecognized(parsed.options, po::include_positional); - - if (m_vm.contains("command")) { - // there's a word as the first argument; this may be a command name or - // an old style exe name/binary - - const auto commandName = m_vm["command"].as(); - - // look for the command by name first - for (auto&& c : m_commands) { - if (c->name() == commandName) { - // this is a command - - // remove the command name itself - opts.erase(opts.begin()); - - try { - // legacy commands handle their own parsing, such as 'launch'; don't - // attempt to parse anything here - if (!c->legacy()) { - // parse the the remainder of the command line according to the - // command's options - po::wcommand_line_parser parser(opts); - - auto co = c->allOptions(); - parser.options(co); - - auto pos = c->positional(); - parser.positional(pos); - - parsed = parser.run(); - - po::store(parsed, m_vm); - - if (m_vm.contains("help")) { - env::Console const console; - std::cout << usage(c.get()) << "\n"; - return 0; - } - - // must be below the help check because it throws if required - // positional arguments are missing - po::notify(m_vm); - } - - c->set(line, m_vm, opts); - m_command = c.get(); - - return runEarly(); - } catch (po::error& e) { - env::Console const console; - - std::cerr << e.what() << "\n" << usage(c.get()) << "\n"; - - return 1; - } - } - } - } - - // the first word on the command line is not a valid command, try the other - // stuff; this is used in setupCore() below when called from - // MOApplication::doOneRun() - - // look for help - if (m_vm.contains("help")) { - env::Console const console; - std::cout << usage() << "\n"; - return 0; - } - - if (!opts.empty()) { - const auto qs = QString::fromStdWString(opts[0]); - - if (qs.startsWith("--")) { - // assume that for something like `ModOrganizer.exe --bleh`, it's just - // a bad option instead of an executable that starts with "--" - env::Console const console; - std::cerr << "\nUnrecognized option " << qs.toStdString() << "\n"; - - return 1; - } - - // try as an moshortcut:// - m_shortcut = qs; - - if (!m_shortcut.isValid()) { - // not a shortcut, try a link - if (isNxmLink(qs)) { - m_nxmLink = qs; - } else { - // assume an executable name/binary - m_executable = qs; - } - } - - // remove the shortcut/nxm/executable - opts.erase(opts.begin()); - - for (auto&& o : opts) { - m_untouched.push_back(QString::fromStdWString(o)); - } - } - - return {}; - } catch (po::error& e) { - env::Console const console; - - std::cerr << e.what() << "\n" << usage() << "\n"; - - return 1; - } -} - -bool CommandLine::forwardToPrimary(MOMultiProcess& multiProcess) -{ - if (m_shortcut.isValid()) { - multiProcess.sendMessage(m_shortcut.toString()); - } else if (m_nxmLink) { - multiProcess.sendMessage(*m_nxmLink); - } else if (m_command && m_command->canForwardToPrimary()) { - multiProcess.sendMessage(QString::fromStdWString(m_originalLine)); - } else { - return false; - } - - return true; -} - -std::optional CommandLine::runEarly() -{ - if (m_vm.contains("logs")) { - // in loglist.h - logToStdout(true); - } - - if (m_command) { - return m_command->runEarly(); - } - - return {}; -} - -std::optional CommandLine::runPostApplication(MOApplication& a) -{ - // handle -i with no arguments - if (m_vm.contains("instance") && m_vm["instance"].as().empty()) { - env::Console const c; - - if (auto i = InstanceManager::singleton().currentInstance()) { - std::cout << i->displayName().toStdString() << "\n"; - } else { - std::cout << "no instance configured\n"; - } - - return 0; - } - - if (m_command) { - return m_command->runPostApplication(a); - } - - return {}; -} - -std::optional CommandLine::runPostMultiProcess(MOMultiProcess& mp) -{ - if (m_command) { - return m_command->runPostMultiProcess(mp); - } - - return {}; -} - -std::optional CommandLine::runPostOrganizer(OrganizerCore& core) -{ - if (m_shortcut.isValid()) { - if (m_shortcut.hasExecutable()) { - try { - // make sure MO doesn't exit even if locking is disabled, ForceWait and - // PreventExit will do that - core.processRunner() - .setFromShortcut(m_shortcut) - .setWaitForCompletion(ProcessRunner::ForCommandLine, UILocker::PreventExit) - .run(); - - return 0; - } catch (std::exception&) { - // user was already warned - return 1; - } - } - } else if (m_nxmLink) { - log::debug("starting download from command line: {}", *m_nxmLink); - core.downloadRequestedNXM(*m_nxmLink); - } else if (m_executable) { - const QString exeName = *m_executable; - log::debug("starting {} from command line", exeName); - - try { - // pass the remaining parameters to the binary - // - // make sure MO doesn't exit even if locking is disabled, ForceWait and - // PreventExit will do that - core.processRunner() - .setFromFileOrExecutable(exeName, m_untouched) - .setWaitForCompletion(ProcessRunner::ForCommandLine, UILocker::PreventExit) - .run(); - - return 0; - } catch (const std::exception& e) { - reportError(QObject::tr("failed to start application: %1").arg(e.what())); - return 1; - } - } else if (m_command) { - return m_command->runPostOrganizer(core); - } - - return {}; -} - -void CommandLine::clear() -{ - m_vm.clear(); - m_shortcut = {}; - m_nxmLink = {}; -} - -void CommandLine::createOptions() -{ - m_visibleOptions.add_options()("help", "show this message") - - ("multiple", "allow multiple MO processes to run; see below") - - ("pick", "show the select instance dialog on startup") - - ("logs", "duplicates the logs to stdout") - - ("instance,i", po::value()->implicit_value(""), - "use the given instance (defaults to last used)") - - ("profile,p", po::value(), - "use the given profile (defaults to last used)"); - - po::options_description options; - options.add_options()("command", po::value(), "command")( - "subargs", po::value>(), "args"); - - // one command name, followed by any arguments for that command - m_positional.add("command", 1).add("subargs", -1); - - m_allOptions.add(m_visibleOptions); - m_allOptions.add(options); -} - -std::string CommandLine::usage(const Command* c) const -{ - std::ostringstream oss; - - oss << "\n" - << "Usage:\n"; - - if (c) { - oss << " ModOrganizer.exe [global-options] " << c->usageLine() << "\n\n"; - - const std::string more = c->moreInfo(); - if (!more.empty()) { - oss << more << "\n\n"; - } - - oss << "Command options:\n" << c->visibleOptions() << "\n"; - } else { - oss << " ModOrganizer.exe [options] [[command] [command-options]]\n" - << "\n" - << "Commands:\n"; - - // name and description for all commands - std::vector> v; - for (auto&& c : m_commands) { - // don't show legacy commands - if (c->legacy()) { - continue; - } - - v.push_back({c->name(), c->description()}); - } - - oss << table(v, 2, 4) << "\n" - << "\n"; - } - - oss << "Global options:\n" << m_visibleOptions << "\n"; - - // show the more text unless this is usage for a specific command - if (!c) { - oss << "\n" << more() << "\n"; - } - - return oss.str(); -} - -bool CommandLine::pick() const -{ - return (m_vm.contains("pick")); -} - -bool CommandLine::multiple() const -{ - return (m_vm.contains("multiple")); -} - -std::optional CommandLine::profile() const -{ - if (m_vm.contains("profile")) { - return QString::fromStdString(m_vm["profile"].as()); - } - - return {}; -} - -std::optional CommandLine::instance() const -{ - // note that moshortcut:// overrides -i - - if (m_shortcut.isValid() && m_shortcut.hasInstance()) { - return m_shortcut.instanceName(); - } else if (m_vm.contains("instance")) { - return QString::fromStdString(m_vm["instance"].as()); - } - - return {}; -} - -const MOShortcut& CommandLine::shortcut() const -{ - return m_shortcut; -} - -std::optional CommandLine::nxmLink() const -{ - return m_nxmLink; -} - -std::optional CommandLine::executable() const -{ - return m_executable; -} - -const QStringList& CommandLine::untouched() const -{ - return m_untouched; -} - -std::string CommandLine::more() -{ - return "Multiple processes\n" - " A note on terminology: 'instance' can either mean an MO process\n" - " that's running on the system, or a set of mods and profiles managed\n" - " by MO. To avoid confusion, the term 'process' is used below for the\n" - " former.\n" - " \n" - " --multiple can be used to allow multiple MO processes to run\n" - " simultaneously. This is unsupported and can create all sorts of weird\n" - " problems. To minimize these:\n" - " \n" - " 1) Never have multiple MO processes running that manage the same\n" - " game instance.\n" - " 2) If an executable is launched from an MO process, only this\n" - " process may launch executables until all processes are \n" - " terminated.\n" - " \n" - " It is recommended to close _all_ MO processes as soon as multiple\n" - " processes become unnecessary."; -} - -Command::Meta::Meta(std::string n, std::string d, std::string u, std::string m) - : name(n), description(d), usage(u), more(m) -{} - -std::string Command::name() const -{ - return meta().name; -} - -std::string Command::description() const -{ - return meta().description; -} - -std::string Command::usageLine() const -{ - return name() + " " + meta().usage; -} - -std::string Command::moreInfo() const -{ - return meta().more; -} - -po::options_description Command::allOptions() const -{ - po::options_description d; - - d.add(visibleOptions()); - d.add(getInternalOptions()); - - return d; -} - -po::options_description Command::visibleOptions() const -{ - po::options_description d(getVisibleOptions()); - - d.add_options()("help", "shows this message"); - - return d; -} - -po::positional_options_description Command::positional() const -{ - return getPositional(); -} - -bool Command::legacy() const -{ - return false; -} - -po::options_description Command::getVisibleOptions() const -{ - // no-op - return {}; -} - -po::options_description Command::getInternalOptions() const -{ - // no-op - return {}; -} - -po::positional_options_description Command::getPositional() const -{ - // no-op - return {}; -} - -void Command::set(const std::wstring& originalLine, po::variables_map vm, - std::vector untouched) -{ - m_original = originalLine; - m_vm = vm; - m_untouched = untouched; -} - -std::optional Command::runEarly() -{ - return {}; -} - -std::optional Command::runPostApplication(MOApplication& a) -{ - return {}; -} - -std::optional Command::runPostMultiProcess(MOMultiProcess&) -{ - return {}; -} - -std::optional Command::runPostOrganizer(OrganizerCore&) -{ - return {}; -} - -bool Command::canForwardToPrimary() const -{ - return false; -} - -const std::wstring& Command::originalCmd() const -{ - return m_original; -} - -const po::variables_map& Command::vm() const -{ - return m_vm; -} - -const std::vector& Command::untouched() const -{ - return m_untouched; -} - -po::options_description CrashDumpCommand::getVisibleOptions() const -{ - po::options_description d; - - d.add_options()("type", po::value()->default_value("mini"), - "mini|data|full"); - - return d; -} - -Command::Meta CrashDumpCommand::meta() const -{ - return {"crashdump", "writes a crashdump for a running process of MO", "[options]", - ""}; -} - -std::optional CrashDumpCommand::runEarly() -{ - env::Console const console; - - const auto typeString = vm()["type"].as(); - const auto type = env::coreDumpTypeFromString(typeString); - - // dump - const auto b = env::coredumpOther(type); - if (!b) { - std::wcerr << L"\n>>>> a minidump file was not written\n\n"; - } - - std::wcerr << L"Press enter to continue..."; - std::wcin.get(); - - return (b ? 0 : 1); -} - -Command::Meta LaunchCommand::meta() const -{ - return {"launch", "(internal, do not use)", "", ""}; -} - -bool LaunchCommand::legacy() const -{ - return true; -} - -std::optional LaunchCommand::runEarly() -{ - // The launch command is a Windows-specific internal command used to spawn - // processes from within USVFS; not applicable on Linux. - log::error("The 'launch' command is not supported on Linux"); - return 1; -} - -Command::Meta RunCommand::meta() const -{ - return {"run", "runs a program, file or a configured executable", "[options] NAME", - - "Runs a program or a file with the virtual filesystem. If NAME is a path\n" - "to a non-executable file, the program that is associated with the file\n" - "extension is run instead. With -e, NAME must refer to the name of an\n" - "executable in the instance (for example, \"SKSE\")."}; -} - -po::options_description RunCommand::getVisibleOptions() const -{ - po::options_description d; - - d.add_options()("executable,e", - po::value()->default_value(false)->zero_tokens(), - "the name is a configured executable name")( - "arguments,a", po::value(), "override arguments")( - "cwd,c", po::value(), "override working directory"); - - return d; -} - -po::options_description RunCommand::getInternalOptions() const -{ - po::options_description d; - - d.add_options()("NAME", po::value()->required(), - "program or executable name"); - - return d; -} - -po::positional_options_description RunCommand::getPositional() const -{ - po::positional_options_description d; - - d.add("NAME", 1); - - return d; -} - -bool RunCommand::canForwardToPrimary() const -{ - return true; -} - -std::optional RunCommand::runPostOrganizer(OrganizerCore& core) -{ - const auto program = QString::fromStdString(vm()["NAME"].as()); - - try { - // make sure MO doesn't exit even if locking is disabled, ForceWait and - // PreventExit will do that - auto p = core.processRunner(); - - if (vm()["executable"].as()) { - const auto& exes = *core.executablesList(); - - // case sensitive - auto itor = exes.find(program, true); - if (itor == exes.end()) { - // case insensitive - itor = exes.find(program, false); - - if (itor == exes.end()) { - // not found - reportError( - QObject::tr("Executable '%1' not found in instance '%2'.") - .arg(program) - .arg(InstanceManager::singleton().currentInstance()->displayName())); - - return 1; - } - } - - p.setFromExecutable(*itor); - } else { - p.setFromFile(nullptr, QFileInfo(program)); - } - - if (vm().contains("arguments")) { - p.setArguments(QString::fromStdString(vm()["arguments"].as())); - } - - if (vm().contains("cwd")) { - p.setCurrentDirectory(QString::fromStdString(vm()["cwd"].as())); - } - - p.setWaitForCompletion(ProcessRunner::ForCommandLine, UILocker::PreventExit); - - const auto r = p.run(); - if (r == ProcessRunner::Error) { - reportError( - QObject::tr("Failed to run '%1'. The logs might have more information.") - .arg(program)); - - return 1; - } - - return 0; - } catch (const std::exception& e) { - reportError( - QObject::tr("Failed to run '%1'. The logs might have more information. %2") - .arg(program) - .arg(e.what())); - - return 1; - } -} - -Command::Meta ReloadPluginCommand::meta() const -{ - return {"reload-plugin", "reloads the given plugin", "PLUGIN", ""}; -} - -po::options_description ReloadPluginCommand::getInternalOptions() const -{ - po::options_description d; - - d.add_options()("PLUGIN", po::value()->required(), "plugin name"); - - return d; -} - -po::positional_options_description ReloadPluginCommand::getPositional() const -{ - po::positional_options_description d; - - d.add("PLUGIN", 1); - - return d; -} - -bool ReloadPluginCommand::canForwardToPrimary() const -{ - return true; -} - -std::optional ReloadPluginCommand::runPostOrganizer(OrganizerCore& core) -{ - const QString name = QString::fromStdString(vm()["PLUGIN"].as()); - - QString filepath = - QDir(AppConfig::pluginsPath()).absoluteFilePath(name); - - log::debug("reloading plugin from {}", filepath); - core.pluginContainer().reloadPlugin(filepath); - - return {}; -} - -Command::Meta DownloadFileCommand::meta() const -{ - return {"download", "downloads a file", "URL", ""}; -} - -po::options_description DownloadFileCommand::getInternalOptions() const -{ - po::options_description d; - - d.add_options()("URL", po::value()->required(), "file URL"); - - return d; -} - -po::positional_options_description DownloadFileCommand::getPositional() const -{ - po::positional_options_description d; - - d.add("URL", 1); - - return d; -} - -bool DownloadFileCommand::canForwardToPrimary() const -{ - return true; -} - -std::optional DownloadFileCommand::runPostOrganizer(OrganizerCore& core) -{ - const QString url = QString::fromStdString(vm()["URL"].as()); - - if (!url.startsWith("https://")) { - reportError(QObject::tr("Download URL must start with https://")); - return 1; - } - - log::debug("starting direct download from command line: {}", url.toStdString()); - MessageDialog::showMessage(QObject::tr("Download started"), qApp->activeWindow(), - false); - core.downloadManager()->startDownloadURLs(QStringList() << url); - - return {}; -} - -Command::Meta RefreshCommand::meta() const -{ - return {"refresh", "refreshes MO (same as F5)", "", ""}; -} - -bool RefreshCommand::canForwardToPrimary() const -{ - return true; -} - -std::optional RefreshCommand::runPostOrganizer(OrganizerCore& core) -{ - core.refresh(); - return {}; -} - -Command::Meta CreatePortableCommand::meta() const -{ - return {"create-portable", "creates a portable MO2 instance", "[options]", - "Creates a new portable MO2 instance with a generated launch script\n" - "and configuration."}; -} - -po::options_description CreatePortableCommand::getVisibleOptions() const -{ - po::options_description d; - - d.add_options() - ("name", po::value()->required(), "instance name") - ("game", po::value(), "target game (e.g., falloutnv, skyrimse)") - ("game-path", po::value(), "path to the game installation") - ("prefix", po::value(), "Wine prefix path") - ("proton", po::value(), "Proton path") - ("output", po::value()->default_value("."), "output directory"); - - return d; -} - -po::options_description CreatePortableCommand::getInternalOptions() const -{ - return {}; -} - -po::positional_options_description CreatePortableCommand::getPositional() const -{ - return {}; -} - -std::optional CreatePortableCommand::runEarly() -{ - env::Console const console; - - const auto name = QString::fromStdString(vm()["name"].as()); - const auto outputDir = QString::fromStdString(vm()["output"].as()); - - const QString instanceDir = QDir(outputDir).filePath(name); - - if (QDir(instanceDir).exists()) { - std::cerr << "Error: directory already exists: " << instanceDir.toStdString() << "\n"; - return 1; - } - - // Create directory structure - const QStringList dirs = { - "mods", - "profiles/Default", - "downloads", - "overwrite" - }; - - for (const auto& dir : dirs) { - if (!QDir().mkpath(QDir(instanceDir).filePath(dir))) { - std::cerr << "Error: failed to create directory: " << dir.toStdString() << "\n"; - return 1; - } - } - - // Create empty profile files - const QStringList profileFiles = {"modlist.txt", "plugins.txt", "loadorder.txt", "initweaks.ini"}; - for (const auto& f : profileFiles) { - QFile file(QDir(instanceDir).filePath("profiles/Default/" + f)); - if (!file.open(QIODevice::WriteOnly)) { - std::cerr << "Error: failed to create file: " << f.toStdString() << "\n"; - return 1; - } - file.close(); - } - - // Generate ModOrganizer.ini - { - QSettings ini(QDir(instanceDir).filePath("ModOrganizer.ini"), QSettings::IniFormat); - - if (vm().contains("game")) { - ini.setValue("General/gameName", QString::fromStdString(vm()["game"].as())); - } - if (vm().contains("game-path")) { - ini.setValue("General/gamePath", QString::fromStdString(vm()["game-path"].as())); - } - ini.setValue("General/portable", true); - - ini.setValue("Settings/download_directory", "%BASE_DIR%/downloads"); - ini.setValue("Settings/mod_directory", "%BASE_DIR%/mods"); - ini.setValue("Settings/overwrite_directory", "%BASE_DIR%/overwrite"); - ini.setValue("Settings/profile_local_inis", true); - - if (vm().contains("prefix")) { - ini.setValue("fluorine/prefix_path", QString::fromStdString(vm()["prefix"].as())); - } - if (vm().contains("proton")) { - ini.setValue("fluorine/proton_path", QString::fromStdString(vm()["proton"].as())); - } - ini.sync(); - } - - // Generate ModOrganizer.sh - { - const QString launchPath = QDir(instanceDir).filePath("ModOrganizer.sh"); - QFile launchFile(launchPath); - if (launchFile.open(QIODevice::WriteOnly | QIODevice::Text)) { - QTextStream out(&launchFile); - out << "#!/bin/bash\n"; - out << "# Auto-generated by fluorine-manager\n"; - out << "INSTANCE_DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n"; - out << "MO2_BIN=\"$(which ModOrganizer 2>/dev/null || echo ModOrganizer)\"\n"; - out << "exec \"${MO2_BIN}\" --instance \"${INSTANCE_DIR}\" \"$@\"\n"; - launchFile.close(); - QFile::setPermissions(launchPath, - QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner | - QFileDevice::ReadGroup | QFileDevice::ExeGroup | - QFileDevice::ReadOther | QFileDevice::ExeOther); - } - } - - std::cout << "Portable instance created: " << instanceDir.toStdString() << "\n"; - std::cout << " Launch with: " << QDir(instanceDir).filePath("ModOrganizer.sh").toStdString() << "\n"; - - return 0; -} - -Command::Meta ListInstancesCommand::meta() const -{ - return {"list-instances", "lists portable MO2 instances", "", - "Searches for portable MO2 instances in common locations."}; -} - -std::optional ListInstancesCommand::runEarly() -{ - env::Console const console; - - // Check common locations for portable instances - const QStringList searchPaths = { - QDir::currentPath(), - QDir::homePath(), - QDir(QDir::homePath()).filePath(".var/app/com.fluorine.manager"), - }; - - bool found = false; - for (const auto& searchPath : searchPaths) { - QDir const dir(searchPath); - if (!dir.exists()) continue; - - for (const auto& entry : dir.entryList(QDir::Dirs | QDir::NoDotAndDotDot)) { - const QString iniPath = QDir(dir.filePath(entry)).filePath("ModOrganizer.ini"); - if (QFile::exists(iniPath)) { - QSettings const ini(iniPath, QSettings::IniFormat); - if (ini.value("General/portable", false).toBool()) { - if (!found) { - std::cout << "Portable instances:\n"; - found = true; - } - const auto gameName = ini.value("General/gameName", "unknown").toString(); - std::cout << " " << entry.toStdString() - << " (" << gameName.toStdString() << ")" - << " - " << dir.filePath(entry).toStdString() << "\n"; - } - } - } - } - - if (!found) { - std::cout << "No portable instances found.\n"; - } - - return 0; -} - -Command::Meta InfoCommand::meta() const -{ - return {"info", "shows instance configuration", "INSTANCE", - "Shows configuration details for the given portable instance path."}; -} - -po::options_description InfoCommand::getInternalOptions() const -{ - po::options_description d; - - d.add_options()("INSTANCE", po::value()->required(), "instance path"); - - return d; -} - -po::positional_options_description InfoCommand::getPositional() const -{ - po::positional_options_description d; - - d.add("INSTANCE", 1); - - return d; -} - -std::optional InfoCommand::runEarly() -{ - env::Console const console; - - const auto instancePath = QString::fromStdString(vm()["INSTANCE"].as()); - const QString iniPath = QDir(instancePath).filePath("ModOrganizer.ini"); - - if (!QFile::exists(iniPath)) { - std::cerr << "Error: no ModOrganizer.ini found at " << instancePath.toStdString() << "\n"; - return 1; - } - - QSettings const ini(iniPath, QSettings::IniFormat); - - std::cout << "Instance: " << instancePath.toStdString() << "\n"; - std::cout << " Game: " << ini.value("General/gameName", "not set").toString().toStdString() << "\n"; - std::cout << " Game Path: " << ini.value("General/gamePath", "not set").toString().toStdString() << "\n"; - std::cout << " Portable: " << (ini.value("General/portable", false).toBool() ? "yes" : "no") << "\n"; - std::cout << " Mods Dir: " << ini.value("Settings/mod_directory", "not set").toString().toStdString() << "\n"; - std::cout << " Downloads: " << ini.value("Settings/download_directory", "not set").toString().toStdString() << "\n"; - - const auto prefix = ini.value("fluorine/prefix_path").toString(); - const auto proton = ini.value("fluorine/proton_path").toString(); - if (!prefix.isEmpty()) - std::cout << " Prefix: " << prefix.toStdString() << "\n"; - if (!proton.isEmpty()) - std::cout << " Proton: " << proton.toStdString() << "\n"; - return 0; -} - -} // namespace cl +#include "commandline.h" +#include "env.h" +#include "instancemanager.h" +#include "loglist.h" +#include "messagedialog.h" +#include "multiprocess.h" +#include "organizercore.h" +#include "shared/appconfig.h" +#include "shared/util.h" +#include +#include +#include +#include +#include +#include + +namespace cl +{ + +using namespace MOBase; + +std::string pad_right(std::string s, std::size_t n, char c = ' ') +{ + if (s.size() < n) + s.append(n - s.size(), c); + + return s; +} + +// formats the list of pairs in two columns +// +std::string table(const std::vector>& v, + std::size_t indent, std::size_t spacing) +{ + std::size_t longest = 0; + + for (auto&& p : v) + longest = std::max(longest, p.first.size()); + + std::string s; + + for (auto&& p : v) { + if (!s.empty()) + s += "\n"; + + s += std::string(indent, ' ') + pad_right(p.first, longest) + " " + + std::string(spacing, ' ') + p.second; + } + + return s; +} + +CommandLine::CommandLine() +{ + createOptions(); + + add(); +} + +std::optional CommandLine::process(const std::wstring& line) +{ + m_originalLine = line; + try { + // Convert wstring args to vector for compatibility with + // wcommand_line_parser. + auto narrow_args = po::split_unix(QString::fromStdWString(line).toStdString()); + std::vector args; + for (const auto& a : narrow_args) { + args.push_back(QString::fromStdString(a).toStdWString()); + } + if (!args.empty()) { + // remove program name + args.erase(args.begin()); + } + + // parsing the first part of the command line, including global options and + // command name, but not the rest, which will be collected below + + auto parsed = po::wcommand_line_parser(args) + .options(m_allOptions) + .positional(m_positional) + .allow_unregistered() + .run(); + + po::store(parsed, m_vm); + po::notify(m_vm); + + // collect options past the command name + auto opts = po::collect_unrecognized(parsed.options, po::include_positional); + + if (m_vm.contains("command")) { + // there's a word as the first argument; this may be a command name or + // an old style exe name/binary + + const auto commandName = m_vm["command"].as(); + + // look for the command by name first + for (auto&& c : m_commands) { + if (c->name() == commandName) { + // this is a command + + // remove the command name itself + opts.erase(opts.begin()); + + try { + // legacy commands handle their own parsing, such as 'launch'; don't + // attempt to parse anything here + if (!c->legacy()) { + // parse the the remainder of the command line according to the + // command's options + po::wcommand_line_parser parser(opts); + + auto co = c->allOptions(); + parser.options(co); + + auto pos = c->positional(); + parser.positional(pos); + + parsed = parser.run(); + + po::store(parsed, m_vm); + + if (m_vm.contains("help")) { + env::Console const console; + std::cout << usage(c.get()) << "\n"; + return 0; + } + + // must be below the help check because it throws if required + // positional arguments are missing + po::notify(m_vm); + } + + c->set(line, m_vm, opts); + m_command = c.get(); + + return runEarly(); + } catch (po::error& e) { + env::Console const console; + + std::cerr << e.what() << "\n" << usage(c.get()) << "\n"; + + return 1; + } + } + } + } + + // the first word on the command line is not a valid command, try the other + // stuff; this is used in setupCore() below when called from + // MOApplication::doOneRun() + + // look for help + if (m_vm.contains("help")) { + env::Console const console; + std::cout << usage() << "\n"; + return 0; + } + + if (!opts.empty()) { + const auto qs = QString::fromStdWString(opts[0]); + + if (qs.startsWith("--")) { + // assume that for something like `ModOrganizer.exe --bleh`, it's just + // a bad option instead of an executable that starts with "--" + env::Console const console; + std::cerr << "\nUnrecognized option " << qs.toStdString() << "\n"; + + return 1; + } + + // try as an moshortcut:// + m_shortcut = qs; + + if (!m_shortcut.isValid()) { + // not a shortcut, try a link + if (isNxmLink(qs)) { + m_nxmLink = qs; + } else { + // assume an executable name/binary + m_executable = qs; + } + } + + // remove the shortcut/nxm/executable + opts.erase(opts.begin()); + + for (auto&& o : opts) { + m_untouched.push_back(QString::fromStdWString(o)); + } + } + + return {}; + } catch (po::error& e) { + env::Console const console; + + std::cerr << e.what() << "\n" << usage() << "\n"; + + return 1; + } +} + +bool CommandLine::forwardToPrimary(MOMultiProcess& multiProcess) +{ + if (m_shortcut.isValid()) { + multiProcess.sendMessage(m_shortcut.toString()); + } else if (m_nxmLink) { + multiProcess.sendMessage(*m_nxmLink); + } else if (m_command && m_command->canForwardToPrimary()) { + multiProcess.sendMessage(QString::fromStdWString(m_originalLine)); + } else { + return false; + } + + return true; +} + +std::optional CommandLine::runEarly() +{ + if (m_vm.contains("logs")) { + // in loglist.h + logToStdout(true); + } + + if (m_command) { + return m_command->runEarly(); + } + + return {}; +} + +std::optional CommandLine::runPostApplication(MOApplication& a) +{ + // handle -i with no arguments + if (m_vm.contains("instance") && m_vm["instance"].as().empty()) { + env::Console const c; + + if (auto i = InstanceManager::singleton().currentInstance()) { + std::cout << i->displayName().toStdString() << "\n"; + } else { + std::cout << "no instance configured\n"; + } + + return 0; + } + + if (m_command) { + return m_command->runPostApplication(a); + } + + return {}; +} + +std::optional CommandLine::runPostMultiProcess(MOMultiProcess& mp) +{ + if (m_command) { + return m_command->runPostMultiProcess(mp); + } + + return {}; +} + +std::optional CommandLine::runPostOrganizer(OrganizerCore& core) +{ + if (m_shortcut.isValid()) { + if (m_shortcut.hasExecutable()) { + try { + // make sure MO doesn't exit even if locking is disabled, ForceWait and + // PreventExit will do that + core.processRunner() + .setFromShortcut(m_shortcut) + .setWaitForCompletion(ProcessRunner::ForCommandLine, UILocker::PreventExit) + .run(); + + return 0; + } catch (std::exception&) { + // user was already warned + return 1; + } + } + } else if (m_nxmLink) { + log::debug("starting download from command line: {}", *m_nxmLink); + core.downloadRequestedNXM(*m_nxmLink); + } else if (m_executable) { + const QString exeName = *m_executable; + log::debug("starting {} from command line", exeName); + + try { + // pass the remaining parameters to the binary + // + // make sure MO doesn't exit even if locking is disabled, ForceWait and + // PreventExit will do that + core.processRunner() + .setFromFileOrExecutable(exeName, m_untouched) + .setWaitForCompletion(ProcessRunner::ForCommandLine, UILocker::PreventExit) + .run(); + + return 0; + } catch (const std::exception& e) { + reportError(QObject::tr("failed to start application: %1").arg(e.what())); + return 1; + } + } else if (m_command) { + return m_command->runPostOrganizer(core); + } + + return {}; +} + +void CommandLine::clear() +{ + m_vm.clear(); + m_shortcut = {}; + m_nxmLink = {}; +} + +void CommandLine::createOptions() +{ + m_visibleOptions.add_options()("help", "show this message") + + ("multiple", "allow multiple MO processes to run; see below") + + ("pick", "show the select instance dialog on startup") + + ("logs", "duplicates the logs to stdout") + + ("instance,i", po::value()->implicit_value(""), + "use the given instance (defaults to last used)") + + ("profile,p", po::value(), + "use the given profile (defaults to last used)"); + + po::options_description options; + options.add_options()("command", po::value(), "command")( + "subargs", po::value>(), "args"); + + // one command name, followed by any arguments for that command + m_positional.add("command", 1).add("subargs", -1); + + m_allOptions.add(m_visibleOptions); + m_allOptions.add(options); +} + +std::string CommandLine::usage(const Command* c) const +{ + std::ostringstream oss; + + oss << "\n" + << "Usage:\n"; + + if (c) { + oss << " ModOrganizer.exe [global-options] " << c->usageLine() << "\n\n"; + + const std::string more = c->moreInfo(); + if (!more.empty()) { + oss << more << "\n\n"; + } + + oss << "Command options:\n" << c->visibleOptions() << "\n"; + } else { + oss << " ModOrganizer.exe [options] [[command] [command-options]]\n" + << "\n" + << "Commands:\n"; + + // name and description for all commands + std::vector> v; + for (auto&& c : m_commands) { + // don't show legacy commands + if (c->legacy()) { + continue; + } + + v.emplace_back(c->name(), c->description()); + } + + oss << table(v, 2, 4) << "\n" + << "\n"; + } + + oss << "Global options:\n" << m_visibleOptions << "\n"; + + // show the more text unless this is usage for a specific command + if (!c) { + oss << "\n" << more() << "\n"; + } + + return oss.str(); +} + +bool CommandLine::pick() const +{ + return (m_vm.contains("pick")); +} + +bool CommandLine::multiple() const +{ + return (m_vm.contains("multiple")); +} + +std::optional CommandLine::profile() const +{ + if (m_vm.contains("profile")) { + return QString::fromStdString(m_vm["profile"].as()); + } + + return {}; +} + +std::optional CommandLine::instance() const +{ + // note that moshortcut:// overrides -i + + if (m_shortcut.isValid() && m_shortcut.hasInstance()) { + return m_shortcut.instanceName(); + } else if (m_vm.contains("instance")) { + return QString::fromStdString(m_vm["instance"].as()); + } + + return {}; +} + +const MOShortcut& CommandLine::shortcut() const +{ + return m_shortcut; +} + +std::optional CommandLine::nxmLink() const +{ + return m_nxmLink; +} + +std::optional CommandLine::executable() const +{ + return m_executable; +} + +const QStringList& CommandLine::untouched() const +{ + return m_untouched; +} + +std::string CommandLine::more() +{ + return "Multiple processes\n" + " A note on terminology: 'instance' can either mean an MO process\n" + " that's running on the system, or a set of mods and profiles managed\n" + " by MO. To avoid confusion, the term 'process' is used below for the\n" + " former.\n" + " \n" + " --multiple can be used to allow multiple MO processes to run\n" + " simultaneously. This is unsupported and can create all sorts of weird\n" + " problems. To minimize these:\n" + " \n" + " 1) Never have multiple MO processes running that manage the same\n" + " game instance.\n" + " 2) If an executable is launched from an MO process, only this\n" + " process may launch executables until all processes are \n" + " terminated.\n" + " \n" + " It is recommended to close _all_ MO processes as soon as multiple\n" + " processes become unnecessary."; +} + +Command::Meta::Meta(std::string n, std::string d, std::string u, std::string m) + : name(n), description(d), usage(u), more(m) +{} + +std::string Command::name() const +{ + return meta().name; +} + +std::string Command::description() const +{ + return meta().description; +} + +std::string Command::usageLine() const +{ + return name() + " " + meta().usage; +} + +std::string Command::moreInfo() const +{ + return meta().more; +} + +po::options_description Command::allOptions() const +{ + po::options_description d; + + d.add(visibleOptions()); + d.add(getInternalOptions()); + + return d; +} + +po::options_description Command::visibleOptions() const +{ + po::options_description d(getVisibleOptions()); + + d.add_options()("help", "shows this message"); + + return d; +} + +po::positional_options_description Command::positional() const +{ + return getPositional(); +} + +bool Command::legacy() const +{ + return false; +} + +po::options_description Command::getVisibleOptions() const +{ + // no-op + return {}; +} + +po::options_description Command::getInternalOptions() const +{ + // no-op + return {}; +} + +po::positional_options_description Command::getPositional() const +{ + // no-op + return {}; +} + +void Command::set(const std::wstring& originalLine, po::variables_map vm, + std::vector untouched) +{ + m_original = originalLine; + m_vm = vm; + m_untouched = untouched; +} + +std::optional Command::runEarly() +{ + return {}; +} + +std::optional Command::runPostApplication(MOApplication& a) +{ + return {}; +} + +std::optional Command::runPostMultiProcess(MOMultiProcess&) +{ + return {}; +} + +std::optional Command::runPostOrganizer(OrganizerCore&) +{ + return {}; +} + +bool Command::canForwardToPrimary() const +{ + return false; +} + +const std::wstring& Command::originalCmd() const +{ + return m_original; +} + +const po::variables_map& Command::vm() const +{ + return m_vm; +} + +const std::vector& Command::untouched() const +{ + return m_untouched; +} + +po::options_description CrashDumpCommand::getVisibleOptions() const +{ + po::options_description d; + + d.add_options()("type", po::value()->default_value("mini"), + "mini|data|full"); + + return d; +} + +Command::Meta CrashDumpCommand::meta() const +{ + return {"crashdump", "writes a crashdump for a running process of MO", "[options]", + ""}; +} + +std::optional CrashDumpCommand::runEarly() +{ + env::Console const console; + + const auto typeString = vm()["type"].as(); + const auto type = env::coreDumpTypeFromString(typeString); + + // dump + const auto b = env::coredumpOther(type); + if (!b) { + std::wcerr << L"\n>>>> a minidump file was not written\n\n"; + } + + std::wcerr << L"Press enter to continue..."; + std::wcin.get(); + + return (b ? 0 : 1); +} + +Command::Meta LaunchCommand::meta() const +{ + return {"launch", "(internal, do not use)", "", ""}; +} + +bool LaunchCommand::legacy() const +{ + return true; +} + +std::optional LaunchCommand::runEarly() +{ + // The launch command is a Windows-specific internal command used to spawn + // processes from within USVFS; not applicable on Linux. + log::error("The 'launch' command is not supported on Linux"); + return 1; +} + +Command::Meta RunCommand::meta() const +{ + return {"run", "runs a program, file or a configured executable", "[options] NAME", + + "Runs a program or a file with the virtual filesystem. If NAME is a path\n" + "to a non-executable file, the program that is associated with the file\n" + "extension is run instead. With -e, NAME must refer to the name of an\n" + "executable in the instance (for example, \"SKSE\")."}; +} + +po::options_description RunCommand::getVisibleOptions() const +{ + po::options_description d; + + d.add_options()("executable,e", + po::value()->default_value(false)->zero_tokens(), + "the name is a configured executable name")( + "arguments,a", po::value(), "override arguments")( + "cwd,c", po::value(), "override working directory"); + + return d; +} + +po::options_description RunCommand::getInternalOptions() const +{ + po::options_description d; + + d.add_options()("NAME", po::value()->required(), + "program or executable name"); + + return d; +} + +po::positional_options_description RunCommand::getPositional() const +{ + po::positional_options_description d; + + d.add("NAME", 1); + + return d; +} + +bool RunCommand::canForwardToPrimary() const +{ + return true; +} + +std::optional RunCommand::runPostOrganizer(OrganizerCore& core) +{ + const auto program = QString::fromStdString(vm()["NAME"].as()); + + try { + // make sure MO doesn't exit even if locking is disabled, ForceWait and + // PreventExit will do that + auto p = core.processRunner(); + + if (vm()["executable"].as()) { + const auto& exes = *core.executablesList(); + + // case sensitive + auto itor = exes.find(program, true); + if (itor == exes.end()) { + // case insensitive + itor = exes.find(program, false); + + if (itor == exes.end()) { + // not found + reportError( + QObject::tr("Executable '%1' not found in instance '%2'.") + .arg(program) + .arg(InstanceManager::singleton().currentInstance()->displayName())); + + return 1; + } + } + + p.setFromExecutable(*itor); + } else { + p.setFromFile(nullptr, QFileInfo(program)); + } + + if (vm().contains("arguments")) { + p.setArguments(QString::fromStdString(vm()["arguments"].as())); + } + + if (vm().contains("cwd")) { + p.setCurrentDirectory(QString::fromStdString(vm()["cwd"].as())); + } + + p.setWaitForCompletion(ProcessRunner::ForCommandLine, UILocker::PreventExit); + + const auto r = p.run(); + if (r == ProcessRunner::Error) { + reportError( + QObject::tr("Failed to run '%1'. The logs might have more information.") + .arg(program)); + + return 1; + } + + return 0; + } catch (const std::exception& e) { + reportError( + QObject::tr("Failed to run '%1'. The logs might have more information. %2") + .arg(program) + .arg(e.what())); + + return 1; + } +} + +Command::Meta ReloadPluginCommand::meta() const +{ + return {"reload-plugin", "reloads the given plugin", "PLUGIN", ""}; +} + +po::options_description ReloadPluginCommand::getInternalOptions() const +{ + po::options_description d; + + d.add_options()("PLUGIN", po::value()->required(), "plugin name"); + + return d; +} + +po::positional_options_description ReloadPluginCommand::getPositional() const +{ + po::positional_options_description d; + + d.add("PLUGIN", 1); + + return d; +} + +bool ReloadPluginCommand::canForwardToPrimary() const +{ + return true; +} + +std::optional ReloadPluginCommand::runPostOrganizer(OrganizerCore& core) +{ + const QString name = QString::fromStdString(vm()["PLUGIN"].as()); + + QString filepath = + QDir(AppConfig::pluginsPath()).absoluteFilePath(name); + + log::debug("reloading plugin from {}", filepath); + core.pluginContainer().reloadPlugin(filepath); + + return {}; +} + +Command::Meta DownloadFileCommand::meta() const +{ + return {"download", "downloads a file", "URL", ""}; +} + +po::options_description DownloadFileCommand::getInternalOptions() const +{ + po::options_description d; + + d.add_options()("URL", po::value()->required(), "file URL"); + + return d; +} + +po::positional_options_description DownloadFileCommand::getPositional() const +{ + po::positional_options_description d; + + d.add("URL", 1); + + return d; +} + +bool DownloadFileCommand::canForwardToPrimary() const +{ + return true; +} + +std::optional DownloadFileCommand::runPostOrganizer(OrganizerCore& core) +{ + const QString url = QString::fromStdString(vm()["URL"].as()); + + if (!url.startsWith("https://")) { + reportError(QObject::tr("Download URL must start with https://")); + return 1; + } + + log::debug("starting direct download from command line: {}", url.toStdString()); + MessageDialog::showMessage(QObject::tr("Download started"), qApp->activeWindow(), + false); + core.downloadManager()->startDownloadURLs(QStringList() << url); + + return {}; +} + +Command::Meta RefreshCommand::meta() const +{ + return {"refresh", "refreshes MO (same as F5)", "", ""}; +} + +bool RefreshCommand::canForwardToPrimary() const +{ + return true; +} + +std::optional RefreshCommand::runPostOrganizer(OrganizerCore& core) +{ + core.refresh(); + return {}; +} + +Command::Meta CreatePortableCommand::meta() const +{ + return {"create-portable", "creates a portable MO2 instance", "[options]", + "Creates a new portable MO2 instance with a generated launch script\n" + "and configuration."}; +} + +po::options_description CreatePortableCommand::getVisibleOptions() const +{ + po::options_description d; + + d.add_options() + ("name", po::value()->required(), "instance name") + ("game", po::value(), "target game (e.g., falloutnv, skyrimse)") + ("game-path", po::value(), "path to the game installation") + ("prefix", po::value(), "Wine prefix path") + ("proton", po::value(), "Proton path") + ("output", po::value()->default_value("."), "output directory"); + + return d; +} + +po::options_description CreatePortableCommand::getInternalOptions() const +{ + return {}; +} + +po::positional_options_description CreatePortableCommand::getPositional() const +{ + return {}; +} + +std::optional CreatePortableCommand::runEarly() +{ + env::Console const console; + + const auto name = QString::fromStdString(vm()["name"].as()); + const auto outputDir = QString::fromStdString(vm()["output"].as()); + + const QString instanceDir = QDir(outputDir).filePath(name); + + if (QDir(instanceDir).exists()) { + std::cerr << "Error: directory already exists: " << instanceDir.toStdString() << "\n"; + return 1; + } + + // Create directory structure + const QStringList dirs = { + "mods", + "profiles/Default", + "downloads", + "overwrite" + }; + + for (const auto& dir : dirs) { + if (!QDir().mkpath(QDir(instanceDir).filePath(dir))) { + std::cerr << "Error: failed to create directory: " << dir.toStdString() << "\n"; + return 1; + } + } + + // Create empty profile files + const QStringList profileFiles = {"modlist.txt", "plugins.txt", "loadorder.txt", "initweaks.ini"}; + for (const auto& f : profileFiles) { + QFile file(QDir(instanceDir).filePath("profiles/Default/" + f)); + if (!file.open(QIODevice::WriteOnly)) { + std::cerr << "Error: failed to create file: " << f.toStdString() << "\n"; + return 1; + } + file.close(); + } + + // Generate ModOrganizer.ini + { + QSettings ini(QDir(instanceDir).filePath("ModOrganizer.ini"), QSettings::IniFormat); + + if (vm().contains("game")) { + ini.setValue("General/gameName", QString::fromStdString(vm()["game"].as())); + } + if (vm().contains("game-path")) { + ini.setValue("General/gamePath", QString::fromStdString(vm()["game-path"].as())); + } + ini.setValue("General/portable", true); + + ini.setValue("Settings/download_directory", "%BASE_DIR%/downloads"); + ini.setValue("Settings/mod_directory", "%BASE_DIR%/mods"); + ini.setValue("Settings/overwrite_directory", "%BASE_DIR%/overwrite"); + ini.setValue("Settings/profile_local_inis", true); + + if (vm().contains("prefix")) { + ini.setValue("fluorine/prefix_path", QString::fromStdString(vm()["prefix"].as())); + } + if (vm().contains("proton")) { + ini.setValue("fluorine/proton_path", QString::fromStdString(vm()["proton"].as())); + } + ini.sync(); + } + + // Generate ModOrganizer.sh + { + const QString launchPath = QDir(instanceDir).filePath("ModOrganizer.sh"); + QFile launchFile(launchPath); + if (launchFile.open(QIODevice::WriteOnly | QIODevice::Text)) { + QTextStream out(&launchFile); + out << "#!/bin/bash\n"; + out << "# Auto-generated by fluorine-manager\n"; + out << "INSTANCE_DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n"; + out << "MO2_BIN=\"$(which ModOrganizer 2>/dev/null || echo ModOrganizer)\"\n"; + out << "exec \"${MO2_BIN}\" --instance \"${INSTANCE_DIR}\" \"$@\"\n"; + launchFile.close(); + QFile::setPermissions(launchPath, + QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner | + QFileDevice::ReadGroup | QFileDevice::ExeGroup | + QFileDevice::ReadOther | QFileDevice::ExeOther); + } + } + + std::cout << "Portable instance created: " << instanceDir.toStdString() << "\n"; + std::cout << " Launch with: " << QDir(instanceDir).filePath("ModOrganizer.sh").toStdString() << "\n"; + + return 0; +} + +Command::Meta ListInstancesCommand::meta() const +{ + return {"list-instances", "lists portable MO2 instances", "", + "Searches for portable MO2 instances in common locations."}; +} + +std::optional ListInstancesCommand::runEarly() +{ + env::Console const console; + + // Check common locations for portable instances + const QStringList searchPaths = { + QDir::currentPath(), + QDir::homePath(), + QDir(QDir::homePath()).filePath(".var/app/com.fluorine.manager"), + }; + + bool found = false; + for (const auto& searchPath : searchPaths) { + QDir const dir(searchPath); + if (!dir.exists()) continue; + + for (const auto& entry : dir.entryList(QDir::Dirs | QDir::NoDotAndDotDot)) { + const QString iniPath = QDir(dir.filePath(entry)).filePath("ModOrganizer.ini"); + if (QFile::exists(iniPath)) { + QSettings const ini(iniPath, QSettings::IniFormat); + if (ini.value("General/portable", false).toBool()) { + if (!found) { + std::cout << "Portable instances:\n"; + found = true; + } + const auto gameName = ini.value("General/gameName", "unknown").toString(); + std::cout << " " << entry.toStdString() + << " (" << gameName.toStdString() << ")" + << " - " << dir.filePath(entry).toStdString() << "\n"; + } + } + } + } + + if (!found) { + std::cout << "No portable instances found.\n"; + } + + return 0; +} + +Command::Meta InfoCommand::meta() const +{ + return {"info", "shows instance configuration", "INSTANCE", + "Shows configuration details for the given portable instance path."}; +} + +po::options_description InfoCommand::getInternalOptions() const +{ + po::options_description d; + + d.add_options()("INSTANCE", po::value()->required(), "instance path"); + + return d; +} + +po::positional_options_description InfoCommand::getPositional() const +{ + po::positional_options_description d; + + d.add("INSTANCE", 1); + + return d; +} + +std::optional InfoCommand::runEarly() +{ + env::Console const console; + + const auto instancePath = QString::fromStdString(vm()["INSTANCE"].as()); + const QString iniPath = QDir(instancePath).filePath("ModOrganizer.ini"); + + if (!QFile::exists(iniPath)) { + std::cerr << "Error: no ModOrganizer.ini found at " << instancePath.toStdString() << "\n"; + return 1; + } + + QSettings const ini(iniPath, QSettings::IniFormat); + + std::cout << "Instance: " << instancePath.toStdString() << "\n"; + std::cout << " Game: " << ini.value("General/gameName", "not set").toString().toStdString() << "\n"; + std::cout << " Game Path: " << ini.value("General/gamePath", "not set").toString().toStdString() << "\n"; + std::cout << " Portable: " << (ini.value("General/portable", false).toBool() ? "yes" : "no") << "\n"; + std::cout << " Mods Dir: " << ini.value("Settings/mod_directory", "not set").toString().toStdString() << "\n"; + std::cout << " Downloads: " << ini.value("Settings/download_directory", "not set").toString().toStdString() << "\n"; + + const auto prefix = ini.value("fluorine/prefix_path").toString(); + const auto proton = ini.value("fluorine/proton_path").toString(); + if (!prefix.isEmpty()) + std::cout << " Prefix: " << prefix.toStdString() << "\n"; + if (!proton.isEmpty()) + std::cout << " Proton: " << proton.toStdString() << "\n"; + return 0; +} + +} // namespace cl diff --git a/src/src/createinstancedialogpages.cpp b/src/src/createinstancedialogpages.cpp index df7a32e..0bd4a6e 100644 --- a/src/src/createinstancedialogpages.cpp +++ b/src/src/createinstancedialogpages.cpp @@ -1,1335 +1,1335 @@ -#include "createinstancedialogpages.h" -#include "filesystemutilities.h" -#include "instancemanager.h" -#include "plugincontainer.h" -#include "settings.h" -#include "settingsdialognexus.h" -#include "shared/appconfig.h" -#include "ui_createinstancedialog.h" -#include -#include -#include -#include - -namespace cid -{ - -using namespace MOBase; -using MOBase::TaskDialog; - -// returns %base_dir%/dir -// -QString makeDefaultPath(const std::wstring& dir) -{ - return QDir::toNativeSeparators( - PathSettings::makeDefaultPath(QString::fromStdWString(dir))); -} - -QString compactPathForDescription(const QString& path) -{ - const QString nativePath = QDir::toNativeSeparators(path); - const QString homePath = QDir::toNativeSeparators(QDir::homePath()); - - if (nativePath.startsWith(homePath, Qt::CaseInsensitive)) { - return "~" + nativePath.mid(homePath.size()); - } - - return nativePath; -} - -void tuneTypeButtonHeight(QCommandLinkButton* button) -{ - // Qt can underestimate command-link height on Linux for long descriptions. - button->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::MinimumExpanding); - button->setMinimumHeight(std::max(button->minimumSizeHint().height(), 110)); -} - -QString toLocalizedString(CreateInstanceDialog::Types t) -{ - switch (t) { - case CreateInstanceDialog::Global: - return QObject::tr("Global"); - - case CreateInstanceDialog::Portable: - return QObject::tr("Portable"); - - default: - return QObject::tr("Instance type: %1").arg(QObject::tr("?")); - } -} - -PlaceholderLabel::PlaceholderLabel(QLabel* label) - : m_label(label), m_original(label->text()) -{} - -void PlaceholderLabel::setText(const QString& arg) -{ - if (m_original.contains("%1")) { - m_label->setText(m_original.arg(arg)); - } -} - -void PlaceholderLabel::setVisible(bool b) -{ - m_label->setVisible(b); -} - -Page::Page(CreateInstanceDialog& dlg) - : ui(dlg.getUI()), m_dlg(dlg), m_pc(dlg.pluginContainer()) - -{} - -bool Page::ready() const -{ - return true; -} - -bool Page::skip() const -{ - // setSkip() overrides this if it's true - return m_skip || doSkip(); -} - -bool Page::doSkip() const -{ - return false; -} - -void Page::doActivated(bool) -{ - // no-op -} - -void Page::activated() -{ - doActivated(m_firstActivation); - m_firstActivation = false; -} - -void Page::setSkip(bool b) -{ - m_skip = b; -} - -void Page::updateNavigation() -{ - m_dlg.updateNavigation(); -} - -void Page::next() -{ - m_dlg.next(); -} - -bool Page::action(CreateInstanceDialog::Actions a) -{ - // no-op - return false; -} - -CreateInstanceDialog::Types Page::selectedInstanceType() const -{ - // no-op - return CreateInstanceDialog::NoType; -} - -IPluginGame* Page::selectedGame() const -{ - // no-op - return nullptr; -} - -QString Page::selectedGameLocation() const -{ - // no-op - return {}; -} - -QString Page::selectedGameVariant(MOBase::IPluginGame*) const -{ - // no-op - return {}; -} - -QString Page::selectedInstanceName() const -{ - // no-op - return {}; -} - -CreateInstanceDialog::Paths Page::selectedPaths() const -{ - // no-op - return {}; -} - -CreateInstanceDialog::ProfileSettings Page::profileSettings() const -{ - // no-op - return {}; -} - -IntroPage::IntroPage(CreateInstanceDialog& dlg) - : Page(dlg), m_skip(GlobalSettings::hideCreateInstanceIntro()) -{ - QObject::connect(ui->hideIntro, &QCheckBox::toggled, [&] { - GlobalSettings::setHideCreateInstanceIntro(ui->hideIntro->isChecked()); - }); -} - -bool IntroPage::doSkip() const -{ - return m_skip; -} - -TypePage::TypePage(CreateInstanceDialog& dlg) - : Page(dlg) -{ - // replace placeholders with actual paths - ui->createGlobal->setDescription(ui->createGlobal->description().arg( - compactPathForDescription(InstanceManager::singleton().globalInstancesRootPath()))); - - ui->createPortable->setDescription(ui->createPortable->description().arg( - compactPathForDescription(InstanceManager::singleton().portablePath()))); - - tuneTypeButtonHeight(ui->createGlobal); - tuneTypeButtonHeight(ui->createPortable); - - // Portable instances can now be created at any location, so always enable - ui->createPortable->setEnabled(true); - ui->portableExistsLabel->setVisible(false); - - QObject::connect(ui->createGlobal, &QAbstractButton::clicked, [&] { - global(); - }); - - QObject::connect(ui->createPortable, &QAbstractButton::clicked, [&] { - portable(); - }); -} - -bool TypePage::ready() const -{ - return (m_type != CreateInstanceDialog::NoType); -} - -CreateInstanceDialog::Types TypePage::selectedInstanceType() const -{ - return m_type; -} - -void TypePage::global() -{ - m_type = CreateInstanceDialog::Global; - - ui->createGlobal->setChecked(true); - ui->createPortable->setChecked(false); - - next(); -} - -void TypePage::portable() -{ - m_type = CreateInstanceDialog::Portable; - - ui->createGlobal->setChecked(false); - ui->createPortable->setChecked(true); - - next(); -} - -void TypePage::doActivated(bool firstTime) -{ - if (firstTime) { - ui->createGlobal->setFocus(); - } -} - -GamePage::Game::Game(IPluginGame* g) : game(g), installed(g->isInstalled()) -{ - if (installed) { - dir = game->gameDirectory().path(); - } -} - -GamePage::GamePage(CreateInstanceDialog& dlg) : Page(dlg) -{ - createGames(); - fillList(); - - m_filter.setEdit(ui->gamesFilter); - - QObject::connect(&m_filter, &FilterWidget::changed, [&] { - fillList(); - }); - QObject::connect(ui->showAllGames, &QCheckBox::clicked, [&] { - fillList(); - }); -} - -bool GamePage::ready() const -{ - return (m_selection != nullptr); -} - -bool GamePage::action(CreateInstanceDialog::Actions a) -{ - using Actions = CreateInstanceDialog::Actions; - - if (a == Actions::Find) { - ui->gamesFilter->setFocus(); - return true; - } - - return false; -} - -IPluginGame* GamePage::selectedGame() const -{ - if (!m_selection) { - return nullptr; - } - - return m_selection->game; -} - -QString GamePage::selectedGameLocation() const -{ - if (!m_selection) { - return {}; - } - - return QDir::toNativeSeparators(m_selection->dir); -} - -void GamePage::select(IPluginGame* game, const QString& dir) -{ - Game* checked = findGame(game); - - if (checked) { - if (!checked->installed || (detectMicrosoftStore(checked->dir) && - !confirmMicrosoftStore(checked->dir, checked->game))) { - if (dir.isEmpty()) { - // the selected game has no installation directory and none was given, - // ask the user - - const auto path = QFileDialog::getExistingDirectory( - &m_dlg, - QObject::tr("Find game installation for %1").arg(game->displayGameName()), - {}, {}); - - if (path.isEmpty()) { - // cancelled - checked = nullptr; - } else if (detectMicrosoftStore(path) && !confirmMicrosoftStore(path, game)) { - // cancelled - checked = nullptr; - } else { - // check whether a plugin supports the given directory; this can - // return the same plugin, a different one, or null - checked = checkInstallation(path, checked); - - if (checked) { - // plugin was found, remember this path - checked->dir = path; - checked->installed = true; - } - } - } else { - // the selected game didn't detect anything, but a directory was given, - // so use that - checked->dir = dir; - checked->installed = true; - } - } - } - - // select this plugin, if any - m_selection = checked; - - // update the button associated with it in case the paths have changed - updateButton(checked); - - // toggle it on - selectButton(checked); - - updateNavigation(); - - if (checked) { - // automatically move to the next page when a game is selected - next(); - } -} - -void GamePage::selectCustom() -{ - const auto path = QFileDialog::getExistingDirectory( - &m_dlg, QObject::tr("Find game installation"), {}, {}); - - if (path.isEmpty()) { - // reselect the previous button - selectButton(m_selection); - return; - } - - // Microsoft store games are not supported - if (detectMicrosoftStore(path) && !confirmMicrosoftStore(path, nullptr)) { - // reselect the previous button - selectButton(m_selection); - return; - } - - // try to find a plugin that likes this directory - for (auto& g : m_games) { - if (g->game->looksValid(path)) { - // found one - g->dir = path; - g->installed = true; - - // select it - select(g->game); - - // update the button because the path has changed - updateButton(g.get()); - - return; - } - } - - // warning to the user - warnUnrecognized(path); - - // reselect the previous button - selectButton(m_selection); -} - -void GamePage::warnUnrecognized(const QString& path) -{ - // put the list of supported games in the details textbox - QString supportedGames; - for (auto* game : sortedGamePlugins()) { - supportedGames += game->displayGameName() + "\n"; - } - - QMessageBox dlg(&m_dlg); - - dlg.setWindowTitle(QObject::tr("Unrecognized game")); - dlg.setText( - QObject::tr("The folder %1 does not seem to contain a game Mod Organizer can " - "manage.") - .arg(path)); - dlg.setInformativeText(QObject::tr("See details for the list of supported games.")); - dlg.setDetailedText(supportedGames); - dlg.setIcon(QMessageBox::Warning); - dlg.setStandardButtons(QMessageBox::Ok); - - dlg.exec(); -} - -std::vector GamePage::sortedGamePlugins() const -{ - std::vector v; - - // all game plugins - for (auto* game : m_pc.plugins()) { - v.push_back(game); - } - - // natsort - std::sort(v.begin(), v.end(), [](auto* a, auto* b) { - return (naturalCompare(a->displayGameName(), b->displayGameName()) < 0); - }); - - return v; -} - -void GamePage::createGames() -{ - m_games.clear(); - - for (auto* game : sortedGamePlugins()) { - m_games.push_back(std::make_unique(game)); - } -} - -GamePage::Game* GamePage::findGame(IPluginGame* game) -{ - for (auto& g : m_games) { - if (g->game == game) { - return g.get(); - } - } - - return nullptr; -} - -void GamePage::createGameButton(Game* g) -{ - g->button = new QCommandLinkButton; - g->button->setCheckable(true); - g->button->setIconSize(QSize(24, 24)); - - updateButton(g); - - QObject::connect(g->button, &QAbstractButton::clicked, [g, this] { - select(g->game); - }); -} - -void GamePage::addButton(QAbstractButton* b) -{ - auto* ly = static_cast(ui->games->layout()); - - // insert before the stretch - ly->insertWidget(ly->count() - 1, b); -} - -void GamePage::updateButton(Game* g) -{ - if (!g || !g->button) { - return; - } - - g->button->setText(g->game->displayGameName().replace("&", "&&")); - QIcon icon = g->game->gameIcon(); - if (icon.isNull()) { - icon = QIcon(":/MO/gui/executable"); - } - if (icon.isNull()) { - icon = QApplication::style()->standardIcon(QStyle::SP_ComputerIcon); - } - g->button->setIcon(icon); - - if (g->installed) { - const QString compact = compactPathForDescription(g->dir); - const QFontMetrics fm(g->button->font()); - const QString elided = fm.elidedText(compact, Qt::ElideMiddle, 460); - g->button->setDescription(elided); - g->button->setToolTip(QDir::toNativeSeparators(g->dir)); - } else { - g->button->setDescription(QObject::tr("No installation found")); - g->button->setToolTip({}); - } -} - -void GamePage::selectButton(Game* g) -{ - // go through each game, set the button that is for game `g` as active; - // some button might not exist, which happens when selecting a custom - // folder for a game that was considered uninstalled - - for (const auto& gg : m_games) { - if (!g) { - // nothing should be selected - if (gg->button) { - gg->button->setChecked(false); - } - - continue; - } - - if (gg->game == g->game) { - // this is the button that should be selected - - if (!gg->button) { - // this happens when the button wasn't visible because the game - // was not installed; create it and show it - // and it has a button, just check it - createGameButton(gg.get()); - addButton(gg->button); - } - - gg->button->setChecked(true); - gg->button->setFocus(); - } else { - // this is not the button you're looking for - if (gg->button) { - gg->button->setChecked(false); - } - } - } -} - -void GamePage::clearButtons() -{ - auto* ly = static_cast(ui->games->layout()); - - ui->games->setUpdatesEnabled(false); - - // delete all children - qDeleteAll(ui->games->findChildren("", Qt::FindDirectChildrenOnly)); - - // stretch widgets added with addStretch() are not in the parent widget, - // they have to be deleted from the layout itself - while (auto* child = ly->takeAt(0)) - delete child; - - // add a stretch, buttons will be added before - ly->addStretch(); - - ui->games->setUpdatesEnabled(true); - - for (auto& g : m_games) { - // all buttons have been deleted - g->button = nullptr; - } -} - -QCommandLinkButton* GamePage::createCustomButton() -{ - auto* b = new QCommandLinkButton; - - b->setText(QObject::tr("Browse...")); - b->setDescription(QObject::tr("The folder must contain a valid game installation")); - - QObject::connect(b, &QAbstractButton::clicked, [&] { - selectCustom(); - }); - - return b; -} - -void GamePage::fillList() -{ - const bool showAll = ui->showAllGames->isChecked(); - - clearButtons(); - - Game* firstButton = nullptr; - - for (auto& g : m_games) { - if (!showAll && !g->installed) { - // not installed - continue; - } - - if (!m_filter.matches(g->game->gameName()) && - !m_filter.matches(g->game->displayGameName())) { - // filtered out - continue; - } - - createGameButton(g.get()); - addButton(g->button); - - if (!firstButton) { - firstButton = g.get(); - } - } - - // browse button - addButton(createCustomButton()); - - if (firstButton) { - firstButton->button->setDefault(true); - } -} - -GamePage::Game* GamePage::checkInstallation(const QString& path, Game* g) -{ - if (g->game->looksValid(path)) { - // okay - return g; - } - - if (detectMicrosoftStore(path) && confirmMicrosoftStore(path, g->game)) { - // okay - return g; - } - - // the selected game can't use that folder, find another one - IPluginGame* otherGame = nullptr; - - for (auto* gg : m_pc.plugins()) { - if (gg->looksValid(path)) { - otherGame = gg; - break; - } - } - - if (otherGame == g->game) { - // shouldn't happen, but okay - return g; - } - - if (otherGame) { - // an alternative was found, ask the user about it - auto* confirmedGame = confirmOtherGame(path, g->game, otherGame); - - if (!confirmedGame) { - // cancelled - return nullptr; - } - - // make it look like the user clicked that button instead - g = findGame(confirmedGame); - if (!g) { - return nullptr; - } - } else { - // nothing can manage this, but the user can override - if (!confirmUnknown(path, g->game)) { - // cancelled - return nullptr; - } - } - - // remember this path - g->dir = path; - g->installed = true; - - updateButton(g); - - return g; -} - -bool GamePage::detectMicrosoftStore(const QString& path) -{ - return path.contains("/ModifiableWindowsApps/") || path.contains("/WindowsApps/"); -} - -bool GamePage::confirmMicrosoftStore(const QString& path, IPluginGame* game) -{ - const auto r = - TaskDialog(&m_dlg) - .title(QObject::tr("Microsoft Store game")) - .main(QObject::tr("Microsoft Store game")) - .content( - QObject::tr( - "The folder %1 seems to be a Microsoft Store game install. Games" - " installed through the Microsoft Store are not supported by Mod " - "Organizer" - " and will not work properly.") - .arg(path)) - .button( - {game ? QObject::tr("Use this folder for %1").arg(game->displayGameName()) - : QObject::tr("Use this folder"), - QObject::tr("I know what I'm doing"), QMessageBox::Ignore}) - .button({QObject::tr("Cancel"), QMessageBox::Cancel}) - .exec(); - - return (r == QMessageBox::Ignore); -} - -bool GamePage::confirmUnknown(const QString& path, IPluginGame* game) -{ - const auto r = - TaskDialog(&m_dlg) - .title(QObject::tr("Unrecognized game")) - .main(QObject::tr("Unrecognized game")) - .content( - QObject::tr("The folder %1 does not seem to contain an installation for " - "%2 or " - "for any other game Mod Organizer can manage.") - .arg(path) - .arg(game->displayGameName())) - .button({QObject::tr("Use this folder for %1").arg(game->displayGameName()), - QObject::tr("I know what I'm doing"), QMessageBox::Ignore}) - .button({QObject::tr("Cancel"), QMessageBox::Cancel}) - .exec(); - - return (r == QMessageBox::Ignore); -} - -IPluginGame* GamePage::confirmOtherGame(const QString& path, IPluginGame* selectedGame, - IPluginGame* guessedGame) -{ - const auto r = - TaskDialog(&m_dlg) - .title(QObject::tr("Incorrect game")) - .main(QObject::tr("Incorrect game")) - .content( - QObject::tr( - "The folder %1 seems to contain an installation for " - "%2, " - "not " - "%3.") - .arg(path) - .arg(guessedGame->displayGameName()) - .arg(selectedGame->displayGameName())) - .button({QObject::tr("Manage %1 instead").arg(guessedGame->displayGameName()), - QMessageBox::Ok}) - .button({QObject::tr("Use this folder for %1") - .arg(selectedGame->displayGameName()), - QObject::tr("I know what I'm doing"), QMessageBox::Ignore}) - .button({QObject::tr("Cancel"), QMessageBox::Cancel}) - .exec(); - - switch (r) { - case QMessageBox::Ok: - return guessedGame; - - case QMessageBox::Ignore: - return selectedGame; - - case QMessageBox::Cancel: - default: - return nullptr; - } -} - -VariantsPage::VariantsPage(CreateInstanceDialog& dlg) - : Page(dlg) -{} - -bool VariantsPage::ready() const -{ - // note that this isn't called when doSkip() is true, which happens when - // the game has no variants - - return !m_selection.isEmpty(); -} - -bool VariantsPage::doSkip() const -{ - auto* g = m_dlg.rawCreationInfo().game; - if (!g) { - // shouldn't happen - return true; - } - - return (g->gameVariants().size() < 2); -} - -void VariantsPage::doActivated(bool) -{ - auto* g = m_dlg.rawCreationInfo().game; - - if (m_previousGame != g) { - // recreate the list, the game has changed - m_previousGame = g; - m_selection = ""; - fillList(); - } -} - -void VariantsPage::select(const QString& variant) -{ - m_selection = variant; - - // find the button, set it checked - for (auto* b : m_buttons) { - if (b->text() == variant) { - b->setChecked(true); - } else { - b->setChecked(false); - } - } - - updateNavigation(); - - if (!m_selection.isEmpty()) { - // automatically move to the next page when a variant is selected - next(); - } -} - -QString VariantsPage::selectedGameVariant(MOBase::IPluginGame* game) const -{ - if (!game) { - return {}; - } - - if (game->gameVariants().size() < 2) { - return {}; - } else { - return m_selection; - } -} - -void VariantsPage::fillList() -{ - ui->editions->clear(); - m_buttons.clear(); - - auto* g = m_dlg.rawCreationInfo().game; - if (!g) { - // shouldn't happen - return; - } - - // for each variant, create a checkable button and add it - for (auto& v : g->gameVariants()) { - auto* b = new QCommandLinkButton(v); - b->setCheckable(true); - - QObject::connect(b, &QAbstractButton::clicked, [v, this] { - select(v); - }); - - ui->editions->addButton(b, QDialogButtonBox::AcceptRole); - m_buttons.push_back(b); - } - - if (!m_buttons.empty()) { - m_buttons[0]->setDefault(true); - } -} - -NamePage::NamePage(CreateInstanceDialog& dlg) - : Page(dlg), m_label(ui->instanceNameLabel), - m_exists(ui->instanceNameExists), m_invalid(ui->instanceNameInvalid) -{ - QObject::connect(ui->instanceName, &QLineEdit::textEdited, [&] { - onChanged(); - }); - - QObject::connect(ui->instanceName, &QLineEdit::returnPressed, [&] { - next(); - }); -} - -bool NamePage::ready() const -{ - // checked when textboxes change or when the page is activated - return m_okay; -} - -bool NamePage::doSkip() const -{ - // portable instances have no name - return (m_dlg.rawCreationInfo().type == CreateInstanceDialog::Portable); -} - -void NamePage::doActivated(bool) -{ - auto* g = m_dlg.rawCreationInfo().game; - if (!g) { - // shouldn't happen, next should be disabled - return; - } - - m_label.setText(g->gameName()); - - // generate a name if the user hasn't changed the text in case the game - // changed, or if it's empty - if (!m_modified || ui->instanceName->text().isEmpty()) { - const auto n = InstanceManager::singleton().makeUniqueName(g->gameName()); - ui->instanceName->setText(n); - m_modified = false; - } - - verify(); -} - -QString NamePage::selectedInstanceName() const -{ - if (!m_okay) { - return {}; - } - - const auto text = ui->instanceName->text().trimmed(); - return MOBase::sanitizeFileName(text); -} - -void NamePage::onChanged() -{ - m_modified = true; - verify(); -} - -void NamePage::verify() -{ - const auto root = InstanceManager::singleton().globalInstancesRootPath(); - m_okay = checkName(root, ui->instanceName->text()); - updateNavigation(); -} - -bool NamePage::checkName(QString parentDir, QString name) -{ - bool exists = false; - bool invalid = false; - bool empty = false; - - name = name.trimmed(); - - if (name.isEmpty()) { - empty = true; - } else { - if (MOBase::validFileName(name)) { - exists = QDir(parentDir).exists(name); - } else { - invalid = true; - } - } - - bool okay = false; - - if (exists) { - m_exists.setVisible(true); - m_exists.setText(QDir(parentDir).filePath(name)); - m_invalid.setVisible(false); - } else if (invalid) { - m_exists.setVisible(false); - m_invalid.setVisible(true); - m_invalid.setText(name); - } else { - okay = !empty; - m_exists.setVisible(false); - m_invalid.setVisible(false); - } - - return okay; -} - -ProfilePage::ProfilePage(CreateInstanceDialog& dlg) : Page(dlg) {} - -bool ProfilePage::ready() const -{ - return true; -} - -CreateInstanceDialog::ProfileSettings ProfilePage::profileSettings() const -{ - CreateInstanceDialog::ProfileSettings profileSettings; - profileSettings.localInis = ui->profileInisCheckbox->isChecked(); - profileSettings.localSaves = ui->profileSavesCheckbox->isChecked(); - profileSettings.archiveInvalidation = ui->archiveInvalidationCheckbox->isChecked(); - return profileSettings; -} - -PathsPage::PathsPage(CreateInstanceDialog& dlg) - : Page(dlg), m_label(ui->pathsLabel), - m_simpleExists(ui->locationExists), m_simpleInvalid(ui->locationInvalid), - m_advancedExists(ui->advancedDirExists), - m_advancedInvalid(ui->advancedDirInvalid) -{ - auto setEdit = [&](QLineEdit* e) { - QObject::connect(e, &QLineEdit::textEdited, [&] { - onChanged(); - }); - QObject::connect(e, &QLineEdit::returnPressed, [&] { - next(); - }); - }; - - auto setBrowse = [&](QAbstractButton* b, QLineEdit* e) { - QObject::connect(b, &QAbstractButton::clicked, [this, e] { - browse(e); - }); - }; - - setEdit(ui->location); - setEdit(ui->base); - setEdit(ui->downloads); - setEdit(ui->mods); - setEdit(ui->profiles); - setEdit(ui->overwrite); - - setBrowse(ui->browseLocation, ui->location); - setBrowse(ui->browseBase, ui->base); - setBrowse(ui->browseDownloads, ui->downloads); - setBrowse(ui->browseMods, ui->mods); - setBrowse(ui->browseProfiles, ui->profiles); - setBrowse(ui->browseOverwrite, ui->overwrite); - - QObject::connect(ui->advancedPathOptions, &QCheckBox::clicked, [&] { - onAdvanced(); - }); - - ui->pathPages->setCurrentIndex(0); -} - -bool PathsPage::ready() const -{ - // set when the page is activated, textboxes are changed or the advanced - // checkbox is toggled - return m_okay; -} - -void PathsPage::doActivated(bool firstTime) -{ - const auto name = m_dlg.rawCreationInfo().instanceName; - const auto type = m_dlg.rawCreationInfo().type; - - // if the instance name or type have changed, all the paths must be - // regenerated - const bool changed = (m_lastInstanceName != name) || (m_lastType != type); - - const bool isGlobal = (type == CreateInstanceDialog::Global); - - // Global instances have a fixed location derived from the instance name; - // the user should not be able to browse to an arbitrary directory. - ui->location->setReadOnly(isGlobal); - ui->browseLocation->setVisible(!isGlobal); - ui->advancedPathOptions->setVisible(!isGlobal); - - // If switching from portable back to global, reset to simple view - if (isGlobal && ui->advancedPathOptions->isChecked()) { - ui->advancedPathOptions->setChecked(false); - ui->pathPages->setCurrentIndex(0); - } - - // generating and paths - setPaths(name, changed); - checkPaths(); - - updateNavigation(); - - m_label.setText(m_dlg.rawCreationInfo().game->gameName()); - m_lastInstanceName = name; - m_lastType = type; - - if (firstTime) { - ui->location->setFocus(); - } -} - -CreateInstanceDialog::Paths PathsPage::selectedPaths() const -{ - CreateInstanceDialog::Paths p; - - if (ui->advancedPathOptions->isChecked()) { - p.base = ui->base->text(); - p.downloads = ui->downloads->text(); - p.mods = ui->mods->text(); - p.profiles = ui->profiles->text(); - p.overwrite = ui->overwrite->text(); - } else { - p.base = ui->location->text(); - } - - return p; -} - -void PathsPage::onChanged() -{ - checkPaths(); - updateNavigation(); -} - -void PathsPage::browse(QLineEdit* e) -{ - const auto s = - QFileDialog::getExistingDirectory(&m_dlg, {}, e->text(), {}); - if (s.isNull() || s.isEmpty()) { - return; - } - - e->setText(QDir::toNativeSeparators(s)); -} - -void PathsPage::checkPaths() -{ - if (ui->advancedPathOptions->isChecked()) { - // checking advanced paths - m_okay = checkAdvancedPath(ui->base->text()) && - checkAdvancedPath(resolve(ui->downloads->text())) && - checkAdvancedPath(resolve(ui->mods->text())) && - checkAdvancedPath(resolve(ui->profiles->text())) && - checkAdvancedPath(resolve(ui->overwrite->text())); - } else { - // checking simple path - m_okay = checkSimplePath(ui->location->text()); - } -} - -bool PathsPage::checkSimplePath(const QString& path) -{ - return checkPath(path, m_simpleExists, m_simpleInvalid); -} - -bool PathsPage::checkAdvancedPath(const QString& path) -{ - return checkPath(path, m_advancedExists, m_advancedInvalid); -} - -QString PathsPage::resolve(const QString& path) const -{ - return PathSettings::resolve(path, ui->base->text()); -} - -void PathsPage::onAdvanced() -{ - // the base/location textboxes are different widgets but they represent the - // same base path value, so they're synced between pages - - if (ui->advancedPathOptions->isChecked()) { - ui->base->setText(ui->location->text()); - ui->pathPages->setCurrentIndex(1); - } else { - ui->location->setText(ui->base->text()); - ui->pathPages->setCurrentIndex(0); - } - - checkPaths(); -} - -void PathsPage::setPaths(const QString& name, bool force) -{ - QString basePath; - - if (m_dlg.rawCreationInfo().type == CreateInstanceDialog::Portable) { - basePath = InstanceManager::singleton().portablePath(); - } else { - const auto root = InstanceManager::singleton().globalInstancesRootPath(); - basePath = root + "/" + name; - // Global instances always use the auto-derived path - force = true; - } - - basePath = QDir::toNativeSeparators(QDir::cleanPath(basePath)); - - // all paths are set regardless of advanced checkbox - - setIfEmpty(ui->location, basePath, force); - setIfEmpty(ui->base, basePath, force); - - setIfEmpty(ui->downloads, makeDefaultPath(AppConfig::downloadPath()), force); - setIfEmpty(ui->mods, makeDefaultPath(AppConfig::modsPath()), force); - setIfEmpty(ui->profiles, makeDefaultPath(AppConfig::profilesPath()), force); - setIfEmpty(ui->overwrite, makeDefaultPath(AppConfig::overwritePath()), force); -} - -void PathsPage::setIfEmpty(QLineEdit* e, const QString& path, bool force) -{ - if (e->text().isEmpty() || force) { - e->setText(path); - } -} - -bool PathsPage::checkPath(QString path, PlaceholderLabel& existsLabel, - PlaceholderLabel& invalidLabel) -{ - auto& m = InstanceManager::singleton(); - - bool exists = false; - bool invalid = false; - bool empty = false; - - path = QDir::toNativeSeparators(path.trimmed()); - - if (path.isEmpty()) { - empty = true; - } else { - const QDir d(path); - - if (MOBase::validFileName(d.dirName())) { - if (m_dlg.rawCreationInfo().type == CreateInstanceDialog::Portable) { - // the default data path for a portable instance is the application - // directory, so it's not an error if it exists - if (QDir(path) != m.portablePath()) { - exists = QDir(path).exists(); - } - } else { - exists = QDir(path).exists(); - } - } else { - invalid = true; - } - } - - bool okay = true; - - if (invalid) { - okay = false; - existsLabel.setVisible(false); - invalidLabel.setVisible(true); - invalidLabel.setText(path); - } else if (empty) { - okay = false; - existsLabel.setVisible(false); - invalidLabel.setVisible(false); - } else if (exists) { - // this is just a warning - existsLabel.setVisible(true); - existsLabel.setText(path); - invalidLabel.setVisible(false); - } else { - okay = true; - existsLabel.setVisible(false); - invalidLabel.setVisible(false); - } - - return okay; -} - -NexusPage::NexusPage(CreateInstanceDialog& dlg) : Page(dlg) -{ - m_connectionUI.reset(new NexusConnectionUI(&m_dlg, dlg.settings(), ui->nexusConnect, - nullptr, ui->nexusManual, ui->nexusLog)); - - // just check it once, or connecting and then going back and forth would skip - // the page, which would be unexpected - m_skip = GlobalSettings::hasNexusApiKey(); -} - -NexusPage::~NexusPage() = default; - -bool NexusPage::ready() const -{ - // this page is optional - return true; -} - -bool NexusPage::doSkip() const -{ - return m_skip; -} - -ConfirmationPage::ConfirmationPage(CreateInstanceDialog& dlg) : Page(dlg) {} - -void ConfirmationPage::doActivated(bool) -{ - ui->review->setPlainText(makeReview()); - ui->creationLog->clear(); -} - -QString ConfirmationPage::makeReview() const -{ - QStringList lines; - - const auto ci = m_dlg.rawCreationInfo(); - - lines.push_back(QObject::tr("Instance type: %1").arg(toLocalizedString(ci.type))); - - lines.push_back(QObject::tr("Instance location: %1").arg(ci.dataPath)); - - if (ci.type != CreateInstanceDialog::Portable) { - lines.push_back(QObject::tr("Instance name: %1").arg(ci.instanceName)); - } - - lines.push_back(QObject::tr("Profile settings:")); - lines.push_back( - QObject::tr(" Local INIs: %1") - .arg(ci.profileSettings.localInis ? QObject::tr("yes") : QObject::tr("no"))); - lines.push_back( - QObject::tr(" Local Saves: %1") - .arg(ci.profileSettings.localSaves ? QObject::tr("yes") : QObject::tr("no"))); - lines.push_back(QObject::tr(" Automatic Archive Invalidation: %1") - .arg(ci.profileSettings.archiveInvalidation ? QObject::tr("yes") - : QObject::tr("no"))); - - if (ci.paths.downloads.isEmpty()) { - // simple settings - if (ci.paths.base != ci.dataPath) { - lines.push_back(QObject::tr("Base directory: %1").arg(ci.paths.base)); - } - } else { - // advanced settings - lines.push_back(QObject::tr("Base directory: %1").arg(ci.paths.base)); - lines.push_back(dirLine(QObject::tr("Downloads"), ci.paths.downloads)); - lines.push_back(dirLine(QObject::tr("Mods"), ci.paths.mods)); - lines.push_back(dirLine(QObject::tr("Profiles"), ci.paths.profiles)); - lines.push_back(dirLine(QObject::tr("Overwrite"), ci.paths.overwrite)); - } - - // game - QString name = ci.game->gameName(); - if (!ci.gameVariant.isEmpty()) { - name += " (" + ci.gameVariant + ")"; - } - - lines.push_back(QObject::tr("Game: %1").arg(name)); - lines.push_back(QObject::tr("Game location: %1").arg(ci.gameLocation)); - - return lines.join("\n"); -} - -QString ConfirmationPage::dirLine(const QString& caption, const QString& path) -{ - return QString(" - %1: %2").arg(caption).arg(path); -} - -} // namespace cid +#include "createinstancedialogpages.h" +#include "filesystemutilities.h" +#include "instancemanager.h" +#include "plugincontainer.h" +#include "settings.h" +#include "settingsdialognexus.h" +#include "shared/appconfig.h" +#include "ui_createinstancedialog.h" +#include +#include +#include +#include + +namespace cid +{ + +using namespace MOBase; +using MOBase::TaskDialog; + +// returns %base_dir%/dir +// +QString makeDefaultPath(const std::wstring& dir) +{ + return QDir::toNativeSeparators( + PathSettings::makeDefaultPath(QString::fromStdWString(dir))); +} + +QString compactPathForDescription(const QString& path) +{ + const QString nativePath = QDir::toNativeSeparators(path); + const QString homePath = QDir::toNativeSeparators(QDir::homePath()); + + if (nativePath.startsWith(homePath, Qt::CaseInsensitive)) { + return "~" + nativePath.mid(homePath.size()); + } + + return nativePath; +} + +void tuneTypeButtonHeight(QCommandLinkButton* button) +{ + // Qt can underestimate command-link height on Linux for long descriptions. + button->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::MinimumExpanding); + button->setMinimumHeight(std::max(button->minimumSizeHint().height(), 110)); +} + +QString toLocalizedString(CreateInstanceDialog::Types t) +{ + switch (t) { + case CreateInstanceDialog::Global: + return QObject::tr("Global"); + + case CreateInstanceDialog::Portable: + return QObject::tr("Portable"); + + default: + return QObject::tr("Instance type: %1").arg(QObject::tr("?")); + } +} + +PlaceholderLabel::PlaceholderLabel(QLabel* label) + : m_label(label), m_original(label->text()) +{} + +void PlaceholderLabel::setText(const QString& arg) +{ + if (m_original.contains("%1")) { + m_label->setText(m_original.arg(arg)); + } +} + +void PlaceholderLabel::setVisible(bool b) +{ + m_label->setVisible(b); +} + +Page::Page(CreateInstanceDialog& dlg) + : ui(dlg.getUI()), m_dlg(dlg), m_pc(dlg.pluginContainer()) + +{} + +bool Page::ready() const +{ + return true; +} + +bool Page::skip() const +{ + // setSkip() overrides this if it's true + return m_skip || doSkip(); +} + +bool Page::doSkip() const +{ + return false; +} + +void Page::doActivated(bool) +{ + // no-op +} + +void Page::activated() +{ + doActivated(m_firstActivation); + m_firstActivation = false; +} + +void Page::setSkip(bool b) +{ + m_skip = b; +} + +void Page::updateNavigation() +{ + m_dlg.updateNavigation(); +} + +void Page::next() +{ + m_dlg.next(); +} + +bool Page::action(CreateInstanceDialog::Actions a) +{ + // no-op + return false; +} + +CreateInstanceDialog::Types Page::selectedInstanceType() const +{ + // no-op + return CreateInstanceDialog::NoType; +} + +IPluginGame* Page::selectedGame() const +{ + // no-op + return nullptr; +} + +QString Page::selectedGameLocation() const +{ + // no-op + return {}; +} + +QString Page::selectedGameVariant(MOBase::IPluginGame*) const +{ + // no-op + return {}; +} + +QString Page::selectedInstanceName() const +{ + // no-op + return {}; +} + +CreateInstanceDialog::Paths Page::selectedPaths() const +{ + // no-op + return {}; +} + +CreateInstanceDialog::ProfileSettings Page::profileSettings() const +{ + // no-op + return {}; +} + +IntroPage::IntroPage(CreateInstanceDialog& dlg) + : Page(dlg), m_skip(GlobalSettings::hideCreateInstanceIntro()) +{ + QObject::connect(ui->hideIntro, &QCheckBox::toggled, [&] { + GlobalSettings::setHideCreateInstanceIntro(ui->hideIntro->isChecked()); + }); +} + +bool IntroPage::doSkip() const +{ + return m_skip; +} + +TypePage::TypePage(CreateInstanceDialog& dlg) + : Page(dlg) +{ + // replace placeholders with actual paths + ui->createGlobal->setDescription(ui->createGlobal->description().arg( + compactPathForDescription(InstanceManager::singleton().globalInstancesRootPath()))); + + ui->createPortable->setDescription(ui->createPortable->description().arg( + compactPathForDescription(InstanceManager::singleton().portablePath()))); + + tuneTypeButtonHeight(ui->createGlobal); + tuneTypeButtonHeight(ui->createPortable); + + // Portable instances can now be created at any location, so always enable + ui->createPortable->setEnabled(true); + ui->portableExistsLabel->setVisible(false); + + QObject::connect(ui->createGlobal, &QAbstractButton::clicked, [&] { + global(); + }); + + QObject::connect(ui->createPortable, &QAbstractButton::clicked, [&] { + portable(); + }); +} + +bool TypePage::ready() const +{ + return (m_type != CreateInstanceDialog::NoType); +} + +CreateInstanceDialog::Types TypePage::selectedInstanceType() const +{ + return m_type; +} + +void TypePage::global() +{ + m_type = CreateInstanceDialog::Global; + + ui->createGlobal->setChecked(true); + ui->createPortable->setChecked(false); + + next(); +} + +void TypePage::portable() +{ + m_type = CreateInstanceDialog::Portable; + + ui->createGlobal->setChecked(false); + ui->createPortable->setChecked(true); + + next(); +} + +void TypePage::doActivated(bool firstTime) +{ + if (firstTime) { + ui->createGlobal->setFocus(); + } +} + +GamePage::Game::Game(IPluginGame* g) : game(g), installed(g->isInstalled()) +{ + if (installed) { + dir = game->gameDirectory().path(); + } +} + +GamePage::GamePage(CreateInstanceDialog& dlg) : Page(dlg) +{ + createGames(); + fillList(); + + m_filter.setEdit(ui->gamesFilter); + + QObject::connect(&m_filter, &FilterWidget::changed, [&] { + fillList(); + }); + QObject::connect(ui->showAllGames, &QCheckBox::clicked, [&] { + fillList(); + }); +} + +bool GamePage::ready() const +{ + return (m_selection != nullptr); +} + +bool GamePage::action(CreateInstanceDialog::Actions a) +{ + using Actions = CreateInstanceDialog::Actions; + + if (a == Actions::Find) { + ui->gamesFilter->setFocus(); + return true; + } + + return false; +} + +IPluginGame* GamePage::selectedGame() const +{ + if (!m_selection) { + return nullptr; + } + + return m_selection->game; +} + +QString GamePage::selectedGameLocation() const +{ + if (!m_selection) { + return {}; + } + + return QDir::toNativeSeparators(m_selection->dir); +} + +void GamePage::select(IPluginGame* game, const QString& dir) +{ + Game* checked = findGame(game); + + if (checked) { + if (!checked->installed || (detectMicrosoftStore(checked->dir) && + !confirmMicrosoftStore(checked->dir, checked->game))) { + if (dir.isEmpty()) { + // the selected game has no installation directory and none was given, + // ask the user + + const auto path = QFileDialog::getExistingDirectory( + &m_dlg, + QObject::tr("Find game installation for %1").arg(game->displayGameName()), + {}, {}); + + if (path.isEmpty()) { + // cancelled + checked = nullptr; + } else if (detectMicrosoftStore(path) && !confirmMicrosoftStore(path, game)) { + // cancelled + checked = nullptr; + } else { + // check whether a plugin supports the given directory; this can + // return the same plugin, a different one, or null + checked = checkInstallation(path, checked); + + if (checked) { + // plugin was found, remember this path + checked->dir = path; + checked->installed = true; + } + } + } else { + // the selected game didn't detect anything, but a directory was given, + // so use that + checked->dir = dir; + checked->installed = true; + } + } + } + + // select this plugin, if any + m_selection = checked; + + // update the button associated with it in case the paths have changed + updateButton(checked); + + // toggle it on + selectButton(checked); + + updateNavigation(); + + if (checked) { + // automatically move to the next page when a game is selected + next(); + } +} + +void GamePage::selectCustom() +{ + const auto path = QFileDialog::getExistingDirectory( + &m_dlg, QObject::tr("Find game installation"), {}, {}); + + if (path.isEmpty()) { + // reselect the previous button + selectButton(m_selection); + return; + } + + // Microsoft store games are not supported + if (detectMicrosoftStore(path) && !confirmMicrosoftStore(path, nullptr)) { + // reselect the previous button + selectButton(m_selection); + return; + } + + // try to find a plugin that likes this directory + for (auto& g : m_games) { + if (g->game->looksValid(path)) { + // found one + g->dir = path; + g->installed = true; + + // select it + select(g->game); + + // update the button because the path has changed + updateButton(g.get()); + + return; + } + } + + // warning to the user + warnUnrecognized(path); + + // reselect the previous button + selectButton(m_selection); +} + +void GamePage::warnUnrecognized(const QString& path) +{ + // put the list of supported games in the details textbox + QString supportedGames; + for (auto* game : sortedGamePlugins()) { + supportedGames += game->displayGameName() + "\n"; + } + + QMessageBox dlg(&m_dlg); + + dlg.setWindowTitle(QObject::tr("Unrecognized game")); + dlg.setText( + QObject::tr("The folder %1 does not seem to contain a game Mod Organizer can " + "manage.") + .arg(path)); + dlg.setInformativeText(QObject::tr("See details for the list of supported games.")); + dlg.setDetailedText(supportedGames); + dlg.setIcon(QMessageBox::Warning); + dlg.setStandardButtons(QMessageBox::Ok); + + dlg.exec(); +} + +std::vector GamePage::sortedGamePlugins() const +{ + std::vector v; + + // all game plugins + for (auto* game : m_pc.plugins()) { + v.push_back(game); + } + + // natsort + std::sort(v.begin(), v.end(), [](auto* a, auto* b) { + return (naturalCompare(a->displayGameName(), b->displayGameName()) < 0); + }); + + return v; +} + +void GamePage::createGames() +{ + m_games.clear(); + + for (auto* game : sortedGamePlugins()) { + m_games.push_back(std::make_unique(game)); + } +} + +GamePage::Game* GamePage::findGame(IPluginGame* game) +{ + for (auto& g : m_games) { + if (g->game == game) { + return g.get(); + } + } + + return nullptr; +} + +void GamePage::createGameButton(Game* g) +{ + g->button = new QCommandLinkButton; + g->button->setCheckable(true); + g->button->setIconSize(QSize(24, 24)); + + updateButton(g); + + QObject::connect(g->button, &QAbstractButton::clicked, [g, this] { + select(g->game); + }); +} + +void GamePage::addButton(QAbstractButton* b) +{ + auto* ly = static_cast(ui->games->layout()); + + // insert before the stretch + ly->insertWidget(ly->count() - 1, b); +} + +void GamePage::updateButton(Game* g) +{ + if (!g || !g->button) { + return; + } + + g->button->setText(g->game->displayGameName().replace("&", "&&")); + QIcon icon = g->game->gameIcon(); + if (icon.isNull()) { + icon = QIcon(":/MO/gui/executable"); + } + if (icon.isNull()) { + icon = QApplication::style()->standardIcon(QStyle::SP_ComputerIcon); + } + g->button->setIcon(icon); + + if (g->installed) { + const QString compact = compactPathForDescription(g->dir); + const QFontMetrics fm(g->button->font()); + const QString elided = fm.elidedText(compact, Qt::ElideMiddle, 460); + g->button->setDescription(elided); + g->button->setToolTip(QDir::toNativeSeparators(g->dir)); + } else { + g->button->setDescription(QObject::tr("No installation found")); + g->button->setToolTip({}); + } +} + +void GamePage::selectButton(Game* g) +{ + // go through each game, set the button that is for game `g` as active; + // some button might not exist, which happens when selecting a custom + // folder for a game that was considered uninstalled + + for (const auto& gg : m_games) { + if (!g) { + // nothing should be selected + if (gg->button) { + gg->button->setChecked(false); + } + + continue; + } + + if (gg->game == g->game) { + // this is the button that should be selected + + if (!gg->button) { + // this happens when the button wasn't visible because the game + // was not installed; create it and show it + // and it has a button, just check it + createGameButton(gg.get()); + addButton(gg->button); + } + + gg->button->setChecked(true); + gg->button->setFocus(); + } else { + // this is not the button you're looking for + if (gg->button) { + gg->button->setChecked(false); + } + } + } +} + +void GamePage::clearButtons() +{ + auto* ly = static_cast(ui->games->layout()); + + ui->games->setUpdatesEnabled(false); + + // delete all children + qDeleteAll(ui->games->findChildren("", Qt::FindDirectChildrenOnly)); + + // stretch widgets added with addStretch() are not in the parent widget, + // they have to be deleted from the layout itself + while (auto* child = ly->takeAt(0)) + delete child; + + // add a stretch, buttons will be added before + ly->addStretch(); + + ui->games->setUpdatesEnabled(true); + + for (auto& g : m_games) { + // all buttons have been deleted + g->button = nullptr; + } +} + +QCommandLinkButton* GamePage::createCustomButton() +{ + auto* b = new QCommandLinkButton; + + b->setText(QObject::tr("Browse...")); + b->setDescription(QObject::tr("The folder must contain a valid game installation")); + + QObject::connect(b, &QAbstractButton::clicked, [&] { + selectCustom(); + }); + + return b; +} + +void GamePage::fillList() +{ + const bool showAll = ui->showAllGames->isChecked(); + + clearButtons(); + + Game* firstButton = nullptr; + + for (auto& g : m_games) { + if (!showAll && !g->installed) { + // not installed + continue; + } + + if (!m_filter.matches(g->game->gameName()) && + !m_filter.matches(g->game->displayGameName())) { + // filtered out + continue; + } + + createGameButton(g.get()); + addButton(g->button); + + if (!firstButton) { + firstButton = g.get(); + } + } + + // browse button + addButton(createCustomButton()); + + if (firstButton) { + firstButton->button->setDefault(true); + } +} + +GamePage::Game* GamePage::checkInstallation(const QString& path, Game* g) +{ + if (g->game->looksValid(path)) { + // okay + return g; + } + + if (detectMicrosoftStore(path) && confirmMicrosoftStore(path, g->game)) { + // okay + return g; + } + + // the selected game can't use that folder, find another one + IPluginGame* otherGame = nullptr; + + for (auto* gg : m_pc.plugins()) { + if (gg->looksValid(path)) { + otherGame = gg; + break; + } + } + + if (otherGame == g->game) { + // shouldn't happen, but okay + return g; + } + + if (otherGame) { + // an alternative was found, ask the user about it + auto* confirmedGame = confirmOtherGame(path, g->game, otherGame); + + if (!confirmedGame) { + // cancelled + return nullptr; + } + + // make it look like the user clicked that button instead + g = findGame(confirmedGame); + if (!g) { + return nullptr; + } + } else { + // nothing can manage this, but the user can override + if (!confirmUnknown(path, g->game)) { + // cancelled + return nullptr; + } + } + + // remember this path + g->dir = path; + g->installed = true; + + updateButton(g); + + return g; +} + +bool GamePage::detectMicrosoftStore(const QString& path) +{ + return path.contains("/ModifiableWindowsApps/") || path.contains("/WindowsApps/"); +} + +bool GamePage::confirmMicrosoftStore(const QString& path, IPluginGame* game) +{ + const auto r = + TaskDialog(&m_dlg) + .title(QObject::tr("Microsoft Store game")) + .main(QObject::tr("Microsoft Store game")) + .content( + QObject::tr( + "The folder %1 seems to be a Microsoft Store game install. Games" + " installed through the Microsoft Store are not supported by Mod " + "Organizer" + " and will not work properly.") + .arg(path)) + .button( + {game ? QObject::tr("Use this folder for %1").arg(game->displayGameName()) + : QObject::tr("Use this folder"), + QObject::tr("I know what I'm doing"), QMessageBox::Ignore}) + .button({QObject::tr("Cancel"), QMessageBox::Cancel}) + .exec(); + + return (r == QMessageBox::Ignore); +} + +bool GamePage::confirmUnknown(const QString& path, IPluginGame* game) +{ + const auto r = + TaskDialog(&m_dlg) + .title(QObject::tr("Unrecognized game")) + .main(QObject::tr("Unrecognized game")) + .content( + QObject::tr("The folder %1 does not seem to contain an installation for " + "%2 or " + "for any other game Mod Organizer can manage.") + .arg(path) + .arg(game->displayGameName())) + .button({QObject::tr("Use this folder for %1").arg(game->displayGameName()), + QObject::tr("I know what I'm doing"), QMessageBox::Ignore}) + .button({QObject::tr("Cancel"), QMessageBox::Cancel}) + .exec(); + + return (r == QMessageBox::Ignore); +} + +IPluginGame* GamePage::confirmOtherGame(const QString& path, IPluginGame* selectedGame, + IPluginGame* guessedGame) +{ + const auto r = + TaskDialog(&m_dlg) + .title(QObject::tr("Incorrect game")) + .main(QObject::tr("Incorrect game")) + .content( + QObject::tr( + "The folder %1 seems to contain an installation for " + "%2, " + "not " + "%3.") + .arg(path) + .arg(guessedGame->displayGameName()) + .arg(selectedGame->displayGameName())) + .button({QObject::tr("Manage %1 instead").arg(guessedGame->displayGameName()), + QMessageBox::Ok}) + .button({QObject::tr("Use this folder for %1") + .arg(selectedGame->displayGameName()), + QObject::tr("I know what I'm doing"), QMessageBox::Ignore}) + .button({QObject::tr("Cancel"), QMessageBox::Cancel}) + .exec(); + + switch (r) { + case QMessageBox::Ok: + return guessedGame; + + case QMessageBox::Ignore: + return selectedGame; + + case QMessageBox::Cancel: + default: + return nullptr; + } +} + +VariantsPage::VariantsPage(CreateInstanceDialog& dlg) + : Page(dlg) +{} + +bool VariantsPage::ready() const +{ + // note that this isn't called when doSkip() is true, which happens when + // the game has no variants + + return !m_selection.isEmpty(); +} + +bool VariantsPage::doSkip() const +{ + auto* g = m_dlg.rawCreationInfo().game; + if (!g) { + // shouldn't happen + return true; + } + + return (g->gameVariants().size() < 2); +} + +void VariantsPage::doActivated(bool) +{ + auto* g = m_dlg.rawCreationInfo().game; + + if (m_previousGame != g) { + // recreate the list, the game has changed + m_previousGame = g; + m_selection = ""; + fillList(); + } +} + +void VariantsPage::select(const QString& variant) +{ + m_selection = variant; + + // find the button, set it checked + for (auto* b : m_buttons) { + if (b->text() == variant) { + b->setChecked(true); + } else { + b->setChecked(false); + } + } + + updateNavigation(); + + if (!m_selection.isEmpty()) { + // automatically move to the next page when a variant is selected + next(); + } +} + +QString VariantsPage::selectedGameVariant(MOBase::IPluginGame* game) const +{ + if (!game) { + return {}; + } + + if (game->gameVariants().size() < 2) { + return {}; + } else { + return m_selection; + } +} + +void VariantsPage::fillList() +{ + ui->editions->clear(); + m_buttons.clear(); + + auto* g = m_dlg.rawCreationInfo().game; + if (!g) { + // shouldn't happen + return; + } + + // for each variant, create a checkable button and add it + for (auto& v : g->gameVariants()) { + auto* b = new QCommandLinkButton(v); + b->setCheckable(true); + + QObject::connect(b, &QAbstractButton::clicked, [v, this] { + select(v); + }); + + ui->editions->addButton(b, QDialogButtonBox::AcceptRole); + m_buttons.push_back(b); + } + + if (!m_buttons.empty()) { + m_buttons[0]->setDefault(true); + } +} + +NamePage::NamePage(CreateInstanceDialog& dlg) + : Page(dlg), m_label(ui->instanceNameLabel), + m_exists(ui->instanceNameExists), m_invalid(ui->instanceNameInvalid) +{ + QObject::connect(ui->instanceName, &QLineEdit::textEdited, [&] { + onChanged(); + }); + + QObject::connect(ui->instanceName, &QLineEdit::returnPressed, [&] { + next(); + }); +} + +bool NamePage::ready() const +{ + // checked when textboxes change or when the page is activated + return m_okay; +} + +bool NamePage::doSkip() const +{ + // portable instances have no name + return (m_dlg.rawCreationInfo().type == CreateInstanceDialog::Portable); +} + +void NamePage::doActivated(bool) +{ + auto* g = m_dlg.rawCreationInfo().game; + if (!g) { + // shouldn't happen, next should be disabled + return; + } + + m_label.setText(g->gameName()); + + // generate a name if the user hasn't changed the text in case the game + // changed, or if it's empty + if (!m_modified || ui->instanceName->text().isEmpty()) { + const auto n = InstanceManager::singleton().makeUniqueName(g->gameName()); + ui->instanceName->setText(n); + m_modified = false; + } + + verify(); +} + +QString NamePage::selectedInstanceName() const +{ + if (!m_okay) { + return {}; + } + + const auto text = ui->instanceName->text().trimmed(); + return MOBase::sanitizeFileName(text); +} + +void NamePage::onChanged() +{ + m_modified = true; + verify(); +} + +void NamePage::verify() +{ + const auto root = InstanceManager::singleton().globalInstancesRootPath(); + m_okay = checkName(root, ui->instanceName->text()); + updateNavigation(); +} + +bool NamePage::checkName(QString parentDir, QString name) +{ + bool exists = false; + bool invalid = false; + bool empty = false; + + name = name.trimmed(); + + if (name.isEmpty()) { + empty = true; + } else { + if (MOBase::validFileName(name)) { + exists = QDir(parentDir).exists(name); + } else { + invalid = true; + } + } + + bool okay = false; + + if (exists) { + m_exists.setVisible(true); + m_exists.setText(QDir(parentDir).filePath(name)); + m_invalid.setVisible(false); + } else if (invalid) { + m_exists.setVisible(false); + m_invalid.setVisible(true); + m_invalid.setText(name); + } else { + okay = !empty; + m_exists.setVisible(false); + m_invalid.setVisible(false); + } + + return okay; +} + +ProfilePage::ProfilePage(CreateInstanceDialog& dlg) : Page(dlg) {} + +bool ProfilePage::ready() const +{ + return true; +} + +CreateInstanceDialog::ProfileSettings ProfilePage::profileSettings() const +{ + CreateInstanceDialog::ProfileSettings profileSettings; + profileSettings.localInis = ui->profileInisCheckbox->isChecked(); + profileSettings.localSaves = ui->profileSavesCheckbox->isChecked(); + profileSettings.archiveInvalidation = ui->archiveInvalidationCheckbox->isChecked(); + return profileSettings; +} + +PathsPage::PathsPage(CreateInstanceDialog& dlg) + : Page(dlg), m_label(ui->pathsLabel), + m_simpleExists(ui->locationExists), m_simpleInvalid(ui->locationInvalid), + m_advancedExists(ui->advancedDirExists), + m_advancedInvalid(ui->advancedDirInvalid) +{ + auto setEdit = [&](QLineEdit* e) { + QObject::connect(e, &QLineEdit::textEdited, [&] { + onChanged(); + }); + QObject::connect(e, &QLineEdit::returnPressed, [&] { + next(); + }); + }; + + auto setBrowse = [&](QAbstractButton* b, QLineEdit* e) { + QObject::connect(b, &QAbstractButton::clicked, [this, e] { + browse(e); + }); + }; + + setEdit(ui->location); + setEdit(ui->base); + setEdit(ui->downloads); + setEdit(ui->mods); + setEdit(ui->profiles); + setEdit(ui->overwrite); + + setBrowse(ui->browseLocation, ui->location); + setBrowse(ui->browseBase, ui->base); + setBrowse(ui->browseDownloads, ui->downloads); + setBrowse(ui->browseMods, ui->mods); + setBrowse(ui->browseProfiles, ui->profiles); + setBrowse(ui->browseOverwrite, ui->overwrite); + + QObject::connect(ui->advancedPathOptions, &QCheckBox::clicked, [&] { + onAdvanced(); + }); + + ui->pathPages->setCurrentIndex(0); +} + +bool PathsPage::ready() const +{ + // set when the page is activated, textboxes are changed or the advanced + // checkbox is toggled + return m_okay; +} + +void PathsPage::doActivated(bool firstTime) +{ + const auto name = m_dlg.rawCreationInfo().instanceName; + const auto type = m_dlg.rawCreationInfo().type; + + // if the instance name or type have changed, all the paths must be + // regenerated + const bool changed = (m_lastInstanceName != name) || (m_lastType != type); + + const bool isGlobal = (type == CreateInstanceDialog::Global); + + // Global instances have a fixed location derived from the instance name; + // the user should not be able to browse to an arbitrary directory. + ui->location->setReadOnly(isGlobal); + ui->browseLocation->setVisible(!isGlobal); + ui->advancedPathOptions->setVisible(!isGlobal); + + // If switching from portable back to global, reset to simple view + if (isGlobal && ui->advancedPathOptions->isChecked()) { + ui->advancedPathOptions->setChecked(false); + ui->pathPages->setCurrentIndex(0); + } + + // generating and paths + setPaths(name, changed); + checkPaths(); + + updateNavigation(); + + m_label.setText(m_dlg.rawCreationInfo().game->gameName()); + m_lastInstanceName = name; + m_lastType = type; + + if (firstTime) { + ui->location->setFocus(); + } +} + +CreateInstanceDialog::Paths PathsPage::selectedPaths() const +{ + CreateInstanceDialog::Paths p; + + if (ui->advancedPathOptions->isChecked()) { + p.base = ui->base->text(); + p.downloads = ui->downloads->text(); + p.mods = ui->mods->text(); + p.profiles = ui->profiles->text(); + p.overwrite = ui->overwrite->text(); + } else { + p.base = ui->location->text(); + } + + return p; +} + +void PathsPage::onChanged() +{ + checkPaths(); + updateNavigation(); +} + +void PathsPage::browse(QLineEdit* e) +{ + const auto s = + QFileDialog::getExistingDirectory(&m_dlg, {}, e->text(), {}); + if (s.isNull() || s.isEmpty()) { + return; + } + + e->setText(QDir::toNativeSeparators(s)); +} + +void PathsPage::checkPaths() +{ + if (ui->advancedPathOptions->isChecked()) { + // checking advanced paths + m_okay = checkAdvancedPath(ui->base->text()) && + checkAdvancedPath(resolve(ui->downloads->text())) && + checkAdvancedPath(resolve(ui->mods->text())) && + checkAdvancedPath(resolve(ui->profiles->text())) && + checkAdvancedPath(resolve(ui->overwrite->text())); + } else { + // checking simple path + m_okay = checkSimplePath(ui->location->text()); + } +} + +bool PathsPage::checkSimplePath(const QString& path) +{ + return checkPath(path, m_simpleExists, m_simpleInvalid); +} + +bool PathsPage::checkAdvancedPath(const QString& path) +{ + return checkPath(path, m_advancedExists, m_advancedInvalid); +} + +QString PathsPage::resolve(const QString& path) const +{ + return PathSettings::resolve(path, ui->base->text()); +} + +void PathsPage::onAdvanced() +{ + // the base/location textboxes are different widgets but they represent the + // same base path value, so they're synced between pages + + if (ui->advancedPathOptions->isChecked()) { + ui->base->setText(ui->location->text()); + ui->pathPages->setCurrentIndex(1); + } else { + ui->location->setText(ui->base->text()); + ui->pathPages->setCurrentIndex(0); + } + + checkPaths(); +} + +void PathsPage::setPaths(const QString& name, bool force) +{ + QString basePath; + + if (m_dlg.rawCreationInfo().type == CreateInstanceDialog::Portable) { + basePath = InstanceManager::singleton().portablePath(); + } else { + const auto root = InstanceManager::singleton().globalInstancesRootPath(); + basePath = root + "/" + name; + // Global instances always use the auto-derived path + force = true; + } + + basePath = QDir::toNativeSeparators(QDir::cleanPath(basePath)); + + // all paths are set regardless of advanced checkbox + + setIfEmpty(ui->location, basePath, force); + setIfEmpty(ui->base, basePath, force); + + setIfEmpty(ui->downloads, makeDefaultPath(AppConfig::downloadPath()), force); + setIfEmpty(ui->mods, makeDefaultPath(AppConfig::modsPath()), force); + setIfEmpty(ui->profiles, makeDefaultPath(AppConfig::profilesPath()), force); + setIfEmpty(ui->overwrite, makeDefaultPath(AppConfig::overwritePath()), force); +} + +void PathsPage::setIfEmpty(QLineEdit* e, const QString& path, bool force) +{ + if (e->text().isEmpty() || force) { + e->setText(path); + } +} + +bool PathsPage::checkPath(QString path, PlaceholderLabel& existsLabel, + PlaceholderLabel& invalidLabel) +{ + auto& m = InstanceManager::singleton(); + + bool exists = false; + bool invalid = false; + bool empty = false; + + path = QDir::toNativeSeparators(path.trimmed()); + + if (path.isEmpty()) { + empty = true; + } else { + const QDir d(path); + + if (MOBase::validFileName(d.dirName())) { + if (m_dlg.rawCreationInfo().type == CreateInstanceDialog::Portable) { + // the default data path for a portable instance is the application + // directory, so it's not an error if it exists + if (QDir(path) != InstanceManager::portablePath()) { + exists = QDir(path).exists(); + } + } else { + exists = QDir(path).exists(); + } + } else { + invalid = true; + } + } + + bool okay = true; + + if (invalid) { + okay = false; + existsLabel.setVisible(false); + invalidLabel.setVisible(true); + invalidLabel.setText(path); + } else if (empty) { + okay = false; + existsLabel.setVisible(false); + invalidLabel.setVisible(false); + } else if (exists) { + // this is just a warning + existsLabel.setVisible(true); + existsLabel.setText(path); + invalidLabel.setVisible(false); + } else { + okay = true; + existsLabel.setVisible(false); + invalidLabel.setVisible(false); + } + + return okay; +} + +NexusPage::NexusPage(CreateInstanceDialog& dlg) : Page(dlg) +{ + m_connectionUI.reset(new NexusConnectionUI(&m_dlg, dlg.settings(), ui->nexusConnect, + nullptr, ui->nexusManual, ui->nexusLog)); + + // just check it once, or connecting and then going back and forth would skip + // the page, which would be unexpected + m_skip = GlobalSettings::hasNexusApiKey(); +} + +NexusPage::~NexusPage() = default; + +bool NexusPage::ready() const +{ + // this page is optional + return true; +} + +bool NexusPage::doSkip() const +{ + return m_skip; +} + +ConfirmationPage::ConfirmationPage(CreateInstanceDialog& dlg) : Page(dlg) {} + +void ConfirmationPage::doActivated(bool) +{ + ui->review->setPlainText(makeReview()); + ui->creationLog->clear(); +} + +QString ConfirmationPage::makeReview() const +{ + QStringList lines; + + const auto ci = m_dlg.rawCreationInfo(); + + lines.push_back(QObject::tr("Instance type: %1").arg(toLocalizedString(ci.type))); + + lines.push_back(QObject::tr("Instance location: %1").arg(ci.dataPath)); + + if (ci.type != CreateInstanceDialog::Portable) { + lines.push_back(QObject::tr("Instance name: %1").arg(ci.instanceName)); + } + + lines.push_back(QObject::tr("Profile settings:")); + lines.push_back( + QObject::tr(" Local INIs: %1") + .arg(ci.profileSettings.localInis ? QObject::tr("yes") : QObject::tr("no"))); + lines.push_back( + QObject::tr(" Local Saves: %1") + .arg(ci.profileSettings.localSaves ? QObject::tr("yes") : QObject::tr("no"))); + lines.push_back(QObject::tr(" Automatic Archive Invalidation: %1") + .arg(ci.profileSettings.archiveInvalidation ? QObject::tr("yes") + : QObject::tr("no"))); + + if (ci.paths.downloads.isEmpty()) { + // simple settings + if (ci.paths.base != ci.dataPath) { + lines.push_back(QObject::tr("Base directory: %1").arg(ci.paths.base)); + } + } else { + // advanced settings + lines.push_back(QObject::tr("Base directory: %1").arg(ci.paths.base)); + lines.push_back(dirLine(QObject::tr("Downloads"), ci.paths.downloads)); + lines.push_back(dirLine(QObject::tr("Mods"), ci.paths.mods)); + lines.push_back(dirLine(QObject::tr("Profiles"), ci.paths.profiles)); + lines.push_back(dirLine(QObject::tr("Overwrite"), ci.paths.overwrite)); + } + + // game + QString name = ci.game->gameName(); + if (!ci.gameVariant.isEmpty()) { + name += " (" + ci.gameVariant + ")"; + } + + lines.push_back(QObject::tr("Game: %1").arg(name)); + lines.push_back(QObject::tr("Game location: %1").arg(ci.gameLocation)); + + return lines.join("\n"); +} + +QString ConfirmationPage::dirLine(const QString& caption, const QString& path) +{ + return QString(" - %1: %2").arg(caption).arg(path); +} + +} // namespace cid diff --git a/src/src/datatab.cpp b/src/src/datatab.cpp index 0f9b5ec..26430a1 100644 --- a/src/src/datatab.cpp +++ b/src/src/datatab.cpp @@ -1,274 +1,274 @@ -#include "datatab.h" -#include "filetree.h" -#include "filetreemodel.h" -#include "messagedialog.h" -#include "modelutils.h" -#include "organizercore.h" -#include "settings.h" -#include "ui_mainwindow.h" -#include -#include - -#include -#include -#include - -using namespace MOShared; -using namespace MOBase; - -// in mainwindow.cpp -QString UnmanagedModName(); - -DataTab::DataTab(OrganizerCore& core, PluginContainer& pc, QWidget* parent, - Ui::MainWindow* mwui) - : m_core(core), m_pluginContainer(pc), m_parent(parent), - ui{mwui->tabWidget, - mwui->dataTab, - mwui->dataTabRefresh, - mwui->dataTabBrowseVFS, - mwui->dataTabBrowseRootBuilder, - mwui->dataTree, - mwui->dataTabShowOnlyConflicts, - mwui->dataTabShowFromArchives, - mwui->dataTabShowHiddenFiles} - -{ - m_filetree.reset(new FileTree(core, m_pluginContainer, ui.tree)); - m_filter.setUseSourceSort(true); - m_filter.setFilterColumn(FileTreeModel::FileName); - m_filter.setEdit(mwui->dataTabFilter); - m_filter.setList(mwui->dataTree); - m_filter.setUpdateDelay(true); - - if (auto* m = m_filter.proxyModel()) { - m->setDynamicSortFilter(false); - } - - connect(&m_filter, &FilterWidget::aboutToChange, [&] { - ensureFullyLoaded(); - }); - - connect(ui.browseVFS, &QPushButton::clicked, [&] { - onBrowseVFS(); - }); - connect(ui.browseRootBuilder, &QPushButton::clicked, [&] { - onBrowseRootBuilder(); - }); - - // Hide Root Builder button if the feature is disabled for this instance. - { - bool rbEnabled = true; - if (const auto* s = Settings::maybeInstance()) { - const QSettings instanceIni(s->filename(), QSettings::IniFormat); - rbEnabled = instanceIni.value("fluorine/vfs_root_builder", true).toBool(); - } - ui.browseRootBuilder->setVisible(rbEnabled); - } - - connect(ui.refresh, &QPushButton::clicked, [&] { - onRefresh(); - }); - - connect(ui.conflicts, &QCheckBox::toggled, [&] { - onConflicts(); - }); - - connect(ui.archives, &QCheckBox::toggled, [&] { - onArchives(); - }); - - connect(ui.hiddenFiles, &QCheckBox::toggled, [&] { - onHiddenFiles(); - }); - - connect(ui.tree->selectionModel(), &QItemSelectionModel::selectionChanged, [=, this] { - const auto* fileTreeModel = m_filetree->model(); - const auto& selectedIndexList = MOShared::indexViewToModel( - ui.tree->selectionModel()->selectedRows(), fileTreeModel); - std::set mods; - for (auto& idx : selectedIndexList) { - mods.insert(fileTreeModel->itemFromIndex(idx)->mod()); - } - mwui->modList->setHighlightedMods(mods); - }); - - connect(m_filetree.get(), &FileTree::executablesChanged, this, - &DataTab::executablesChanged); - - connect(m_filetree.get(), &FileTree::originModified, this, &DataTab::originModified); - - connect(m_filetree.get(), &FileTree::displayModInformation, this, - &DataTab::displayModInformation); -} - -void DataTab::saveState(Settings& s) const -{ - s.geometry().saveState(ui.tree->header()); - s.widgets().saveChecked(ui.conflicts); - s.widgets().saveChecked(ui.archives); - s.widgets().saveChecked(ui.hiddenFiles); -} - -void DataTab::restoreState(const Settings& s) const -{ - s.geometry().restoreState(ui.tree->header()); - - // prior to 2.3, the list was not sortable, and this remembered in the - // widget state, for whatever reason - ui.tree->setSortingEnabled(true); - - s.widgets().restoreChecked(ui.conflicts); - s.widgets().restoreChecked(ui.archives); - s.widgets().restoreChecked(ui.hiddenFiles); -} - -void DataTab::activated() -{ - if (m_needUpdate) { - updateTree(); - } - // update highlighted mods - ui.tree->selectionModel()->selectionChanged({}, {}); -} - -bool DataTab::isActive() const -{ - return ui.tabs->currentWidget() == ui.tab; -} - -void DataTab::onRefresh() -{ - if (QGuiApplication::keyboardModifiers() & Qt::ShiftModifier) { - m_filetree->model()->setEnabled(false); - m_filetree->clear(); - } - - m_core.refreshDirectoryStructure(); -} - -void DataTab::onBrowseVFS() -{ - QString const dataPath = m_core.managedGame()->dataDirectory().absolutePath(); - - // Mount the FUSE VFS so the file manager sees the merged mod files. - log::info("Mounting VFS for Browse..."); - m_core.prepareVFS(); - - // Open the game data folder in the system file manager. - shell::Explore(dataPath); - - // Show a modal dialog that keeps the VFS mounted. When the user - // closes it, we unmount. - QMessageBox box(QMessageBox::Information, - QObject::tr("Browse VFS"), - QObject::tr("The virtual filesystem is mounted.\n\n" - "The game Data folder has been opened in your " - "file manager. You can browse the merged mod " - "files as the game would see them.\n\n" - "Close this dialog to unmount the VFS."), - QMessageBox::Close, m_parent); - box.setWindowModality(Qt::WindowModal); - box.exec(); - - log::info("Unmounting VFS after Browse..."); - m_core.unmountVFS(); -} - -void DataTab::onBrowseRootBuilder() -{ - QString const gameRoot = m_core.managedGame()->gameDirectory().absolutePath(); - - // Mount the FUSE VFS which also triggers Root Builder deployment to the - // game root directory. - log::info("Mounting VFS for Root Builder browse..."); - m_core.prepareVFS(); - - // Open the game root folder (not Data/) so the user sees deployed Root files. - shell::Explore(gameRoot); - - QMessageBox box(QMessageBox::Information, - QObject::tr("Browse Root Builder"), - QObject::tr("The virtual filesystem is mounted and Root Builder " - "files have been deployed to the game root.\n\n" - "The game folder has been opened in your file manager. " - "You can see files deployed by Root Builder (e.g. SKSE, " - "ENB).\n\n" - "Close this dialog to unmount and clean up."), - QMessageBox::Close, m_parent); - box.setWindowModality(Qt::WindowModal); - box.exec(); - - log::info("Unmounting VFS after Root Builder browse..."); - m_core.unmountVFS(); -} - -void DataTab::updateTree() -{ - if (isActive()) { - doUpdateTree(); - } else { - m_needUpdate = true; - } -} - -void DataTab::doUpdateTree() -{ - m_filetree->model()->setEnabled(true); - m_filetree->refresh(); - - if (!m_filter.empty()) { - ensureFullyLoaded(); - - if (auto* m = m_filter.proxyModel()) { - m->invalidate(); - } - } - - m_needUpdate = false; -} - -void DataTab::ensureFullyLoaded() -{ - if (!m_filetree->fullyLoaded()) { - m_filter.setFilteringEnabled(false); - m_filetree->ensureFullyLoaded(); - m_filter.setFilteringEnabled(true); - } -} - -void DataTab::onConflicts() -{ - updateOptions(); -} - -void DataTab::onArchives() -{ - updateOptions(); -} - -void DataTab::onHiddenFiles() -{ - updateOptions(); -} - -void DataTab::updateOptions() -{ - using M = FileTreeModel; - - M::Flags flags = M::NoFlags; - - if (ui.conflicts->isChecked()) { - flags |= M::ConflictsOnly | M::PruneDirectories; - } - - if (ui.archives->isChecked()) { - flags |= M::Archives; - } - - if (ui.hiddenFiles->isChecked()) { - flags |= M::HiddenFiles; - } - - m_filetree->model()->setFlags(flags); - updateTree(); -} +#include "datatab.h" +#include "filetree.h" +#include "filetreemodel.h" +#include "messagedialog.h" +#include "modelutils.h" +#include "organizercore.h" +#include "settings.h" +#include "ui_mainwindow.h" +#include +#include + +#include +#include +#include + +using namespace MOShared; +using namespace MOBase; + +// in mainwindow.cpp +QString UnmanagedModName(); + +DataTab::DataTab(OrganizerCore& core, PluginContainer& pc, QWidget* parent, + Ui::MainWindow* mwui) + : m_core(core), m_pluginContainer(pc), m_parent(parent), + ui{.tabs=mwui->tabWidget, + .tab=mwui->dataTab, + .refresh=mwui->dataTabRefresh, + .browseVFS=mwui->dataTabBrowseVFS, + .browseRootBuilder=mwui->dataTabBrowseRootBuilder, + .tree=mwui->dataTree, + .conflicts=mwui->dataTabShowOnlyConflicts, + .archives=mwui->dataTabShowFromArchives, + .hiddenFiles=mwui->dataTabShowHiddenFiles} + +{ + m_filetree.reset(new FileTree(core, m_pluginContainer, ui.tree)); + m_filter.setUseSourceSort(true); + m_filter.setFilterColumn(FileTreeModel::FileName); + m_filter.setEdit(mwui->dataTabFilter); + m_filter.setList(mwui->dataTree); + m_filter.setUpdateDelay(true); + + if (auto* m = m_filter.proxyModel()) { + m->setDynamicSortFilter(false); + } + + connect(&m_filter, &FilterWidget::aboutToChange, [&] { + ensureFullyLoaded(); + }); + + connect(ui.browseVFS, &QPushButton::clicked, [&] { + onBrowseVFS(); + }); + connect(ui.browseRootBuilder, &QPushButton::clicked, [&] { + onBrowseRootBuilder(); + }); + + // Hide Root Builder button if the feature is disabled for this instance. + { + bool rbEnabled = true; + if (const auto* s = Settings::maybeInstance()) { + const QSettings instanceIni(s->filename(), QSettings::IniFormat); + rbEnabled = instanceIni.value("fluorine/vfs_root_builder", true).toBool(); + } + ui.browseRootBuilder->setVisible(rbEnabled); + } + + connect(ui.refresh, &QPushButton::clicked, [&] { + onRefresh(); + }); + + connect(ui.conflicts, &QCheckBox::toggled, [&] { + onConflicts(); + }); + + connect(ui.archives, &QCheckBox::toggled, [&] { + onArchives(); + }); + + connect(ui.hiddenFiles, &QCheckBox::toggled, [&] { + onHiddenFiles(); + }); + + connect(ui.tree->selectionModel(), &QItemSelectionModel::selectionChanged, [=, this] { + const auto* fileTreeModel = m_filetree->model(); + const auto& selectedIndexList = MOShared::indexViewToModel( + ui.tree->selectionModel()->selectedRows(), fileTreeModel); + std::set mods; + for (const auto& idx : selectedIndexList) { + mods.insert(fileTreeModel->itemFromIndex(idx)->mod()); + } + mwui->modList->setHighlightedMods(mods); + }); + + connect(m_filetree.get(), &FileTree::executablesChanged, this, + &DataTab::executablesChanged); + + connect(m_filetree.get(), &FileTree::originModified, this, &DataTab::originModified); + + connect(m_filetree.get(), &FileTree::displayModInformation, this, + &DataTab::displayModInformation); +} + +void DataTab::saveState(Settings& s) const +{ + s.geometry().saveState(ui.tree->header()); + s.widgets().saveChecked(ui.conflicts); + s.widgets().saveChecked(ui.archives); + s.widgets().saveChecked(ui.hiddenFiles); +} + +void DataTab::restoreState(const Settings& s) const +{ + s.geometry().restoreState(ui.tree->header()); + + // prior to 2.3, the list was not sortable, and this remembered in the + // widget state, for whatever reason + ui.tree->setSortingEnabled(true); + + s.widgets().restoreChecked(ui.conflicts); + s.widgets().restoreChecked(ui.archives); + s.widgets().restoreChecked(ui.hiddenFiles); +} + +void DataTab::activated() +{ + if (m_needUpdate) { + updateTree(); + } + // update highlighted mods + ui.tree->selectionModel()->selectionChanged({}, {}); +} + +bool DataTab::isActive() const +{ + return ui.tabs->currentWidget() == ui.tab; +} + +void DataTab::onRefresh() +{ + if (QGuiApplication::keyboardModifiers() & Qt::ShiftModifier) { + m_filetree->model()->setEnabled(false); + m_filetree->clear(); + } + + m_core.refreshDirectoryStructure(); +} + +void DataTab::onBrowseVFS() +{ + QString const dataPath = m_core.managedGame()->dataDirectory().absolutePath(); + + // Mount the FUSE VFS so the file manager sees the merged mod files. + log::info("Mounting VFS for Browse..."); + m_core.prepareVFS(); + + // Open the game data folder in the system file manager. + shell::Explore(dataPath); + + // Show a modal dialog that keeps the VFS mounted. When the user + // closes it, we unmount. + QMessageBox box(QMessageBox::Information, + QObject::tr("Browse VFS"), + QObject::tr("The virtual filesystem is mounted.\n\n" + "The game Data folder has been opened in your " + "file manager. You can browse the merged mod " + "files as the game would see them.\n\n" + "Close this dialog to unmount the VFS."), + QMessageBox::Close, m_parent); + box.setWindowModality(Qt::WindowModal); + box.exec(); + + log::info("Unmounting VFS after Browse..."); + m_core.unmountVFS(); +} + +void DataTab::onBrowseRootBuilder() +{ + QString const gameRoot = m_core.managedGame()->gameDirectory().absolutePath(); + + // Mount the FUSE VFS which also triggers Root Builder deployment to the + // game root directory. + log::info("Mounting VFS for Root Builder browse..."); + m_core.prepareVFS(); + + // Open the game root folder (not Data/) so the user sees deployed Root files. + shell::Explore(gameRoot); + + QMessageBox box(QMessageBox::Information, + QObject::tr("Browse Root Builder"), + QObject::tr("The virtual filesystem is mounted and Root Builder " + "files have been deployed to the game root.\n\n" + "The game folder has been opened in your file manager. " + "You can see files deployed by Root Builder (e.g. SKSE, " + "ENB).\n\n" + "Close this dialog to unmount and clean up."), + QMessageBox::Close, m_parent); + box.setWindowModality(Qt::WindowModal); + box.exec(); + + log::info("Unmounting VFS after Root Builder browse..."); + m_core.unmountVFS(); +} + +void DataTab::updateTree() +{ + if (isActive()) { + doUpdateTree(); + } else { + m_needUpdate = true; + } +} + +void DataTab::doUpdateTree() +{ + m_filetree->model()->setEnabled(true); + m_filetree->refresh(); + + if (!m_filter.empty()) { + ensureFullyLoaded(); + + if (auto* m = m_filter.proxyModel()) { + m->invalidate(); + } + } + + m_needUpdate = false; +} + +void DataTab::ensureFullyLoaded() +{ + if (!m_filetree->fullyLoaded()) { + m_filter.setFilteringEnabled(false); + m_filetree->ensureFullyLoaded(); + m_filter.setFilteringEnabled(true); + } +} + +void DataTab::onConflicts() +{ + updateOptions(); +} + +void DataTab::onArchives() +{ + updateOptions(); +} + +void DataTab::onHiddenFiles() +{ + updateOptions(); +} + +void DataTab::updateOptions() +{ + using M = FileTreeModel; + + M::Flags flags = M::NoFlags; + + if (ui.conflicts->isChecked()) { + flags |= M::ConflictsOnly | M::PruneDirectories; + } + + if (ui.archives->isChecked()) { + flags |= M::Archives; + } + + if (ui.hiddenFiles->isChecked()) { + flags |= M::HiddenFiles; + } + + m_filetree->model()->setFlags(flags); + updateTree(); +} diff --git a/src/src/directoryrefresher.cpp b/src/src/directoryrefresher.cpp index 486c8af..ad085cc 100644 --- a/src/src/directoryrefresher.cpp +++ b/src/src/directoryrefresher.cpp @@ -1,514 +1,513 @@ -/* -Copyright (C) 2012 Sebastian Herbord. All rights reserved. - -This file is part of Mod Organizer. - -Mod Organizer is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -Mod Organizer is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with Mod Organizer. If not, see . -*/ - -#include "directoryrefresher.h" -#include "shared/fileentry.h" -#include "shared/filesorigin.h" - -#include "envfs.h" -#include "game_features.h" -#include "iplugingame.h" -#include "modinfo.h" -#include "modinfodialogfwd.h" -#include "organizercore.h" -#include "report.h" -#include "settings.h" -#include "shared/util.h" -#include "utility.h" - -#include - -#include -#include -#include - -#include - -using namespace MOBase; -using namespace MOShared; - -DirectoryStats::DirectoryStats() -{ - std::memset(this, 0, sizeof(DirectoryStats)); -} - -DirectoryStats& DirectoryStats::operator+=(const DirectoryStats& o) -{ - dirTimes += o.dirTimes; - fileTimes += o.fileTimes; - sortTimes += o.sortTimes; - - subdirLookupTimes += o.subdirLookupTimes; - addDirectoryTimes += o.addDirectoryTimes; - - filesLookupTimes += o.filesLookupTimes; - addFileTimes += o.addFileTimes; - addOriginToFileTimes += o.addOriginToFileTimes; - addFileToOriginTimes += o.addFileToOriginTimes; - addFileToRegisterTimes += o.addFileToRegisterTimes; - - originExists += o.originExists; - originCreate += o.originCreate; - originsNeededEnabled += o.originsNeededEnabled; - - subdirExists += o.subdirExists; - subdirCreate += o.subdirCreate; - - fileExists += o.fileExists; - fileCreate += o.fileCreate; - filesInsertedInRegister += o.filesInsertedInRegister; - filesAssignedInRegister += o.filesAssignedInRegister; - - return *this; -} - -std::string DirectoryStats::csvHeader() -{ - QStringList const sl = {"dirTimes", - "fileTimes", - "sortTimes", - "subdirLookupTimes", - "addDirectoryTimes", - "filesLookupTimes", - "addFileTimes", - "addOriginToFileTimes", - "addFileToOriginTimes", - "addFileToRegisterTimes", - "originExists", - "originCreate", - "originsNeededEnabled", - "subdirExists", - "subdirCreate", - "fileExists", - "fileCreate", - "filesInsertedInRegister", - "filesAssignedInRegister"}; - - return sl.join(",").toStdString(); -} - -std::string DirectoryStats::toCsv() const -{ - QStringList oss; - - auto s = [](auto ns) { - return ns.count() / 1000.0 / 1000.0 / 1000.0; - }; - - oss << QString::number(s(dirTimes)) << QString::number(s(fileTimes)) - << QString::number(s(sortTimes)) - - << QString::number(s(subdirLookupTimes)) << QString::number(s(addDirectoryTimes)) - - << QString::number(s(filesLookupTimes)) << QString::number(s(addFileTimes)) - << QString::number(s(addOriginToFileTimes)) - << QString::number(s(addFileToOriginTimes)) - << QString::number(s(addFileToRegisterTimes)) - - << QString::number(originExists) << QString::number(originCreate) - << QString::number(originsNeededEnabled) - - << QString::number(subdirExists) << QString::number(subdirCreate) - - << QString::number(fileExists) << QString::number(fileCreate) - << QString::number(filesInsertedInRegister) - << QString::number(filesAssignedInRegister); - - return oss.join(",").toStdString(); -} - -void dumpStats(std::vector& stats) -{ - static int run = 0; - static const std::string file("c:\\tmp\\data.csv"); - - if (run == 0) { - std::ofstream out(file, std::ios::out | std::ios::trunc); - out << std::format("what,run,{}", DirectoryStats::csvHeader()) << "\n"; - } - - std::sort(stats.begin(), stats.end(), [](auto&& a, auto&& b) { - return (naturalCompare(QString::fromStdString(a.mod), - QString::fromStdString(b.mod)) < 0); - }); - - std::ofstream out(file, std::ios::app); - - DirectoryStats total; - for (const auto& s : stats) { - out << std::format("{},{},{}", s.mod, run, s.toCsv()) << "\n"; - total += s; - } - - out << std::format("total,{},{}", run, total.toCsv()) << "\n"; - - ++run; -} - -DirectoryRefresher::DirectoryRefresher(OrganizerCore* core, std::size_t threadCount) - : m_Core(*core), m_threadCount(threadCount) -{} - -DirectoryEntry* DirectoryRefresher::stealDirectoryStructure() -{ - QMutexLocker const locker(&m_RefreshLock); - return m_Root.release(); -} - -void DirectoryRefresher::setMods( - const std::vector>& mods, - const std::set& managedArchives) -{ - QMutexLocker const locker(&m_RefreshLock); - - m_Mods.clear(); - for (auto mod = mods.begin(); mod != mods.end(); ++mod) { - QString const name = std::get<0>(*mod); - ModInfo::Ptr const info = ModInfo::getByIndex(ModInfo::getIndex(name)); - QString path = std::get<1>(*mod); - QString const modDataDir = m_Core.managedGame()->modDataDirectory(); - path = modDataDir.isEmpty() ? path : path + "/" + modDataDir; - m_Mods.push_back( - EntryInfo(name, path, info->stealFiles(), info->archives(), std::get<2>(*mod))); - } - - m_EnabledArchives = managedArchives; -} - -void DirectoryRefresher::cleanStructure(DirectoryEntry* structure) -{ - static const wchar_t* files[] = {L"meta.ini", L"readme.txt"}; - for (int i = 0; i < sizeof(files) / sizeof(wchar_t*); ++i) { - structure->removeFile(files[i]); - } - - static const wchar_t* dirs[] = {L"fomod"}; - for (int i = 0; i < sizeof(dirs) / sizeof(wchar_t*); ++i) { - structure->removeDir(std::wstring(dirs[i])); - } -} - -void DirectoryRefresher::addModBSAToStructure(DirectoryEntry* root, - const QString& modName, int priority, - const QString& directory, - const QStringList& archives) -{ - QStringList loadOrder; - - auto gamePlugins = m_Core.gameFeatures().gameFeature(); - if (gamePlugins) { - loadOrder = gamePlugins->getLoadOrder(); - } - - std::vector lo; - for (auto&& s : loadOrder) { - lo.push_back(s.toStdWString()); - } - - std::vector archivesW; - for (auto&& a : archives) { - archivesW.push_back(a.toStdWString()); - } - - std::set enabledArchives; - for (auto&& a : m_EnabledArchives) { - enabledArchives.insert(a.toStdWString()); - } - - DirectoryStats dummy; - - root->addFromAllBSAs(modName.toStdWString(), - QDir::toNativeSeparators(directory).toStdWString(), priority, - archivesW, enabledArchives, lo, dummy); -} - -void DirectoryRefresher::stealModFilesIntoStructure(DirectoryEntry* directoryStructure, - const QString& modName, - int priority, - const QString& directory, - const QStringList& stealFiles) -{ - std::wstring const directoryW = ToWString(QDir::toNativeSeparators(directory)); - - // instead of adding all the files of the target directory, we just change the root of - // the specified files to this mod - DirectoryStats dummy; - FilesOrigin& origin = - directoryStructure->createOrigin(ToWString(modName), directoryW, priority, dummy); - - for (const QString& filename : stealFiles) { - if (filename.isEmpty()) { - log::warn("Trying to find file with no name"); - log::warn(" . modName: {}", modName); - log::warn(" . directory: {}", directory); - log::warn(" . priority: {}", priority); - for (int i = 0; i < stealFiles.length(); ++i) - log::warn(" . stealFiles[{}]: {}", i, stealFiles[i]); - continue; - } - QFileInfo const fileInfo(filename); - FileEntryPtr const file = directoryStructure->findFile(ToWString(fileInfo.fileName())); - if (file.get() != nullptr) { - if (file->getOrigin() == 0) { - // replace data as the origin on this bsa - file->removeOrigin(0); - } - origin.addFile(file->getIndex()); - file->addOrigin(origin.getID(), file->getFileTime(), L"", -1); - } else { - QString warnStr = fileInfo.absolutePath(); - if (warnStr.isEmpty()) - warnStr = filename; - log::warn("file not found: {}", warnStr); - } - } -} - -void DirectoryRefresher::addModFilesToStructure(DirectoryEntry* directoryStructure, - const QString& modName, int priority, - const QString& directory, - const QStringList& stealFiles) -{ - TimeThis const tt("DirectoryRefresher::addModFilesToStructure()"); - - std::wstring const directoryW = ToWString(QDir::toNativeSeparators(directory)); - DirectoryStats dummy; - - if (!stealFiles.empty()) { - stealModFilesIntoStructure(directoryStructure, modName, priority, directory, - stealFiles); - } else { - directoryStructure->addFromOrigin(ToWString(modName), directoryW, priority, dummy); - } -} - -void DirectoryRefresher::addModToStructure(DirectoryEntry* directoryStructure, - const QString& modName, int priority, - const QString& directory, - const QStringList& stealFiles, - const QStringList& archives) -{ - TimeThis const tt("DirectoryRefresher::addModToStructure()"); - - DirectoryStats dummy; - - if (!stealFiles.empty()) { - stealModFilesIntoStructure(directoryStructure, modName, priority, directory, - stealFiles); - } else { - std::wstring const directoryW = ToWString(QDir::toNativeSeparators(directory)); - directoryStructure->addFromOrigin(ToWString(modName), directoryW, priority, dummy); - } - - if (Settings::instance().archiveParsing()) { - addModBSAToStructure(directoryStructure, modName, priority, directory, archives); - } -} - -struct ModThread -{ - GameFeatures* gameFeatures; - DirectoryRefreshProgress* progress = nullptr; - DirectoryEntry* ds = nullptr; - std::wstring modName; - std::wstring path; - int prio = -1; - std::vector archives; - std::set enabledArchives; - std::vector* loadOrder = nullptr; - DirectoryStats* stats = nullptr; - env::DirectoryWalker walker; - - std::condition_variable cv; - std::mutex mutex; - bool ready = false; - - void wakeup() - { - { - std::scoped_lock const lock(mutex); - ready = true; - } - - cv.notify_one(); - } - - void run() - { - std::unique_lock lock(mutex); - cv.wait(lock, [&] { - return ready; - }); - - SetThisThreadName(QString::fromStdWString(modName + L" refresher")); - ds->addFromOrigin(walker, modName, path, prio, *stats); - - if (Settings::instance().archiveParsing()) { - ds->addFromAllBSAs(modName, path, prio, archives, enabledArchives, *loadOrder, - *stats); - } - - if (progress) { - progress->addDone(); - } - - SetThisThreadName(QString::fromStdWString(L"idle refresher")); - ready = false; - } -}; - -env::ThreadPool g_threads; - -void DirectoryRefresher::updateProgress(const DirectoryRefreshProgress* p) -{ - // careful: called from multiple threads - emit progress(p); -} - -void DirectoryRefresher::addMultipleModsFilesToStructure( - MOShared::DirectoryEntry* directoryStructure, const std::vector& entries, - DirectoryRefreshProgress* progress) -{ - std::vector stats(entries.size()); - - if (progress) { - progress->start(entries.size()); - } - - log::debug("refresher: using {} threads", m_threadCount); - g_threads.setMax(m_threadCount); - - std::vector loadOrder; - if (Settings::instance().archiveParsing()) { - auto gamePlugins = m_Core.gameFeatures().gameFeature(); - if (gamePlugins) { - QStringList const lo = gamePlugins->getLoadOrder(); - loadOrder.reserve(lo.size()); - for (auto&& s : lo) { - loadOrder.push_back(s.toStdWString()); - } - } - } - - for (std::size_t i = 0; i < entries.size(); ++i) { - const auto& e = entries[i]; - const int prio = e.priority + 1; - - if constexpr (DirectoryStats::EnableInstrumentation) { - stats[i].mod = entries[i].modName.toStdString(); - } - - try { - if (!e.stealFiles.empty()) { - stealModFilesIntoStructure(directoryStructure, e.modName, prio, e.absolutePath, - e.stealFiles); - - if (progress) { - progress->addDone(); - } - } else { - auto& mt = g_threads.request(); - - mt.gameFeatures = &m_Core.gameFeatures(); - mt.progress = progress; - mt.ds = directoryStructure; - mt.modName = e.modName.toStdWString(); - mt.path = QDir::toNativeSeparators(e.absolutePath).toStdWString(); - mt.prio = prio; - - mt.archives.clear(); - for (auto&& a : e.archives) { - mt.archives.push_back(a.toStdWString()); - } - - mt.enabledArchives.clear(); - for (auto&& a : m_EnabledArchives) { - mt.enabledArchives.insert(a.toStdWString()); - } - - mt.loadOrder = &loadOrder; - mt.stats = &stats[i]; - - mt.wakeup(); - } - } catch (const std::exception& ex) { - emit error(tr("failed to read mod (%1): %2").arg(e.modName, ex.what())); - } - } - - g_threads.waitForAll(); - - if constexpr (DirectoryStats::EnableInstrumentation) { - dumpStats(stats); - } -} - -void DirectoryRefresher::refresh() -{ - SetThisThreadName("DirectoryRefresher"); - TimeThis const tt("DirectoryRefresher::refresh()"); - auto* p = new DirectoryRefreshProgress(this); - - { - QMutexLocker const locker(&m_RefreshLock); - - m_Root.reset(new DirectoryEntry(L"data", nullptr, 0)); - - IPluginGame* game = qApp->property("managed_game").value(); - - const QString dataPath = game->dataDirectory().absolutePath(); - std::wstring const dataDirectory = - QDir::toNativeSeparators(dataPath).toStdWString(); - - log::debug("refresher: game data directory = '{}'", dataPath); - log::debug("refresher: data directory exists = {}", - QDir(dataPath).exists() ? "yes" : "no"); - - { - DirectoryStats dummy; - m_Root->addFromOrigin(L"data", dataDirectory, 0, dummy); - } - - for (const auto& directory : game->secondaryDataDirectories().toStdMap()) { - DirectoryStats dummy; - m_Root->addFromOrigin( - directory.first.toStdWString(), - QDir::toNativeSeparators(directory.second.absolutePath()).toStdWString(), 0, - dummy); - } - - std::sort(m_Mods.begin(), m_Mods.end(), [](auto lhs, auto rhs) { - return lhs.priority < rhs.priority; - }); - - addMultipleModsFilesToStructure(m_Root.get(), m_Mods, p); - - m_Root->getFileRegister()->sortOrigins(); - - cleanStructure(m_Root.get()); - - m_lastFileCount = m_Root->getFileRegister()->highestCount(); - log::debug("refresher saw {} files", m_lastFileCount); - } - - p->finish(); - - emit progress(p); - emit refreshed(); -} +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "directoryrefresher.h" +#include "shared/fileentry.h" +#include "shared/filesorigin.h" + +#include "envfs.h" +#include "game_features.h" +#include "iplugingame.h" +#include "modinfo.h" +#include "modinfodialogfwd.h" +#include "organizercore.h" +#include "report.h" +#include "settings.h" +#include "shared/util.h" +#include "utility.h" + +#include + +#include +#include +#include + +#include + +using namespace MOBase; +using namespace MOShared; + +DirectoryStats::DirectoryStats() +{ + std::memset(this, 0, sizeof(DirectoryStats)); +} + +DirectoryStats& DirectoryStats::operator+=(const DirectoryStats& o) +{ + dirTimes += o.dirTimes; + fileTimes += o.fileTimes; + sortTimes += o.sortTimes; + + subdirLookupTimes += o.subdirLookupTimes; + addDirectoryTimes += o.addDirectoryTimes; + + filesLookupTimes += o.filesLookupTimes; + addFileTimes += o.addFileTimes; + addOriginToFileTimes += o.addOriginToFileTimes; + addFileToOriginTimes += o.addFileToOriginTimes; + addFileToRegisterTimes += o.addFileToRegisterTimes; + + originExists += o.originExists; + originCreate += o.originCreate; + originsNeededEnabled += o.originsNeededEnabled; + + subdirExists += o.subdirExists; + subdirCreate += o.subdirCreate; + + fileExists += o.fileExists; + fileCreate += o.fileCreate; + filesInsertedInRegister += o.filesInsertedInRegister; + filesAssignedInRegister += o.filesAssignedInRegister; + + return *this; +} + +std::string DirectoryStats::csvHeader() +{ + QStringList const sl = {"dirTimes", + "fileTimes", + "sortTimes", + "subdirLookupTimes", + "addDirectoryTimes", + "filesLookupTimes", + "addFileTimes", + "addOriginToFileTimes", + "addFileToOriginTimes", + "addFileToRegisterTimes", + "originExists", + "originCreate", + "originsNeededEnabled", + "subdirExists", + "subdirCreate", + "fileExists", + "fileCreate", + "filesInsertedInRegister", + "filesAssignedInRegister"}; + + return sl.join(",").toStdString(); +} + +std::string DirectoryStats::toCsv() const +{ + QStringList oss; + + auto s = [](auto ns) { + return ns.count() / 1000.0 / 1000.0 / 1000.0; + }; + + oss << QString::number(s(dirTimes)) << QString::number(s(fileTimes)) + << QString::number(s(sortTimes)) + + << QString::number(s(subdirLookupTimes)) << QString::number(s(addDirectoryTimes)) + + << QString::number(s(filesLookupTimes)) << QString::number(s(addFileTimes)) + << QString::number(s(addOriginToFileTimes)) + << QString::number(s(addFileToOriginTimes)) + << QString::number(s(addFileToRegisterTimes)) + + << QString::number(originExists) << QString::number(originCreate) + << QString::number(originsNeededEnabled) + + << QString::number(subdirExists) << QString::number(subdirCreate) + + << QString::number(fileExists) << QString::number(fileCreate) + << QString::number(filesInsertedInRegister) + << QString::number(filesAssignedInRegister); + + return oss.join(",").toStdString(); +} + +void dumpStats(std::vector& stats) +{ + static int run = 0; + static const std::string file("c:\\tmp\\data.csv"); + + if (run == 0) { + std::ofstream out(file, std::ios::out | std::ios::trunc); + out << std::format("what,run,{}", DirectoryStats::csvHeader()) << "\n"; + } + + std::sort(stats.begin(), stats.end(), [](auto&& a, auto&& b) { + return (naturalCompare(QString::fromStdString(a.mod), + QString::fromStdString(b.mod)) < 0); + }); + + std::ofstream out(file, std::ios::app); + + DirectoryStats total; + for (const auto& s : stats) { + out << std::format("{},{},{}", s.mod, run, s.toCsv()) << "\n"; + total += s; + } + + out << std::format("total,{},{}", run, total.toCsv()) << "\n"; + + ++run; +} + +DirectoryRefresher::DirectoryRefresher(OrganizerCore* core, std::size_t threadCount) + : m_Core(*core), m_threadCount(threadCount) +{} + +DirectoryEntry* DirectoryRefresher::stealDirectoryStructure() +{ + QMutexLocker const locker(&m_RefreshLock); + return m_Root.release(); +} + +void DirectoryRefresher::setMods( + const std::vector>& mods, + const std::set& managedArchives) +{ + QMutexLocker const locker(&m_RefreshLock); + + m_Mods.clear(); + for (auto mod = mods.begin(); mod != mods.end(); ++mod) { + QString const name = std::get<0>(*mod); + ModInfo::Ptr const info = ModInfo::getByIndex(ModInfo::getIndex(name)); + QString path = std::get<1>(*mod); + QString const modDataDir = m_Core.managedGame()->modDataDirectory(); + path = modDataDir.isEmpty() ? path : path + "/" + modDataDir; + m_Mods.emplace_back(name, path, info->stealFiles(), info->archives(), std::get<2>(*mod)); + } + + m_EnabledArchives = managedArchives; +} + +void DirectoryRefresher::cleanStructure(DirectoryEntry* structure) +{ + static const wchar_t* files[] = {L"meta.ini", L"readme.txt"}; + for (int i = 0; i < sizeof(files) / sizeof(wchar_t*); ++i) { + structure->removeFile(files[i]); + } + + static const wchar_t* dirs[] = {L"fomod"}; + for (int i = 0; i < sizeof(dirs) / sizeof(wchar_t*); ++i) { + structure->removeDir(std::wstring(dirs[i])); + } +} + +void DirectoryRefresher::addModBSAToStructure(DirectoryEntry* root, + const QString& modName, int priority, + const QString& directory, + const QStringList& archives) +{ + QStringList loadOrder; + + auto gamePlugins = m_Core.gameFeatures().gameFeature(); + if (gamePlugins) { + loadOrder = gamePlugins->getLoadOrder(); + } + + std::vector lo; + for (auto&& s : loadOrder) { + lo.push_back(s.toStdWString()); + } + + std::vector archivesW; + for (auto&& a : archives) { + archivesW.push_back(a.toStdWString()); + } + + std::set enabledArchives; + for (auto&& a : m_EnabledArchives) { + enabledArchives.insert(a.toStdWString()); + } + + DirectoryStats dummy; + + root->addFromAllBSAs(modName.toStdWString(), + QDir::toNativeSeparators(directory).toStdWString(), priority, + archivesW, enabledArchives, lo, dummy); +} + +void DirectoryRefresher::stealModFilesIntoStructure(DirectoryEntry* directoryStructure, + const QString& modName, + int priority, + const QString& directory, + const QStringList& stealFiles) +{ + std::wstring const directoryW = ToWString(QDir::toNativeSeparators(directory)); + + // instead of adding all the files of the target directory, we just change the root of + // the specified files to this mod + DirectoryStats dummy; + FilesOrigin& origin = + directoryStructure->createOrigin(ToWString(modName), directoryW, priority, dummy); + + for (const QString& filename : stealFiles) { + if (filename.isEmpty()) { + log::warn("Trying to find file with no name"); + log::warn(" . modName: {}", modName); + log::warn(" . directory: {}", directory); + log::warn(" . priority: {}", priority); + for (int i = 0; i < stealFiles.length(); ++i) + log::warn(" . stealFiles[{}]: {}", i, stealFiles[i]); + continue; + } + QFileInfo const fileInfo(filename); + FileEntryPtr const file = directoryStructure->findFile(ToWString(fileInfo.fileName())); + if (file.get() != nullptr) { + if (file->getOrigin() == 0) { + // replace data as the origin on this bsa + file->removeOrigin(0); + } + origin.addFile(file->getIndex()); + file->addOrigin(origin.getID(), file->getFileTime(), L"", -1); + } else { + QString warnStr = fileInfo.absolutePath(); + if (warnStr.isEmpty()) + warnStr = filename; + log::warn("file not found: {}", warnStr); + } + } +} + +void DirectoryRefresher::addModFilesToStructure(DirectoryEntry* directoryStructure, + const QString& modName, int priority, + const QString& directory, + const QStringList& stealFiles) +{ + TimeThis const tt("DirectoryRefresher::addModFilesToStructure()"); + + std::wstring const directoryW = ToWString(QDir::toNativeSeparators(directory)); + DirectoryStats dummy; + + if (!stealFiles.empty()) { + stealModFilesIntoStructure(directoryStructure, modName, priority, directory, + stealFiles); + } else { + directoryStructure->addFromOrigin(ToWString(modName), directoryW, priority, dummy); + } +} + +void DirectoryRefresher::addModToStructure(DirectoryEntry* directoryStructure, + const QString& modName, int priority, + const QString& directory, + const QStringList& stealFiles, + const QStringList& archives) +{ + TimeThis const tt("DirectoryRefresher::addModToStructure()"); + + DirectoryStats dummy; + + if (!stealFiles.empty()) { + stealModFilesIntoStructure(directoryStructure, modName, priority, directory, + stealFiles); + } else { + std::wstring const directoryW = ToWString(QDir::toNativeSeparators(directory)); + directoryStructure->addFromOrigin(ToWString(modName), directoryW, priority, dummy); + } + + if (Settings::instance().archiveParsing()) { + addModBSAToStructure(directoryStructure, modName, priority, directory, archives); + } +} + +struct ModThread +{ + GameFeatures* gameFeatures; + DirectoryRefreshProgress* progress = nullptr; + DirectoryEntry* ds = nullptr; + std::wstring modName; + std::wstring path; + int prio = -1; + std::vector archives; + std::set enabledArchives; + std::vector* loadOrder = nullptr; + DirectoryStats* stats = nullptr; + env::DirectoryWalker walker; + + std::condition_variable cv; + std::mutex mutex; + bool ready = false; + + void wakeup() + { + { + std::scoped_lock const lock(mutex); + ready = true; + } + + cv.notify_one(); + } + + void run() + { + std::unique_lock lock(mutex); + cv.wait(lock, [&] { + return ready; + }); + + SetThisThreadName(QString::fromStdWString(modName + L" refresher")); + ds->addFromOrigin(walker, modName, path, prio, *stats); + + if (Settings::instance().archiveParsing()) { + ds->addFromAllBSAs(modName, path, prio, archives, enabledArchives, *loadOrder, + *stats); + } + + if (progress) { + progress->addDone(); + } + + SetThisThreadName(QString::fromStdWString(L"idle refresher")); + ready = false; + } +}; + +env::ThreadPool g_threads; + +void DirectoryRefresher::updateProgress(const DirectoryRefreshProgress* p) +{ + // careful: called from multiple threads + emit progress(p); +} + +void DirectoryRefresher::addMultipleModsFilesToStructure( + MOShared::DirectoryEntry* directoryStructure, const std::vector& entries, + DirectoryRefreshProgress* progress) +{ + std::vector stats(entries.size()); + + if (progress) { + progress->start(entries.size()); + } + + log::debug("refresher: using {} threads", m_threadCount); + g_threads.setMax(m_threadCount); + + std::vector loadOrder; + if (Settings::instance().archiveParsing()) { + auto gamePlugins = m_Core.gameFeatures().gameFeature(); + if (gamePlugins) { + QStringList const lo = gamePlugins->getLoadOrder(); + loadOrder.reserve(lo.size()); + for (auto&& s : lo) { + loadOrder.push_back(s.toStdWString()); + } + } + } + + for (std::size_t i = 0; i < entries.size(); ++i) { + const auto& e = entries[i]; + const int prio = e.priority + 1; + + if constexpr (DirectoryStats::EnableInstrumentation) { + stats[i].mod = entries[i].modName.toStdString(); + } + + try { + if (!e.stealFiles.empty()) { + stealModFilesIntoStructure(directoryStructure, e.modName, prio, e.absolutePath, + e.stealFiles); + + if (progress) { + progress->addDone(); + } + } else { + auto& mt = g_threads.request(); + + mt.gameFeatures = &m_Core.gameFeatures(); + mt.progress = progress; + mt.ds = directoryStructure; + mt.modName = e.modName.toStdWString(); + mt.path = QDir::toNativeSeparators(e.absolutePath).toStdWString(); + mt.prio = prio; + + mt.archives.clear(); + for (auto&& a : e.archives) { + mt.archives.push_back(a.toStdWString()); + } + + mt.enabledArchives.clear(); + for (auto&& a : m_EnabledArchives) { + mt.enabledArchives.insert(a.toStdWString()); + } + + mt.loadOrder = &loadOrder; + mt.stats = &stats[i]; + + mt.wakeup(); + } + } catch (const std::exception& ex) { + emit error(tr("failed to read mod (%1): %2").arg(e.modName, ex.what())); + } + } + + g_threads.waitForAll(); + + if constexpr (DirectoryStats::EnableInstrumentation) { + dumpStats(stats); + } +} + +void DirectoryRefresher::refresh() +{ + SetThisThreadName("DirectoryRefresher"); + TimeThis const tt("DirectoryRefresher::refresh()"); + auto* p = new DirectoryRefreshProgress(this); + + { + QMutexLocker const locker(&m_RefreshLock); + + m_Root.reset(new DirectoryEntry(L"data", nullptr, 0)); + + IPluginGame* game = qApp->property("managed_game").value(); + + const QString dataPath = game->dataDirectory().absolutePath(); + std::wstring const dataDirectory = + QDir::toNativeSeparators(dataPath).toStdWString(); + + log::debug("refresher: game data directory = '{}'", dataPath); + log::debug("refresher: data directory exists = {}", + QDir(dataPath).exists() ? "yes" : "no"); + + { + DirectoryStats dummy; + m_Root->addFromOrigin(L"data", dataDirectory, 0, dummy); + } + + for (const auto& directory : game->secondaryDataDirectories().toStdMap()) { + DirectoryStats dummy; + m_Root->addFromOrigin( + directory.first.toStdWString(), + QDir::toNativeSeparators(directory.second.absolutePath()).toStdWString(), 0, + dummy); + } + + std::sort(m_Mods.begin(), m_Mods.end(), [](auto lhs, auto rhs) { + return lhs.priority < rhs.priority; + }); + + addMultipleModsFilesToStructure(m_Root.get(), m_Mods, p); + + m_Root->getFileRegister()->sortOrigins(); + + cleanStructure(m_Root.get()); + + m_lastFileCount = m_Root->getFileRegister()->highestCount(); + log::debug("refresher saw {} files", m_lastFileCount); + } + + p->finish(); + + emit progress(p); + emit refreshed(); +} diff --git a/src/src/dlls.manifest.debug.qt6 b/src/src/dlls.manifest.debug.qt6 deleted file mode 100644 index cd0842c..0000000 --- a/src/src/dlls.manifest.debug.qt6 +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/src/dlls.manifest.qt6 b/src/src/dlls.manifest.qt6 deleted file mode 100644 index a5bd11a..0000000 --- a/src/src/dlls.manifest.qt6 +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/src/downloadlist.cpp b/src/src/downloadlist.cpp index 3697839..1804635 100644 --- a/src/src/downloadlist.cpp +++ b/src/src/downloadlist.cpp @@ -1,344 +1,347 @@ -/* -Copyright (C) 2012 Sebastian Herbord. All rights reserved. - -This file is part of Mod Organizer. - -Mod Organizer is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -Mod Organizer is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with Mod Organizer. If not, see . -*/ - -#include "downloadlist.h" -#include "downloadmanager.h" -#include "modlistdropinfo.h" -#include "organizercore.h" -#include "settings.h" -#include -#include -#include -#include -#include -#include - -using namespace MOBase; - -DownloadList::DownloadList(OrganizerCore& core, QObject* parent) - : QAbstractTableModel(parent), m_manager(*core.downloadManager()), - m_settings(core.settings()) -{ - connect(&m_manager, SIGNAL(update(int)), this, SLOT(update(int))); - connect(&m_manager, SIGNAL(aboutToUpdate()), this, SLOT(aboutToUpdate())); - connect(&m_manager, SIGNAL(stateChanged(int,DownloadManager::DownloadState)), this, SLOT(rowChanged(int))); -} - -int DownloadList::rowCount(const QModelIndex& parent) const -{ - if (!parent.isValid()) { - // root item - return m_manager.numTotalDownloads() + m_manager.numPendingDownloads(); - } else { - return 0; - } -} - -int DownloadList::columnCount(const QModelIndex&) const -{ - return COL_COUNT; -} - -QModelIndex DownloadList::index(int row, int column, const QModelIndex&) const -{ - return createIndex(row, column, row); -} - -QModelIndex DownloadList::parent(const QModelIndex&) const -{ - return {}; -} - -QVariant DownloadList::headerData(int section, Qt::Orientation orientation, - int role) const -{ - if ((role == Qt::DisplayRole) && (orientation == Qt::Horizontal)) { - switch (section) { - case COL_NAME: - return tr("Name"); - case COL_MODNAME: - return tr("Mod name"); - case COL_VERSION: - return tr("Version"); - case COL_ID: - return tr("Nexus ID"); - case COL_SIZE: - return tr("Size"); - case COL_STATUS: - return tr("Status"); - case COL_FILETIME: - return tr("Filetime"); - case COL_SOURCEGAME: - return tr("Source Game"); - default: - return {}; - } - } else { - return QAbstractItemModel::headerData(section, orientation, role); - } -} - -Qt::ItemFlags DownloadList::flags(const QModelIndex& idx) const -{ - return QAbstractTableModel::flags(idx) | Qt::ItemIsDragEnabled; -} - -QMimeData* DownloadList::mimeData(const QModelIndexList& indexes) const -{ - QMimeData* result = QAbstractItemModel::mimeData(indexes); - result->setData("text/plain", ModListDropInfo::DownloadText); - return result; -} - -QVariant DownloadList::data(const QModelIndex& index, int role) const -{ - if (!index.isValid() || index.row() < 0 || index.row() >= rowCount()) - return {}; - - bool const pendingDownload = index.row() >= m_manager.numTotalDownloads(); - if (role == Qt::DisplayRole) { - if (pendingDownload) { - std::tuple nexusids = - m_manager.getPendingDownload(index.row() - m_manager.numTotalDownloads()); - switch (index.column()) { - case COL_NAME: - return tr("< game %1 mod %2 file %3 >") - .arg(std::get<0>(nexusids)) - .arg(std::get<1>(nexusids)) - .arg(std::get<2>(nexusids)); - case COL_SIZE: - return tr("Unknown"); - case COL_STATUS: - return tr("Pending"); - } - } else { - switch (index.column()) { - case COL_NAME: - return m_settings.interface().metaDownloads() - ? m_manager.getDisplayName(index.row()) - : m_manager.getFileName(index.row()); - case COL_MODNAME: { - if (m_manager.isInfoIncomplete(index.row())) { - return {}; - } else { - const MOBase::ModRepositoryFileInfo* info = - m_manager.getFileInfo(index.row()); - return info->modName; - } - } - case COL_VERSION: { - if (m_manager.isInfoIncomplete(index.row())) { - return {}; - } else { - const MOBase::ModRepositoryFileInfo* info = - m_manager.getFileInfo(index.row()); - return info->version.canonicalString(); - } - } - case COL_ID: { - if (m_manager.isInfoIncomplete(index.row())) { - return {}; - } else { - return QString("%1").arg(m_manager.getModID(index.row())); - } - } - case COL_SOURCEGAME: { - if (m_manager.isInfoIncomplete(index.row())) { - return {}; - } else { - return QString("%1").arg(m_manager.getDisplayGameName(index.row())); - } - } - case COL_SIZE: - return MOBase::localizedByteSize(m_manager.getFileSize(index.row())); - case COL_FILETIME: - return m_manager.getFileTime(index.row()); - case COL_STATUS: - switch (m_manager.getState(index.row())) { - // STATE_DOWNLOADING handled by DownloadProgressDelegate - case DownloadManager::STATE_STARTED: - return tr("Started"); - case DownloadManager::STATE_CANCELING: - return tr("Canceling"); - case DownloadManager::STATE_PAUSING: - return tr("Pausing"); - case DownloadManager::STATE_CANCELED: - return tr("Canceled"); - case DownloadManager::STATE_PAUSED: - return tr("Paused"); - case DownloadManager::STATE_ERROR: - return tr("Error"); - case DownloadManager::STATE_FETCHINGMODINFO: - return tr("Fetching Info"); - case DownloadManager::STATE_FETCHINGFILEINFO: - return tr("Fetching Info"); - case DownloadManager::STATE_FETCHINGMODINFO_MD5: - return tr("Fetching Info"); - case DownloadManager::STATE_READY: - return tr("Downloaded"); - case DownloadManager::STATE_INSTALLED: - return tr("Installed"); - case DownloadManager::STATE_UNINSTALLED: - return tr("Uninstalled"); - } - } - } - } else if (role == Qt::ForegroundRole && index.column() == COL_STATUS) { - if (pendingDownload) { - return QColor(Qt::darkBlue); - } else { - DownloadManager::DownloadState const state = m_manager.getState(index.row()); - if (state == DownloadManager::STATE_READY) - return QColor(Qt::darkGreen); - else if (state == DownloadManager::STATE_UNINSTALLED) - return QColor(Qt::darkYellow); - else if (state == DownloadManager::STATE_PAUSED) - return QColor(Qt::darkRed); - } - } else if (role == Qt::ToolTipRole) { - if (pendingDownload) { - return tr("Pending download"); - } else { - QString text = m_manager.getFileName(index.row()) + "\n"; - if (m_manager.isInfoIncomplete(index.row())) { - text += tr("Information missing, please select \"Query Info\" from the context " - "menu to re-retrieve."); - } else { - const MOBase::ModRepositoryFileInfo* info = m_manager.getFileInfo(index.row()); - return QString("%1 (ID %2) %3
%4") - .arg(info->modName) - .arg(m_manager.getModID(index.row())) - .arg(info->version.canonicalString()) - .arg(info->description.mid(0, 4096)); - } - return text; - } - } else if (role == Qt::DecorationRole && index.column() == COL_NAME) { - if (!pendingDownload && - m_manager.getState(index.row()) >= DownloadManager::STATE_READY && - m_manager.isInfoIncomplete(index.row())) - return QIcon(":/MO/gui/warning_16"); - } else if (role == Qt::TextAlignmentRole) { - if (index.column() == COL_SIZE) - return {Qt::AlignVCenter | Qt::AlignRight}; - else - return {Qt::AlignVCenter | Qt::AlignLeft}; - } - return {}; -} - -void DownloadList::aboutToUpdate() -{ - emit beginResetModel(); -} - -void DownloadList::update(int row) -{ - if (row < 0) - emit endResetModel(); - else if (row < this->rowCount()) - emit dataChanged( - this->index(row, 0, QModelIndex()), - this->index(row, this->columnCount(QModelIndex()) - 1, QModelIndex())); - else - log::error("invalid row {} in download list, update failed", row); -} - -void DownloadList::rowChanged(int row) -{ - if (row >= 0 && row < rowCount()) { - emit dataChanged(index(row, 0, QModelIndex()), - index(row, columnCount(QModelIndex()) - 1, QModelIndex())); - } -} - -bool DownloadList::lessThanPredicate(const QModelIndex& left, const QModelIndex& right) -{ - int const leftIndex = left.row(); - int const rightIndex = right.row(); - if ((leftIndex < m_manager.numTotalDownloads()) && - (rightIndex < m_manager.numTotalDownloads())) { - if (left.column() == DownloadList::COL_NAME) { - return left.data(Qt::DisplayRole) - .toString() - .compare(right.data(Qt::DisplayRole).toString(), Qt::CaseInsensitive) < - 0; - } else if (left.column() == DownloadList::COL_MODNAME) { - QString leftName, rightName; - - if (!m_manager.isInfoIncomplete(left.row())) { - const MOBase::ModRepositoryFileInfo* info = m_manager.getFileInfo(left.row()); - leftName = info->modName; - } - - if (!m_manager.isInfoIncomplete(right.row())) { - const MOBase::ModRepositoryFileInfo* info = m_manager.getFileInfo(right.row()); - rightName = info->modName; - } - - return leftName.compare(rightName, Qt::CaseInsensitive) < 0; - } else if (left.column() == DownloadList::COL_VERSION) { - MOBase::VersionInfo versionLeft, versionRight; - - if (!m_manager.isInfoIncomplete(left.row())) { - const MOBase::ModRepositoryFileInfo* info = m_manager.getFileInfo(left.row()); - versionLeft = info->version; - } - - if (!m_manager.isInfoIncomplete(right.row())) { - const MOBase::ModRepositoryFileInfo* info = m_manager.getFileInfo(right.row()); - versionRight = info->version; - } - - return versionLeft < versionRight; - } else if (left.column() == DownloadList::COL_ID) { - int leftID = 0, rightID = 0; - - if (!m_manager.isInfoIncomplete(left.row())) { - const MOBase::ModRepositoryFileInfo* info = m_manager.getFileInfo(left.row()); - leftID = info->modID; - } - - if (!m_manager.isInfoIncomplete(right.row())) { - const MOBase::ModRepositoryFileInfo* info = m_manager.getFileInfo(right.row()); - rightID = info->modID; - } - - return leftID < rightID; - } else if (left.column() == DownloadList::COL_STATUS) { - DownloadManager::DownloadState const leftState = m_manager.getState(left.row()); - DownloadManager::DownloadState const rightState = m_manager.getState(right.row()); - if (leftState == rightState) - return m_manager.getFileTime(left.row()) > m_manager.getFileTime(right.row()); - else - return leftState < rightState; - } else if (left.column() == DownloadList::COL_SIZE) { - return m_manager.getFileSize(left.row()) < m_manager.getFileSize(right.row()); - } else if (left.column() == DownloadList::COL_FILETIME) { - return m_manager.getFileTime(left.row()) < m_manager.getFileTime(right.row()); - } else if (left.column() == DownloadList::COL_SOURCEGAME) { - return m_manager.getDisplayGameName(left.row()) < - m_manager.getDisplayGameName(right.row()); - } else { - return leftIndex < rightIndex; - } - } else { - return leftIndex < rightIndex; - } -} +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "downloadlist.h" +#include "downloadmanager.h" +#include "modlistdropinfo.h" +#include "organizercore.h" +#include "settings.h" +#include +#include +#include +#include +#include +#include + +using namespace MOBase; + +DownloadList::DownloadList(OrganizerCore& core, QObject* parent) + : QAbstractTableModel(parent), m_manager(*core.downloadManager()), + m_settings(core.settings()) +{ + connect(&m_manager, SIGNAL(update(int)), this, SLOT(update(int))); + connect(&m_manager, SIGNAL(aboutToUpdate()), this, SLOT(aboutToUpdate())); + connect(&m_manager, SIGNAL(stateChanged(int,DownloadManager::DownloadState)), this, SLOT(rowChanged(int))); +} + +int DownloadList::rowCount(const QModelIndex& parent) const +{ + if (!parent.isValid()) { + // root item + return m_manager.numTotalDownloads() + m_manager.numPendingDownloads(); + } else { + return 0; + } +} + +int DownloadList::columnCount(const QModelIndex&) const +{ + return COL_COUNT; +} + +QModelIndex DownloadList::index(int row, int column, const QModelIndex&) const +{ + return createIndex(row, column, row); +} + +QModelIndex DownloadList::parent(const QModelIndex&) const +{ + return {}; +} + +QVariant DownloadList::headerData(int section, Qt::Orientation orientation, + int role) const +{ + if ((role == Qt::DisplayRole) && (orientation == Qt::Horizontal)) { + switch (section) { + case COL_NAME: + return tr("Name"); + case COL_MODNAME: + return tr("Mod name"); + case COL_VERSION: + return tr("Version"); + case COL_ID: + return tr("Nexus ID"); + case COL_SIZE: + return tr("Size"); + case COL_STATUS: + return tr("Status"); + case COL_FILETIME: + return tr("Filetime"); + case COL_SOURCEGAME: + return tr("Source Game"); + default: + return {}; + } + } else { + return QAbstractItemModel::headerData(section, orientation, role); + } +} + +Qt::ItemFlags DownloadList::flags(const QModelIndex& idx) const +{ + return QAbstractTableModel::flags(idx) | Qt::ItemIsDragEnabled; +} + +QMimeData* DownloadList::mimeData(const QModelIndexList& indexes) const +{ + QMimeData* result = QAbstractItemModel::mimeData(indexes); + result->setData("text/plain", ModListDropInfo::DownloadText); + return result; +} + +QVariant DownloadList::data(const QModelIndex& index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= rowCount()) + return {}; + + bool const pendingDownload = index.row() >= m_manager.numTotalDownloads(); + if (role == Qt::DisplayRole) { + if (pendingDownload) { + std::tuple nexusids = + m_manager.getPendingDownload(index.row() - m_manager.numTotalDownloads()); + switch (index.column()) { + case COL_NAME: + return tr("< game %1 mod %2 file %3 >") + .arg(std::get<0>(nexusids)) + .arg(std::get<1>(nexusids)) + .arg(std::get<2>(nexusids)); + case COL_SIZE: + return tr("Unknown"); + case COL_STATUS: + return tr("Pending"); + } + } else { + switch (index.column()) { + case COL_NAME: + return m_settings.interface().metaDownloads() + ? m_manager.getDisplayName(index.row()) + : m_manager.getFileName(index.row()); + case COL_MODNAME: { + if (m_manager.isInfoIncomplete(index.row())) { + return {}; + } else { + const MOBase::ModRepositoryFileInfo* info = + m_manager.getFileInfo(index.row()); + return info->modName; + } + } + case COL_VERSION: { + if (m_manager.isInfoIncomplete(index.row())) { + return {}; + } else { + const MOBase::ModRepositoryFileInfo* info = + m_manager.getFileInfo(index.row()); + return info->version.canonicalString(); + } + } + case COL_ID: { + if (m_manager.isInfoIncomplete(index.row())) { + return {}; + } else { + return QString("%1").arg(m_manager.getModID(index.row())); + } + } + case COL_SOURCEGAME: { + if (m_manager.isInfoIncomplete(index.row())) { + return {}; + } else { + return QString("%1").arg(m_manager.getDisplayGameName(index.row())); + } + } + case COL_SIZE: + return MOBase::localizedByteSize(m_manager.getFileSize(index.row())); + case COL_FILETIME: + return m_manager.getFileTime(index.row()); + case COL_STATUS: + switch (m_manager.getState(index.row())) { + // STATE_DOWNLOADING handled by DownloadProgressDelegate + case DownloadManager::STATE_STARTED: + return tr("Started"); + case DownloadManager::STATE_CANCELING: + return tr("Canceling"); + case DownloadManager::STATE_PAUSING: + return tr("Pausing"); + case DownloadManager::STATE_CANCELED: + return tr("Canceled"); + case DownloadManager::STATE_PAUSED: + return tr("Paused"); + case DownloadManager::STATE_ERROR: + return tr("Error"); + case DownloadManager::STATE_FETCHINGMODINFO: + return tr("Fetching Info"); + case DownloadManager::STATE_FETCHINGFILEINFO: + return tr("Fetching Info"); + case DownloadManager::STATE_FETCHINGMODINFO_MD5: + return tr("Fetching Info"); + case DownloadManager::STATE_READY: + return tr("Downloaded"); + case DownloadManager::STATE_INSTALLED: + return tr("Installed"); + case DownloadManager::STATE_UNINSTALLED: + return tr("Uninstalled"); + } + } + } + } else if (role == Qt::ForegroundRole && index.column() == COL_STATUS) { + if (pendingDownload) { + return QColor(Qt::darkBlue); + } else { + DownloadManager::DownloadState const state = m_manager.getState(index.row()); + if (state == DownloadManager::STATE_READY) + return QColor(Qt::darkGreen); + else if (state == DownloadManager::STATE_UNINSTALLED) + return QColor(Qt::darkYellow); + else if (state == DownloadManager::STATE_PAUSED) + return QColor(Qt::darkRed); + } + } else if (role == Qt::ToolTipRole) { + if (pendingDownload) { + return tr("Pending download"); + } else { + QString text = m_manager.getFileName(index.row()) + "\n"; + if (m_manager.isInfoIncomplete(index.row())) { + text += tr("Information missing, please select \"Query Info\" from the context " + "menu to re-retrieve."); + } else { + const MOBase::ModRepositoryFileInfo* info = m_manager.getFileInfo(index.row()); + return QString("%1 (ID %2) %3
%4") + .arg(info->modName) + .arg(m_manager.getModID(index.row())) + .arg(info->version.canonicalString()) + .arg(info->description.mid(0, 4096)); + } + return text; + } + } else if (role == Qt::DecorationRole && index.column() == COL_NAME) { + if (!pendingDownload && + m_manager.getState(index.row()) >= DownloadManager::STATE_READY && + m_manager.isInfoIncomplete(index.row())) + return QIcon(":/MO/gui/warning_16"); + } else if (role == Qt::TextAlignmentRole) { + if (index.column() == COL_SIZE) + return {Qt::AlignVCenter | Qt::AlignRight}; + else + return {Qt::AlignVCenter | Qt::AlignLeft}; + } + return {}; +} + +void DownloadList::aboutToUpdate() +{ + emit beginResetModel(); +} + +void DownloadList::update(int row) +{ + if (row < 0) + emit endResetModel(); + else if (row < this->rowCount()) + emit dataChanged( + this->index(row, 0, QModelIndex()), + this->index(row, this->columnCount(QModelIndex()) - 1, QModelIndex())); + else + log::error("invalid row {} in download list, update failed", row); +} + +void DownloadList::rowChanged(int row) +{ + if (row >= 0 && row < rowCount()) { + emit dataChanged(index(row, 0, QModelIndex()), + index(row, columnCount(QModelIndex()) - 1, QModelIndex())); + } +} + +bool DownloadList::lessThanPredicate(const QModelIndex& left, const QModelIndex& right) +{ + int const leftIndex = left.row(); + int const rightIndex = right.row(); + if ((leftIndex < m_manager.numTotalDownloads()) && + (rightIndex < m_manager.numTotalDownloads())) { + if (left.column() == DownloadList::COL_NAME) { + return left.data(Qt::DisplayRole) + .toString() + .compare(right.data(Qt::DisplayRole).toString(), Qt::CaseInsensitive) < + 0; + } else if (left.column() == DownloadList::COL_MODNAME) { + QString leftName; + QString rightName; + + if (!m_manager.isInfoIncomplete(left.row())) { + const MOBase::ModRepositoryFileInfo* info = m_manager.getFileInfo(left.row()); + leftName = info->modName; + } + + if (!m_manager.isInfoIncomplete(right.row())) { + const MOBase::ModRepositoryFileInfo* info = m_manager.getFileInfo(right.row()); + rightName = info->modName; + } + + return leftName.compare(rightName, Qt::CaseInsensitive) < 0; + } else if (left.column() == DownloadList::COL_VERSION) { + MOBase::VersionInfo versionLeft; + MOBase::VersionInfo versionRight; + + if (!m_manager.isInfoIncomplete(left.row())) { + const MOBase::ModRepositoryFileInfo* info = m_manager.getFileInfo(left.row()); + versionLeft = info->version; + } + + if (!m_manager.isInfoIncomplete(right.row())) { + const MOBase::ModRepositoryFileInfo* info = m_manager.getFileInfo(right.row()); + versionRight = info->version; + } + + return versionLeft < versionRight; + } else if (left.column() == DownloadList::COL_ID) { + int leftID = 0; + int rightID = 0; + + if (!m_manager.isInfoIncomplete(left.row())) { + const MOBase::ModRepositoryFileInfo* info = m_manager.getFileInfo(left.row()); + leftID = info->modID; + } + + if (!m_manager.isInfoIncomplete(right.row())) { + const MOBase::ModRepositoryFileInfo* info = m_manager.getFileInfo(right.row()); + rightID = info->modID; + } + + return leftID < rightID; + } else if (left.column() == DownloadList::COL_STATUS) { + DownloadManager::DownloadState const leftState = m_manager.getState(left.row()); + DownloadManager::DownloadState const rightState = m_manager.getState(right.row()); + if (leftState == rightState) + return m_manager.getFileTime(left.row()) > m_manager.getFileTime(right.row()); + else + return leftState < rightState; + } else if (left.column() == DownloadList::COL_SIZE) { + return m_manager.getFileSize(left.row()) < m_manager.getFileSize(right.row()); + } else if (left.column() == DownloadList::COL_FILETIME) { + return m_manager.getFileTime(left.row()) < m_manager.getFileTime(right.row()); + } else if (left.column() == DownloadList::COL_SOURCEGAME) { + return m_manager.getDisplayGameName(left.row()) < + m_manager.getDisplayGameName(right.row()); + } else { + return leftIndex < rightIndex; + } + } else { + return leftIndex < rightIndex; + } +} diff --git a/src/src/downloadmanager.cpp b/src/src/downloadmanager.cpp index f9e9126..0cb37bf 100644 --- a/src/src/downloadmanager.cpp +++ b/src/src/downloadmanager.cpp @@ -1,2555 +1,2556 @@ -/* -Copyright (C) 2012 Sebastian Herbord. All rights reserved. - -This file is part of Mod Organizer. - -Mod Organizer is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -Mod Organizer is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with Mod Organizer. If not, see . -*/ - -#include "downloadmanager.h" - -#include "bbcode.h" -#include "envfs.h" -#include "filesystemutilities.h" -#include "iplugingame.h" -#include "nexusinterface.h" -#include "nxmaccessmanager.h" -#include "nxmurl.h" -#include "organizercore.h" -#include "selectiondialog.h" -#include "shared/util.h" -#include "utility.h" -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include - -using namespace MOBase; - -// TODO limit number of downloads, also display download during nxm requests, store -// modid/fileid with downloads - -static const char UNFINISHED[] = ".unfinished"; - -// Heuristic: does this filename look like a CDN object key that should be -// replaced by the server's Content-Disposition filename if available? -// Nexus v2 file metadata sometimes returns `file_name` as a CDN-path UUID -// (e.g. "91/83/bb/9183bbff-...") rather than the actual archive name. -static bool looksLikeCdnObjectKey(const QString& name) -{ - if (name.isEmpty()) { - return true; - } - // Real archive filenames always end in an archive suffix. If the name - // has no recognized archive extension, assume it's a CDN key. - static const QStringList kArchiveSuffixes = { - ".zip", ".rar", ".7z", ".tar", ".gz", ".bz2", - ".xz", ".exe", ".msi", ".bsa", ".ba2"}; - for (const auto& suf : kArchiveSuffixes) { - if (name.endsWith(suf, Qt::CaseInsensitive)) { - return false; - } - } - return true; -} - -unsigned int DownloadManager::DownloadInfo::s_NextDownloadID = 1U; -int DownloadManager::m_DirWatcherDisabler = 0; - -DownloadManager::DownloadInfo* -DownloadManager::DownloadInfo::createNew(const ModRepositoryFileInfo* fileInfo, - const QStringList& URLs) -{ - DownloadInfo* info = new DownloadInfo; - info->m_DownloadID = s_NextDownloadID++; - info->m_StartTime.start(); - info->m_PreResumeSize = 0LL; - info->m_Progress = std::make_pair(0, "0.0 B/s "); - info->m_ResumePos = 0; - info->m_FileInfo = new ModRepositoryFileInfo(*fileInfo); - info->m_Urls = URLs; - info->m_CurrentUrl = 0; - info->m_Tries = AUTOMATIC_RETRIES; - info->m_State = STATE_STARTED; - info->m_TaskProgressId = TaskProgressManager::instance().getId(); - info->m_Reply = nullptr; - - return info; -} - -DownloadManager::DownloadInfo* -DownloadManager::DownloadInfo::createFromMeta(const QString& filePath, bool showHidden, - const QString outputDirectory, - std::optional fileSize) -{ - DownloadInfo* info = new DownloadInfo; - - QString const metaFileName = filePath + ".meta"; - QFileInfo const metaFileInfo(metaFileName); - if (QDir::fromNativeSeparators(metaFileInfo.path()) - .compare(QDir::fromNativeSeparators(outputDirectory), Qt::CaseInsensitive) != - 0) - return nullptr; - QSettings const metaFile(metaFileName, QSettings::IniFormat); - if (!showHidden && metaFile.value("removed", false).toBool()) { - return nullptr; - } else { - info->m_Hidden = metaFile.value("removed", false).toBool(); - } - - QString const fileName = QFileInfo(filePath).fileName(); - - if (fileName.endsWith(UNFINISHED)) { - info->m_FileName = - fileName.mid(0, fileName.length() - static_cast(strlen(UNFINISHED))); - info->m_State = STATE_PAUSED; - } else { - info->m_FileName = fileName; - - if (metaFile.value("paused", false).toBool()) { - info->m_State = STATE_PAUSED; - } else if (metaFile.value("uninstalled", false).toBool()) { - info->m_State = STATE_UNINSTALLED; - } else if (metaFile.value("installed", false).toBool()) { - info->m_State = STATE_INSTALLED; - } else { - info->m_State = STATE_READY; - } - } - - info->m_DownloadID = s_NextDownloadID++; - info->m_Output.setFileName(filePath); - info->m_TotalSize = fileSize ? *fileSize : QFileInfo(filePath).size(); - info->m_PreResumeSize = info->m_TotalSize; - info->m_CurrentUrl = 0; - info->m_Urls = metaFile.value("url", "").toString().split(";"); - info->m_Tries = 0; - info->m_TaskProgressId = TaskProgressManager::instance().getId(); - QString const gameName = metaFile.value("gameName", "").toString(); - int const modID = metaFile.value("modID", 0).toInt(); - int const fileID = metaFile.value("fileID", 0).toInt(); - info->m_FileInfo = new ModRepositoryFileInfo(gameName, modID, fileID); - info->m_FileInfo->name = metaFile.value("name", "").toString(); - if (info->m_FileInfo->name == "0") { - // bug in earlier version - info->m_FileInfo->name = ""; - } - info->m_FileInfo->modName = metaFile.value("modName", "").toString(); - info->m_FileInfo->gameName = gameName; - info->m_FileInfo->modID = modID; - info->m_FileInfo->fileID = fileID; - info->m_FileInfo->description = metaFile.value("description").toString(); - info->m_FileInfo->version.parse(metaFile.value("version", "0").toString()); - info->m_FileInfo->newestVersion.parse( - metaFile.value("newestVersion", "0").toString()); - info->m_FileInfo->categoryID = metaFile.value("category", 0).toInt(); - info->m_FileInfo->fileCategory = metaFile.value("fileCategory", 0).toInt(); - info->m_FileInfo->repository = metaFile.value("repository", "Nexus").toString(); - info->m_FileInfo->userData = metaFile.value("userData").toMap(); - info->m_FileInfo->author = metaFile.value("author", "").toString(); - info->m_FileInfo->uploader = metaFile.value("uploader", "").toString(); - info->m_FileInfo->uploaderUrl = metaFile.value("uploaderUrl", "").toString(); - info->m_Reply = nullptr; - - return info; -} - -ScopedDisableDirWatcher::ScopedDisableDirWatcher(DownloadManager* downloadManager) -{ - m_downloadManager = downloadManager; - m_downloadManager->startDisableDirWatcher(); - log::debug("Scoped Disable DirWatcher: Started"); -} - -ScopedDisableDirWatcher::~ScopedDisableDirWatcher() -{ - m_downloadManager->endDisableDirWatcher(); - m_downloadManager = nullptr; - log::debug("Scoped Disable DirWatcher: Stopped"); -} - -void DownloadManager::startDisableDirWatcher() -{ - DownloadManager::m_DirWatcherDisabler++; -} - -void DownloadManager::endDisableDirWatcher() -{ - if (DownloadManager::m_DirWatcherDisabler > 0) { - if (DownloadManager::m_DirWatcherDisabler == 1) - QCoreApplication::processEvents(); - DownloadManager::m_DirWatcherDisabler--; - } else { - DownloadManager::m_DirWatcherDisabler = 0; - } -} - -void DownloadManager::DownloadInfo::setName(QString newName, bool renameFile) -{ - QString oldMetaFileName = QString("%1.meta").arg(m_FileName); - m_FileName = QFileInfo(newName).fileName(); - if ((m_State == DownloadManager::STATE_STARTED) || - (m_State == DownloadManager::STATE_DOWNLOADING) || - (m_State == DownloadManager::STATE_PAUSED)) { - newName.append(UNFINISHED); - oldMetaFileName = QString("%1%2.meta").arg(m_FileName).arg(UNFINISHED); - } - if (renameFile) { - if ((newName != m_Output.fileName()) && !m_Output.rename(newName)) { - reportError(tr(R"(failed to rename "%1" to "%2")") - .arg(m_Output.fileName()) - .arg(newName)); - return; - } - - QFile metaFile(QFileInfo(newName).path() + "/" + oldMetaFileName); - if (metaFile.exists()) - metaFile.rename(newName.mid(0).append(".meta")); - } - if (!m_Output.isOpen()) { - // can't set file name if it's open - m_Output.setFileName(newName); - } -} - -bool DownloadManager::DownloadInfo::isPausedState() const -{ - return m_State == STATE_PAUSED || m_State == STATE_ERROR; -} - -QString DownloadManager::DownloadInfo::currentURL() -{ - return m_Urls[m_CurrentUrl]; -} - -DownloadManager::DownloadManager(NexusInterface* nexusInterface, QObject* parent) - : m_NexusInterface(nexusInterface) - -{ - m_OrganizerCore = dynamic_cast(parent); - connect(&m_DirWatcher, SIGNAL(directoryChanged(QString)), this, - SLOT(directoryChanged(QString))); - m_TimeoutTimer.setSingleShot(false); - connect(&m_TimeoutTimer, &QTimer::timeout, this, - &DownloadManager::checkDownloadTimeout); - m_TimeoutTimer.start(5 * 1000); -} - -DownloadManager::~DownloadManager() -{ - for (QVector::iterator iter = m_ActiveDownloads.begin(); - iter != m_ActiveDownloads.end(); ++iter) { - delete *iter; - } - m_ActiveDownloads.clear(); -} - -void DownloadManager::setParentWidget(QWidget* w) -{ - m_ParentWidget = w; -} - -bool DownloadManager::downloadsInProgress() -{ - for (QVector::iterator iter = m_ActiveDownloads.begin(); - iter != m_ActiveDownloads.end(); ++iter) { - if ((*iter)->m_State < STATE_READY) { - return true; - } - } - return false; -} - -bool DownloadManager::downloadsInProgressNoPause() -{ - for (QVector::iterator iter = m_ActiveDownloads.begin(); - iter != m_ActiveDownloads.end(); ++iter) { - if ((*iter)->m_State < STATE_READY && (*iter)->m_State != STATE_PAUSED) { - return true; - } - } - return false; -} - -void DownloadManager::pauseAll() -{ - - // first loop: pause all downloads - for (int i = 0; i < m_ActiveDownloads.count(); ++i) { - if (m_ActiveDownloads[i]->m_State < STATE_READY) { - pauseDownload(i); - } - } - - QThread::msleep(100); - - bool done = false; - QTime const startTime = QTime::currentTime(); - // further loops: busy waiting for all downloads to complete. This could be neater... - while (!done && (startTime.secsTo(QTime::currentTime()) < 5)) { - QCoreApplication::processEvents(); - done = true; - foreach (DownloadInfo* info, m_ActiveDownloads) { - if ((info->m_State < STATE_CANCELED) || - (info->m_State == STATE_FETCHINGFILEINFO) || - (info->m_State == STATE_FETCHINGMODINFO) || - (info->m_State == STATE_FETCHINGMODINFO_MD5)) { - done = false; - break; - } - } - if (!done) { - QThread::msleep(100); - } - } -} - -void DownloadManager::setOutputDirectory(const QString& outputDirectory, - const bool refresh) -{ - QStringList const directories = m_DirWatcher.directories(); - if (!directories.empty()) { - m_DirWatcher.removePaths(directories); - } - m_OutputDirectory = QDir::fromNativeSeparators(outputDirectory); - if (refresh) { - refreshList(); - } - m_DirWatcher.addPath(m_OutputDirectory); -} - -void DownloadManager::setShowHidden(bool showHidden) -{ - m_ShowHidden = showHidden; - refreshList(); -} - -void DownloadManager::setPluginContainer(PluginContainer* pluginContainer) -{ - m_NexusInterface->setPluginContainer(pluginContainer); -} - -void DownloadManager::refreshList() -{ - TimeThis const tt("DownloadManager::refreshList()"); - - try { - emit aboutToUpdate(); - // avoid triggering other refreshes - ScopedDisableDirWatcher const scopedDirWatcher(this); - - int const downloadsBefore = m_ActiveDownloads.size(); - - // remove finished downloads - for (QVector::iterator iter = m_ActiveDownloads.begin(); - iter != m_ActiveDownloads.end();) { - if (((*iter)->m_State == STATE_READY) || ((*iter)->m_State == STATE_INSTALLED) || - ((*iter)->m_State == STATE_UNINSTALLED)) { - delete *iter; - iter = m_ActiveDownloads.erase(iter); - } else { - ++iter; - } - } - - const QStringList supportedExtensions = - m_OrganizerCore->installationManager()->getSupportedExtensions(); - std::vector nameFilters; - for (const auto& extension : supportedExtensions) { - nameFilters.push_back(L"." + extension.toLower().toStdWString()); - } - - nameFilters.push_back(QString(UNFINISHED).toLower().toStdWString()); - - QDir const dir(QDir::fromNativeSeparators(m_OutputDirectory)); - - // find orphaned meta files and delete them (sounds cruel but it's better for - // everyone) - QStringList orphans; - QStringList const metaFiles = dir.entryList(QStringList() << "*.meta"); - foreach (const QString& metaFile, metaFiles) { - QString const baseFile = metaFile.left(metaFile.length() - 5); - if (!QFile::exists(dir.absoluteFilePath(baseFile))) { - orphans.append(dir.absoluteFilePath(metaFile)); - } - } - if (!orphans.empty()) { - log::debug("{} orphaned meta files will be deleted", orphans.size()); - shellDelete(orphans, true); - } - - std::set seen; - - struct Context - { - DownloadManager& self; - std::set& seen; - std::vector& extensions; - }; - - Context cx = {*this, seen, nameFilters}; - - for (auto&& d : m_ActiveDownloads) { - cx.seen.insert(d->m_FileName.toLower().toStdWString()); - cx.seen.insert( - QFileInfo(d->m_Output.fileName()).fileName().toLower().toStdWString()); - } - - env::forEachEntry( - QDir::toNativeSeparators(m_OutputDirectory).toStdWString(), &cx, nullptr, - nullptr, [](void* data, std::wstring_view f, FILETIME, uint64_t size) { - auto& cx = *static_cast(data); - - std::wstring lc = MOShared::ToLowerCopy(f); - - bool interestingExt = false; - for (auto&& ext : cx.extensions) { - if (lc.ends_with(ext)) { - interestingExt = true; - break; - } - } - - if (!interestingExt) { - return; - } - - if (cx.seen.contains(lc)) { - return; - } - - QString const fileName = QDir::fromNativeSeparators(cx.self.m_OutputDirectory) + - "/" + QString::fromWCharArray(f.data(), f.size()); - - DownloadInfo* info = DownloadInfo::createFromMeta( - fileName, cx.self.m_ShowHidden, cx.self.m_OutputDirectory, size); - - if (info == nullptr) { - return; - } - - cx.self.m_ActiveDownloads.push_front(info); - cx.seen.insert(std::move(lc)); - cx.seen.insert( - QFileInfo(info->m_Output.fileName()).fileName().toLower().toStdWString()); - }); - - log::debug("saw {} downloads", m_ActiveDownloads.size()); - - emit update(-1); - - } catch (const std::bad_alloc&) { - reportError(tr("Memory allocation error (in refreshing directory).")); - } -} - -void DownloadManager::queryDownloadListInfo() -{ - int incompleteCount = 0; - for (size_t i = 0; i < m_ActiveDownloads.size(); i++) { - if (isInfoIncomplete(i)) { - incompleteCount++; - } - } - - if (incompleteCount <= 5 || - QMessageBox::question( - m_ParentWidget, tr("Query Metadata"), - tr("There are %1 downloads with incomplete metadata.\n\n" - "Do you want to fetch all incomplete metadata?\n" - "API requests will be consumed, and Mod Organizer may stutter.") - .arg(incompleteCount), - QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { - TimeThis const tt("DownloadManager::queryDownloadListInfo()"); - log::info("Querying metadata for every download with incomplete info..."); - startDisableDirWatcher(); - for (size_t i = 0; i < m_ActiveDownloads.size(); i++) { - if (isInfoIncomplete(i)) { - queryInfoMd5(i, false); - } - } - endDisableDirWatcher(); - log::info("Metadata has been retrieved successfully!"); - } -} - -bool DownloadManager::addDownload(const QStringList& URLs, QString gameName, int modID, - int fileID, const ModRepositoryFileInfo* fileInfo) -{ - // Parse the URL properly instead of feeding it to QFileInfo — QFileInfo - // doesn't understand URLs, so its fileName() can pull in query-string junk - // on Linux and we end up saving downloads as UUIDs from the S3 object key. - const QUrl parsedUrl = QUrl::fromEncoded(URLs.first().toLocal8Bit()); - - QString fileName; - - // Nexus S3-signed download URLs carry the desired filename in the - // `response-content-disposition` query parameter, e.g. - // response-content-disposition=attachment;filename="JohnnyGuitar NVSE - 5.06.7z" - // (percent-encoded on the wire). QUrlQuery decodes values for us. This - // must be checked first — the URL path itself is just the CDN object key, - // which looks like "9183bb...b-fdcf" and is useless as a filename. - const QUrlQuery query(parsedUrl); - const QString kDispoKey = QStringLiteral("response-content-disposition"); - if (query.hasQueryItem(kDispoKey)) { - const QString disposition = - query.queryItemValue(kDispoKey, QUrl::FullyDecoded); - // RFC 6266: filename="quoted" or filename=unquoted-token or filename*=ext-value. - static const QRegularExpression rxQuoted( - QStringLiteral("filename=\"([^\"]+)\"")); - static const QRegularExpression rxUnquoted( - QStringLiteral("filename=([^;\\s]+)")); - static const QRegularExpression rxExtValue( - QStringLiteral("filename\\*=[^']*'[^']*'([^;\\s]+)")); - QRegularExpressionMatch m = rxQuoted.match(disposition); - if (!m.hasMatch()) m = rxExtValue.match(disposition); - if (!m.hasMatch()) m = rxUnquoted.match(disposition); - if (m.hasMatch()) { - fileName = QUrl::fromPercentEncoding(m.captured(1).toUtf8()); - } - } - - // Fall back to the last path segment of the URL if the disposition trick - // didn't produce anything. - if (fileName.isEmpty()) { - fileName = parsedUrl.fileName(); - if (!fileName.isEmpty()) { - fileName = QUrl::fromPercentEncoding(fileName.toUtf8()); - } - } - - if (fileName.isEmpty()) { - fileName = "unknown"; - } - - QUrl const preferredUrl = QUrl::fromEncoded(URLs.first().toLocal8Bit()); - log::debug("selected download url: {}", preferredUrl.toString()); - QHttp2Configuration h2Conf; - h2Conf.setSessionReceiveWindowSize( - 16777215); // 16 MiB, based on Chrome and Firefox values - h2Conf.setStreamReceiveWindowSize(16777215); - QNetworkRequest request(preferredUrl); - request.setHeader(QNetworkRequest::UserAgentHeader, - m_NexusInterface->getAccessManager()->userAgent()); - request.setAttribute(QNetworkRequest::CacheSaveControlAttribute, false); - request.setAttribute(QNetworkRequest::CacheLoadControlAttribute, - QNetworkRequest::AlwaysNetwork); - request.setHttp2Configuration(h2Conf); - return addDownload(m_NexusInterface->getAccessManager()->get(request), URLs, fileName, - gameName, modID, fileID, fileInfo); -} - -bool DownloadManager::addDownload(QNetworkReply* reply, - const ModRepositoryFileInfo* fileInfo) -{ - QString fileName = getFileNameFromNetworkReply(reply); - if (fileName.isEmpty()) { - fileName = "unknown"; - } - - return addDownload(reply, QStringList(reply->url().toString()), fileName, - fileInfo->gameName, fileInfo->modID, fileInfo->fileID, fileInfo); -} - -bool DownloadManager::addDownload(QNetworkReply* reply, const QStringList& URLs, - const QString& fileName, QString gameName, int modID, - int fileID, const ModRepositoryFileInfo* fileInfo) -{ - // download invoked from an already open network reply (i.e. download link in the - // browser) - DownloadInfo* newDownload = DownloadInfo::createNew(fileInfo, URLs); - - QString baseName = fileName; - if (!fileInfo->fileName.isEmpty()) { - baseName = fileInfo->fileName; - } else { - QString const dispoName = getFileNameFromNetworkReply(reply); - - if (!dispoName.isEmpty()) { - baseName = dispoName; - } - } - - // baseName could be a URL at this point so strip out the URL query - int const queryIndex = baseName.indexOf("?"); - if (queryIndex >= 0) { - baseName.truncate(queryIndex); - } - - startDisableDirWatcher(); - newDownload->setName(getDownloadFileName(baseName), false); - endDisableDirWatcher(); - - startDownload(reply, newDownload, false); - // emit update(-1); - return true; -} - -void DownloadManager::removePending(QString gameName, int modID, int fileID) -{ - QString gameShortName = gameName; - QStringList games(m_ManagedGame->validShortNames()); - games += m_ManagedGame->gameShortName(); - for (const auto& game : games) { - MOBase::IPluginGame* gamePlugin = m_OrganizerCore->getGame(game); - if (gamePlugin != nullptr && - gamePlugin->gameNexusName().compare(gameName, Qt::CaseInsensitive) == 0) { - gameShortName = gamePlugin->gameShortName(); - break; - } - } - emit aboutToUpdate(); - for (auto iter : m_PendingDownloads) { - if (gameShortName.compare(std::get<0>(iter), Qt::CaseInsensitive) == 0 && - (std::get<1>(iter) == modID) && (std::get<2>(iter) == fileID)) { - m_PendingDownloads.removeAt(m_PendingDownloads.indexOf(iter)); - break; - } - } - emit update(-1); -} - -void DownloadManager::startDownload(QNetworkReply* reply, DownloadInfo* newDownload, - bool resume) -{ - reply->setReadBufferSize( - 1024 * 1024); // don't read more than 1MB at once to avoid memory troubles - newDownload->m_Reply = reply; - setState(newDownload, STATE_DOWNLOADING); - if (newDownload->m_Urls.count() == 0) { - newDownload->m_Urls = QStringList(reply->url().toString()); - } - - QIODevice::OpenMode mode = QIODevice::WriteOnly; - if (resume) { - mode |= QIODevice::Append; - } - - newDownload->m_StartTime.start(); - createMetaFile(newDownload); - - if (!newDownload->m_Output.open(mode)) { - reportError(tr("failed to download %1: could not open output file: %2") - .arg(reply->url().toString()) - .arg(newDownload->m_Output.fileName())); - return; - } - - connect(newDownload->m_Reply, SIGNAL(downloadProgress(qint64, qint64)), this, - SLOT(downloadProgress(qint64, qint64))); - connect(newDownload->m_Reply, SIGNAL(errorOccurred(QNetworkReply::NetworkError)), - this, SLOT(downloadError(QNetworkReply::NetworkError))); - connect(newDownload->m_Reply, SIGNAL(readyRead()), this, SLOT(downloadReadyRead())); - connect(newDownload->m_Reply, SIGNAL(metaDataChanged()), this, - SLOT(metaDataChanged())); - - if (!resume) { - newDownload->m_PreResumeSize = newDownload->m_Output.size(); - removePending(newDownload->m_FileInfo->gameName, newDownload->m_FileInfo->modID, - newDownload->m_FileInfo->fileID); - - emit aboutToUpdate(); - m_ActiveDownloads.append(newDownload); - - emit update(-1); - emit downloadAdded(); - - if (QFile::exists(m_OutputDirectory + "/" + newDownload->m_FileName)) { - setState(newDownload, STATE_PAUSING); - QCoreApplication::processEvents(); - if (QMessageBox::question( - m_ParentWidget, tr("Download again?"), - tr("A file with the same name \"%1\" has already been downloaded. " - "Do you want to download it again? The new file will receive a " - "different name.") - .arg(newDownload->m_FileName), - QMessageBox::Yes | QMessageBox::No) == QMessageBox::No) { - if (reply->isFinished()) - setState(newDownload, STATE_CANCELED); - else - setState(newDownload, STATE_CANCELING); - } else { - startDisableDirWatcher(); - newDownload->setName(getDownloadFileName(newDownload->m_FileName, true), true); - endDisableDirWatcher(); - if (newDownload->m_State == STATE_PAUSED) - resumeDownload(indexByInfo(newDownload)); - else - setState(newDownload, STATE_DOWNLOADING); - } - } else - connect(newDownload->m_Reply, SIGNAL(finished()), this, SLOT(downloadFinished())); - - QCoreApplication::processEvents(); - - if (newDownload->m_State != STATE_DOWNLOADING && - newDownload->m_State != STATE_READY && - newDownload->m_State != STATE_FETCHINGMODINFO && reply->isFinished()) { - int const index = indexByInfo(newDownload); - if (index >= 0) { - downloadFinished(index); - } - return; - } - } else - connect(newDownload->m_Reply, SIGNAL(finished()), this, SLOT(downloadFinished())); -} - -void DownloadManager::addNXMDownload(const QString& url) -{ - NXMUrl const nxmInfo(url); - - QStringList validGames; - const MOBase::IPluginGame* foundGame = nullptr; - validGames.append(m_ManagedGame->gameShortName()); - validGames.append(m_ManagedGame->validShortNames()); - for (auto game : validGames) { - MOBase::IPluginGame* gamePlugin = m_OrganizerCore->getGame(game); - - // some game plugins give names in validShortNames() that may refer to other - // plugins, like ttw returning "FalloutNV" and "Fallout3"; if these plugins - // are not loaded, getGame() might return null - if (!gamePlugin) { - // log an error, it's most probably not normal - log::error("no plugin for game '{}', an antivirus might have deleted it", game); - continue; - } - - if (nxmInfo.game().compare(gamePlugin->gameShortName(), Qt::CaseInsensitive) == 0 || - nxmInfo.game().compare(gamePlugin->gameNexusName(), Qt::CaseInsensitive) == 0) { - foundGame = gamePlugin; - break; - } - } - log::debug("add nxm download: {}", url); - // The Nexus API game name to use for requests (may differ from foundGame's short name - // for special domains like "site" that have no matching game plugin). - QString apiGameName; - if (foundGame == nullptr) { - // "site" is Nexus Mods' game-agnostic domain for tools/utilities (e.g. BethINI Pie). - // Allow these downloads using the current managed game's API context. - if (nxmInfo.game().compare("site", Qt::CaseInsensitive) == 0) { - log::debug("NXM link is for nexusmods.com/site (game-agnostic tool), allowing download"); - foundGame = m_ManagedGame; - apiGameName = nxmInfo.game(); // keep "site" for correct API URLs - } else { - log::debug("download requested for wrong game (game: {}, url: {})", - m_ManagedGame->gameShortName(), nxmInfo.game()); - QMessageBox::information( - m_ParentWidget, tr("Wrong Game"), - tr("The download link is for a mod for \"%1\" but this instance of MO " - "has been set up for \"%2\".") - .arg(nxmInfo.game()) - .arg(m_ManagedGame->gameShortName()), - QMessageBox::Ok); - return; - } - } else { - apiGameName = foundGame->gameShortName(); - } - - for (auto tuple : m_PendingDownloads) { - if (std::get<0>(tuple).compare(apiGameName, Qt::CaseInsensitive) == - 0, - std::get<1>(tuple) == nxmInfo.modId() && - std::get<2>(tuple) == nxmInfo.fileId()) { - const auto infoStr = - tr("There is already a download queued for this file.\n\nMod %1\nFile %2") - .arg(nxmInfo.modId()) - .arg(nxmInfo.fileId()); - - log::debug("download requested is already queued (mod: {}, file: {})", - nxmInfo.modId(), nxmInfo.fileId()); - - QMessageBox::information(m_ParentWidget, tr("Already Queued"), infoStr, - QMessageBox::Ok); - return; - } - } - - for (DownloadInfo* download : m_ActiveDownloads) { - if (download->m_FileInfo->modID == nxmInfo.modId() && - download->m_FileInfo->fileID == nxmInfo.fileId()) { - if (download->m_State == STATE_DOWNLOADING || download->m_State == STATE_PAUSED || - download->m_State == STATE_STARTED) { - QString debugStr( - "download requested is already started (mod %1: %2, file %3: %4)"); - QString infoStr(tr("There is already a download started for this file.\n\nMod " - "%1:\t%2\nFile %3:\t%4")); - - // %1 - debugStr = debugStr.arg(download->m_FileInfo->modID); - infoStr = infoStr.arg(download->m_FileInfo->modID); - - // %2 - if (!download->m_FileInfo->name.isEmpty()) { - debugStr = debugStr.arg(download->m_FileInfo->name); - infoStr = infoStr.arg(download->m_FileInfo->name); - } else if (!download->m_FileInfo->modName.isEmpty()) { - debugStr = debugStr.arg(download->m_FileInfo->modName); - infoStr = infoStr.arg(download->m_FileInfo->modName); - } else { - debugStr = debugStr.arg(QStringLiteral("")); - infoStr = infoStr.arg(QStringLiteral("")); - } - - // %3 - debugStr = debugStr.arg(download->m_FileInfo->fileID); - infoStr = infoStr.arg(download->m_FileInfo->fileID); - - // %4 - if (!download->m_FileInfo->fileName.isEmpty()) { - debugStr = debugStr.arg(download->m_FileInfo->fileName); - infoStr = infoStr.arg(download->m_FileInfo->fileName); - } else if (!download->m_FileName.isEmpty()) { - debugStr = debugStr.arg(download->m_FileName); - infoStr = infoStr.arg(download->m_FileName); - } else { - debugStr = debugStr.arg(QStringLiteral("")); - infoStr = infoStr.arg(QStringLiteral("")); - } - - log::debug("{}", debugStr); - QMessageBox::information(m_ParentWidget, tr("Already Started"), infoStr, - QMessageBox::Ok); - return; - } - } - } - - emit aboutToUpdate(); - - m_PendingDownloads.append( - std::make_tuple(apiGameName, nxmInfo.modId(), nxmInfo.fileId())); - - emit update(-1); - emit downloadAdded(); - ModRepositoryFileInfo* info = new ModRepositoryFileInfo(); - - info->nexusKey = nxmInfo.key(); - info->nexusExpires = nxmInfo.expires(); - info->nexusDownloadUser = nxmInfo.userId(); - - QObject* test = info; - m_RequestIDs.insert(m_NexusInterface->requestFileInfo( - apiGameName, nxmInfo.modId(), nxmInfo.fileId(), this, - QVariant::fromValue(test), "")); -} - -void DownloadManager::removeFile(int index, bool deleteFile) -{ - // Avoid triggering refreshes from DirWatcher - ScopedDisableDirWatcher const scopedDirWatcher(this); - - if (index >= m_ActiveDownloads.size()) { - throw MyException(tr("remove: invalid download index %1").arg(index)); - } - - DownloadInfo* download = m_ActiveDownloads.at(index); - QString filePath = m_OutputDirectory + "/" + download->m_FileName; - if ((download->m_State == STATE_STARTED) || - (download->m_State == STATE_DOWNLOADING)) { - // shouldn't have been possible - log::error("tried to remove active download"); - return; - } - - if ((download->m_State == STATE_PAUSED) || (download->m_State == STATE_ERROR)) { - filePath = download->m_Output.fileName(); - } - - if (deleteFile) { - if (!shellDelete(QStringList(filePath), true)) { - reportError(tr("failed to delete %1").arg(filePath)); - return; - } - - QFile const metaFile(filePath.append(".meta")); - if (metaFile.exists() && !shellDelete(QStringList(filePath), true)) { - reportError(tr("failed to delete meta file for %1").arg(filePath)); - } - } else { - QSettings metaSettings(filePath.append(".meta"), QSettings::IniFormat); - if (!download->m_Hidden) - metaSettings.setValue("removed", true); - } - m_DownloadRemoved(index); -} - -class LessThanWrapper -{ -public: - LessThanWrapper(DownloadManager* manager) : m_Manager(manager) {} - bool operator()(int LHS, int RHS) - { - return m_Manager->getFileName(LHS).compare(m_Manager->getFileName(RHS), - Qt::CaseInsensitive) < 0; - } - -private: - DownloadManager* m_Manager; -}; - -bool DownloadManager::ByName(int LHS, int RHS) -{ - return m_ActiveDownloads[LHS]->m_FileName < m_ActiveDownloads[RHS]->m_FileName; -} - -void DownloadManager::refreshAlphabeticalTranslation() -{ - m_AlphabeticalTranslation.clear(); - int pos = 0; - for (QVector::iterator iter = m_ActiveDownloads.begin(); - iter != m_ActiveDownloads.end(); ++iter, ++pos) { - m_AlphabeticalTranslation.push_back(pos); - } - - std::sort(m_AlphabeticalTranslation.begin(), m_AlphabeticalTranslation.end(), - LessThanWrapper(this)); -} - -void DownloadManager::restoreDownload(int index) -{ - - if (index < 0) { - DownloadState const minState = STATE_READY; - index = 0; - - for (QVector::const_iterator iter = m_ActiveDownloads.begin(); - iter != m_ActiveDownloads.end(); ++iter) { - - if ((*iter)->m_State >= minState) { - restoreDownload(index); - } - index++; - } - } else { - if (index >= m_ActiveDownloads.size()) { - throw MyException(tr("restore: invalid download index: %1").arg(index)); - } - - DownloadInfo* download = m_ActiveDownloads.at(index); - if (download->m_Hidden) { - download->m_Hidden = false; - - QString filePath = m_OutputDirectory + "/" + download->m_FileName; - - // avoid dirWatcher triggering refreshes - startDisableDirWatcher(); - QSettings metaSettings(filePath.append(".meta"), QSettings::IniFormat); - metaSettings.setValue("removed", false); - - endDisableDirWatcher(); - } - } -} - -void DownloadManager::removeDownload(int index, bool deleteFile) -{ - try { - // avoid dirWatcher triggering refreshes - ScopedDisableDirWatcher const scopedDirWatcher(this); - - emit aboutToUpdate(); - - if (index < 0) { - bool const removeAll = (index == -1); - DownloadState const removeState = (index == -2 ? STATE_INSTALLED : STATE_UNINSTALLED); - - index = 0; - for (QVector::iterator iter = m_ActiveDownloads.begin(); - iter != m_ActiveDownloads.end();) { - DownloadState const downloadState = (*iter)->m_State; - if ((removeAll && (downloadState >= STATE_READY)) || - (removeState == downloadState)) { - removeFile(index, deleteFile); - delete *iter; - iter = m_ActiveDownloads.erase(iter); - } else { - ++iter; - ++index; - } - } - } else { - if (index >= m_ActiveDownloads.size()) { - reportError(tr("remove: invalid download index %1").arg(index)); - // emit update(-1); - return; - } - - removeFile(index, deleteFile); - delete m_ActiveDownloads.at(index); - m_ActiveDownloads.erase(m_ActiveDownloads.begin() + index); - } - emit update(-1); - } catch (const std::exception& e) { - log::error("failed to remove download: {}", e.what()); - } - refreshList(); -} - -void DownloadManager::cancelDownload(int index) -{ - if ((index < 0) || (index >= m_ActiveDownloads.size())) { - reportError(tr("cancel: invalid download index %1").arg(index)); - return; - } - - DownloadInfo* info = m_ActiveDownloads.at(index); - if (info->m_State == STATE_DOWNLOADING || info->m_State == STATE_STARTED || - info->m_State == STATE_PAUSING) { - setState(info, STATE_CANCELING); - } -} - -void DownloadManager::pauseDownload(int index) -{ - if ((index < 0) || (index >= m_ActiveDownloads.size())) { - reportError(tr("pause: invalid download index %1").arg(index)); - return; - } - - DownloadInfo* info = m_ActiveDownloads.at(index); - - if (info->m_State == STATE_DOWNLOADING) { - if ((info->m_Reply != nullptr) && (info->m_Reply->isRunning())) { - setState(info, STATE_PAUSING); - } else { - setState(info, STATE_PAUSED); - } - } else if ((info->m_State == STATE_FETCHINGMODINFO) || - (info->m_State == STATE_FETCHINGFILEINFO) || - (info->m_State == STATE_FETCHINGMODINFO_MD5)) { - setState(info, STATE_READY); - } -} - -void DownloadManager::resumeDownload(int index) -{ - if ((index < 0) || (index >= m_ActiveDownloads.size())) { - reportError(tr("resume: invalid download index %1").arg(index)); - return; - } - DownloadInfo* info = m_ActiveDownloads[index]; - info->m_Tries = AUTOMATIC_RETRIES; - resumeDownloadInt(index); -} - -void DownloadManager::resumeDownloadInt(int index) -{ - if ((index < 0) || (index >= m_ActiveDownloads.size())) { - reportError(tr("resume (int): invalid download index %1").arg(index)); - return; - } - DownloadInfo* info = m_ActiveDownloads[index]; - - // Check for finished download; - if (info->m_TotalSize <= info->m_Output.size() && info->m_Reply != nullptr && - info->m_Reply->isFinished() && info->m_State != STATE_ERROR) { - setState(info, STATE_DOWNLOADING); - downloadFinished(index); - return; - } - - if (info->isPausedState() || info->m_State == STATE_PAUSING) { - if (info->m_State == STATE_PAUSING) { - if (info->m_Output.isOpen()) { - writeData(info); - if (info->m_State == STATE_PAUSING) { - setState(info, STATE_PAUSED); - } - } - } - if ((info->m_Urls.empty()) || - ((info->m_Urls.size() == 1) && (info->m_Urls[0].size() == 0))) { - emit showMessage( - tr("No known download urls. Sorry, this download can't be resumed.")); - return; - } - if (info->m_State == STATE_ERROR) { - info->m_CurrentUrl = (info->m_CurrentUrl + 1) % info->m_Urls.count(); - } - log::debug("request resume from url {}", info->currentURL()); - QNetworkRequest request(QUrl::fromEncoded(info->currentURL().toLocal8Bit())); - request.setHeader(QNetworkRequest::UserAgentHeader, - m_NexusInterface->getAccessManager()->userAgent()); - if (info->m_State != STATE_ERROR) { - info->m_ResumePos = info->m_Output.size(); - QByteArray const rangeHeader = "bytes=" + QByteArray::number(info->m_ResumePos) + "-"; - request.setRawHeader("Range", rangeHeader); - } - info->m_DownloadLast = 0; - info->m_DownloadTimeLast = 0; - info->m_DownloadAcc = accumulator_set>( - tag::rolling_window::window_size = 200); - info->m_DownloadTimeAcc = accumulator_set>( - tag::rolling_window::window_size = 200); - log::debug("resume at {} bytes", info->m_ResumePos); - startDownload(m_NexusInterface->getAccessManager()->get(request), info, true); - } - emit update(index); -} - -DownloadManager::DownloadInfo* DownloadManager::downloadInfoByID(unsigned int id) -{ - auto iter = std::find_if(m_ActiveDownloads.begin(), m_ActiveDownloads.end(), - [id](DownloadInfo* info) { - return info->m_DownloadID == id; - }); - if (iter != m_ActiveDownloads.end()) { - return *iter; - } else { - return nullptr; - } -} - -void DownloadManager::queryInfo(int index) -{ - if ((index < 0) || (index >= m_ActiveDownloads.size())) { - reportError(tr("query: invalid download index %1").arg(index)); - return; - } - DownloadInfo* info = m_ActiveDownloads[index]; - - if (info->m_FileInfo->repository != "Nexus") { - log::warn("re-querying file info is currently only possible with Nexus"); - return; - } - - if (info->m_State < DownloadManager::STATE_READY) { - // UI shouldn't allow this - return; - } - - if (info->m_FileInfo->modID <= 0) { - QString const fileName = getFileName(index); - QString ignore; - NexusInterface::interpretNexusFileName(fileName, ignore, info->m_FileInfo->modID, - info->m_AskIfNotFound); - if (info->m_FileInfo->modID < 0) { - bool ok = false; - int const modId = QInputDialog::getInt(nullptr, tr("Please enter the Nexus mod ID"), - tr("Mod ID:"), 1, 1, - std::numeric_limits::max(), 1, &ok); - // careful now: while the dialog was displayed, events were processed. - // the download list might have changed and our info-ptr invalidated. - if (ok) - m_ActiveDownloads[index]->m_FileInfo->modID = modId; - return; - } - } - - if (info->m_FileInfo->gameName.isEmpty()) { - if (!info->m_AskIfNotFound) { - // in case the info couldn't be retrieved from Nexus due to connection issues, - // don't create a meta file so you can query again after the issues are resolved. - return; - } - - SelectionDialog selection( - tr("Please select the source game code for %1").arg(getFileName(index))); - - std::vector> choices = - m_NexusInterface->getGameChoices(m_ManagedGame); - if (choices.size() == 1) { - info->m_FileInfo->gameName = choices[0].first; - } else { - for (const auto& choice : choices) { - selection.addChoice(choice.first, choice.second, choice.first); - } - if (selection.exec() == QDialog::Accepted) { - info->m_FileInfo->gameName = selection.getChoiceData().toString(); - } else { - info->m_FileInfo->gameName = m_ManagedGame->gameShortName(); - } - } - } - info->m_ReQueried = true; - setState(info, STATE_FETCHINGMODINFO); -} - -void DownloadManager::queryInfoMd5(int index, bool askIfNotFound) -{ - if ((index < 0) || (index >= m_ActiveDownloads.size())) { - reportError(tr("query: invalid download index %1").arg(index)); - return; - } - DownloadInfo* info = m_ActiveDownloads[index]; - - if (info->m_FileInfo->repository != "Nexus") { - log::warn("re-querying file info is currently only possible with Nexus"); - return; - } - - if (info->m_State < DownloadManager::STATE_READY) { - // UI shouldn't allow this - return; - } - - info->m_GamesToQuery << m_ManagedGame->gameShortName(); - info->m_GamesToQuery << m_ManagedGame->validShortNames(); - - QFile downloadFile(info->m_FileName); - if (!downloadFile.exists()) { - downloadFile.setFileName(m_OrganizerCore->downloadsPath() + "/" + - info->m_FileName); - } - if (!downloadFile.exists()) { - log::error("Can't find download file '{}'", info->m_FileName); - return; - } - if (!downloadFile.open(QIODevice::ReadOnly)) { - log::error("Can't open download file '{}'", info->m_FileName); - return; - } - - QCryptographicHash hash(QCryptographicHash::Md5); - const qint64 progressStep = 10 * 1024 * 1024; - QProgressDialog progress(tr("Hashing download file '%1'").arg(info->m_FileName), - tr("Cancel"), 0, downloadFile.size() / progressStep); - progress.setWindowModality(Qt::WindowModal); - progress.setMinimumDuration(1000); - - for (qint64 i = 0; i < downloadFile.size(); i += progressStep) { - progress.setValue(progress.value() + 1); - if (progress.wasCanceled()) { - break; - } - hash.addData(downloadFile.read(progressStep)); - } - if (progress.wasCanceled()) { - downloadFile.close(); - return; - } - - progress.close(); - downloadFile.close(); - - info->m_Hash = hash.result(); - info->m_ReQueried = true; - info->m_AskIfNotFound = askIfNotFound; - setState(info, STATE_FETCHINGMODINFO_MD5); -} - -void DownloadManager::visitOnNexus(int index) -{ - if ((index < 0) || (index >= m_ActiveDownloads.size())) { - reportError(tr("VisitNexus: invalid download index %1").arg(index)); - return; - } - DownloadInfo* info = m_ActiveDownloads[index]; - - if (info->m_FileInfo->repository != "Nexus") { - log::warn("Visiting mod page is currently only possible with Nexus"); - return; - } - - if (info->m_State < DownloadManager::STATE_READY) { - // UI shouldn't allow this - return; - } - int const modID = info->m_FileInfo->modID; - - QString const gameName = info->m_FileInfo->gameName; - if (modID > 0) { - shell::Open(QUrl(m_NexusInterface->getModURL(modID, gameName))); - } else { - emit showMessage(tr("Nexus ID for this Mod is unknown")); - } -} - -void DownloadManager::visitUploaderProfile(int index) -{ - if ((index < 0) || (index >= m_ActiveDownloads.size())) { - reportError(tr("VisitUploaderProfile: invalid download index %1").arg(index)); - return; - } - DownloadInfo* info = m_ActiveDownloads[index]; - - if (info->m_State < DownloadManager::STATE_READY) { - // UI shouldn't allow this - return; - } - - const auto& uploaderUrl = info->m_FileInfo->uploaderUrl; - if (!uploaderUrl.isEmpty()) { - shell::Open(QUrl(uploaderUrl)); - } else { - emit showMessage(tr("Uploader for this Mod is unknown")); - } -} - -void DownloadManager::openFile(int index) -{ - if ((index < 0) || (index >= m_ActiveDownloads.size())) { - reportError(tr("OpenFile: invalid download index %1").arg(index)); - return; - } - - QDir const path = QDir(m_OutputDirectory); - if (path.exists(getFileName(index))) { - shell::Open(getFilePath(index)); - return; - } - - shell::Explore(m_OutputDirectory); - return; -} - -void DownloadManager::openMetaFile(int index) -{ - if (index < 0 || index >= m_ActiveDownloads.size()) { - log::error("OpenMetaFile: invalid download index {}", index); - return; - } - - const auto path = QDir(m_OutputDirectory); - const auto filePath = getFilePath(index); - const auto metaPath = filePath + ".meta"; - - if (path.exists(metaPath)) { - shell::Open(metaPath); - return; - } else { - QSettings metaFile(metaPath, QSettings::IniFormat); - metaFile.setValue("removed", false); - } - - if (path.exists(metaPath)) { - shell::Open(metaPath); - return; - } - - shell::Explore(filePath); -} - -void DownloadManager::openInDownloadsFolder(int index) -{ - if ((index < 0) || (index >= m_ActiveDownloads.size())) { - reportError(tr("OpenFileInDownloadsFolder: invalid download index %1").arg(index)); - return; - } - - const auto path = getFilePath(index); - - if (QFile::exists(path)) { - shell::Explore(path); - return; - } else { - const auto unfinished = path + ".unfinished"; - if (QFile::exists(unfinished)) { - shell::Explore(unfinished); - return; - } - } - - shell::Explore(m_OutputDirectory); -} - -int DownloadManager::numTotalDownloads() const -{ - return m_ActiveDownloads.size(); -} - -int DownloadManager::numPendingDownloads() const -{ - return m_PendingDownloads.size(); -} - -std::tuple DownloadManager::getPendingDownload(int index) -{ - if ((index < 0) || (index >= m_PendingDownloads.size())) { - throw MyException(tr("get pending: invalid download index %1").arg(index)); - } - - return m_PendingDownloads.at(index); -} - -QString DownloadManager::getFilePath(int index) const -{ - if ((index < 0) || (index >= m_ActiveDownloads.size())) { - throw MyException(tr("get path: invalid download index %1").arg(index)); - } - - return m_OutputDirectory + "/" + m_ActiveDownloads.at(index)->m_FileName; -} - -QString DownloadManager::getFileTypeString(int fileType) -{ - switch (fileType) { - case 1: - return tr("Main"); - case 2: - return tr("Update"); - case 3: - return tr("Optional"); - case 4: - return tr("Old"); - case 5: - return tr("Miscellaneous"); - case 6: - return tr("Deleted"); - case 7: - return tr("Archived"); - default: - return tr("Unknown"); - } -} - -QString DownloadManager::getDisplayName(int index) const -{ - if ((index < 0) || (index >= m_ActiveDownloads.size())) { - throw MyException(tr("display name: invalid download index %1").arg(index)); - } - - DownloadInfo* info = m_ActiveDownloads.at(index); - - QTextDocument doc; - if (!info->m_FileInfo->name.isEmpty()) { - doc.setHtml(info->m_FileInfo->name); - return QString("%1 (%2, v%3)") - .arg(doc.toPlainText()) - .arg(getFileTypeString(info->m_FileInfo->fileCategory)) - .arg(info->m_FileInfo->version.displayString()); - } else { - doc.setHtml(info->m_FileName); - return doc.toPlainText(); - } -} - -QString DownloadManager::getFileName(int index) const -{ - if ((index < 0) || (index >= m_ActiveDownloads.size())) { - throw MyException(tr("file name: invalid download index %1").arg(index)); - } - - return m_ActiveDownloads.at(index)->m_FileName; -} - -QDateTime DownloadManager::getFileTime(int index) const -{ - if ((index < 0) || (index >= m_ActiveDownloads.size())) { - throw MyException(tr("file time: invalid download index %1").arg(index)); - } - - DownloadInfo* info = m_ActiveDownloads.at(index); - if (!info->m_Created.isValid()) { - QFileInfo const fileInfo(info->m_Output); - info->m_Created = fileInfo.birthTime(); - if (!info->m_Created.isValid()) - info->m_Created = fileInfo.metadataChangeTime(); - if (!info->m_Created.isValid()) - info->m_Created = fileInfo.lastModified(); - } - - return info->m_Created; -} - -qint64 DownloadManager::getFileSize(int index) const -{ - if ((index < 0) || (index >= m_ActiveDownloads.size())) { - throw MyException(tr("file size: invalid download index %1").arg(index)); - } - - return m_ActiveDownloads.at(index)->m_TotalSize; -} - -std::pair DownloadManager::getProgress(int index) const -{ - if ((index < 0) || (index >= m_ActiveDownloads.size())) { - throw MyException(tr("progress: invalid download index %1").arg(index)); - } - - return m_ActiveDownloads.at(index)->m_Progress; -} - -DownloadManager::DownloadState DownloadManager::getState(int index) const -{ - if ((index < 0) || (index >= m_ActiveDownloads.size())) { - throw MyException(tr("state: invalid download index %1").arg(index)); - } - - return m_ActiveDownloads.at(index)->m_State; -} - -bool DownloadManager::isInfoIncomplete(int index) const -{ - if ((index < 0) || (index >= m_ActiveDownloads.size())) { - throw MyException(tr("infocomplete: invalid download index %1").arg(index)); - } - - DownloadInfo* info = m_ActiveDownloads.at(index); - if (info->m_FileInfo->repository != "Nexus") { - // other repositories currently don't support re-querying info anyway - return false; - } - return (info->m_FileInfo->fileID == 0) || (info->m_FileInfo->modID == 0); -} - -int DownloadManager::getModID(int index) const -{ - if ((index < 0) || (index >= m_ActiveDownloads.size())) { - throw MyException(tr("mod id: invalid download index %1").arg(index)); - } - return m_ActiveDownloads.at(index)->m_FileInfo->modID; -} - -QString DownloadManager::getDisplayGameName(int index) const -{ - if ((index < 0) || (index >= m_ActiveDownloads.size())) { - throw MyException(tr("mod id: invalid download index %1").arg(index)); - } - QString gameName = m_ActiveDownloads.at(index)->m_FileInfo->gameName; - IPluginGame* gamePlugin = m_OrganizerCore->getGame(gameName); - if (gamePlugin) { - gameName = gamePlugin->gameName(); - } - return gameName; -} - -QString DownloadManager::getGameName(int index) const -{ - if ((index < 0) || (index >= m_ActiveDownloads.size())) { - throw MyException(tr("mod id: invalid download index %1").arg(index)); - } - return m_ActiveDownloads.at(index)->m_FileInfo->gameName; -} - -bool DownloadManager::isHidden(int index) const -{ - if ((index < 0) || (index >= m_ActiveDownloads.size())) { - throw MyException(tr("ishidden: invalid download index %1").arg(index)); - } - return m_ActiveDownloads.at(index)->m_Hidden; -} - -const ModRepositoryFileInfo* DownloadManager::getFileInfo(int index) const -{ - if ((index < 0) || (index >= m_ActiveDownloads.size())) { - throw MyException(tr("file info: invalid download index %1").arg(index)); - } - - return m_ActiveDownloads.at(index)->m_FileInfo; -} - -void DownloadManager::markInstalled(int index) -{ - if ((index < 0) || (index >= m_ActiveDownloads.size())) { - throw MyException(tr("mark installed: invalid download index %1").arg(index)); - } - - // Avoid triggering refreshes from DirWatcher - ScopedDisableDirWatcher const scopedDirWatcher(this); - - DownloadInfo* info = m_ActiveDownloads.at(index); - QSettings metaFile(info->m_Output.fileName() + ".meta", QSettings::IniFormat); - metaFile.setValue("installed", true); - metaFile.setValue("uninstalled", false); - - setState(m_ActiveDownloads.at(index), STATE_INSTALLED); -} - -void DownloadManager::markInstalled(QString fileName) -{ - int const index = indexByName(fileName); - if (index >= 0) { - markInstalled(index); - } else { - DownloadInfo* info = getDownloadInfo(fileName); - if (info != nullptr) { - // Avoid triggering refreshes from DirWatcher - ScopedDisableDirWatcher const scopedDirWatcher(this); - - QSettings metaFile(info->m_Output.fileName() + ".meta", QSettings::IniFormat); - metaFile.setValue("installed", true); - metaFile.setValue("uninstalled", false); - delete info; - } - } -} - -DownloadManager::DownloadInfo* DownloadManager::getDownloadInfo(QString fileName) -{ - return DownloadInfo::createFromMeta(fileName, true, m_OutputDirectory); -} - -void DownloadManager::markUninstalled(int index) -{ - if ((index < 0) || (index >= m_ActiveDownloads.size())) { - throw MyException(tr("mark uninstalled: invalid download index %1").arg(index)); - } - - // Avoid triggering refreshes from DirWatcher - ScopedDisableDirWatcher const scopedDirWatcher(this); - - DownloadInfo* info = m_ActiveDownloads.at(index); - QSettings metaFile(info->m_Output.fileName() + ".meta", QSettings::IniFormat); - metaFile.setValue("uninstalled", true); - - setState(m_ActiveDownloads.at(index), STATE_UNINSTALLED); -} - -void DownloadManager::markUninstalled(QString fileName) -{ - int const index = indexByName(fileName); - if (index >= 0) { - markUninstalled(index); - } else { - QString const filePath = QDir::fromNativeSeparators(m_OutputDirectory) + "/" + fileName; - DownloadInfo* info = getDownloadInfo(filePath); - if (info != nullptr) { - - // Avoid triggering refreshes from DirWatcher - ScopedDisableDirWatcher const scopedDirWatcher(this); - - QSettings metaFile(info->m_Output.fileName() + ".meta", QSettings::IniFormat); - metaFile.setValue("uninstalled", true); - delete info; - } - } -} - -QString DownloadManager::getDownloadFileName(const QString& baseName, bool rename) const -{ - QString fullPath = m_OutputDirectory + "/" + MOBase::sanitizeFileName(baseName); - if (QFile::exists(fullPath) && rename) { - int i = 1; - while (QFile::exists( - QString("%1/%2_%3").arg(m_OutputDirectory).arg(i).arg(baseName))) { - ++i; - } - - fullPath = QString("%1/%2_%3").arg(m_OutputDirectory).arg(i).arg(baseName); - } - return fullPath; -} - -QString DownloadManager::getFileNameFromNetworkReply(QNetworkReply* reply) -{ - if (reply->hasRawHeader("Content-Disposition")) { - std::regex const exp("filename=\"(.+)\""); - - std::cmatch result; - if (std::regex_search(reply->rawHeader("Content-Disposition").constData(), result, - exp)) { - return MOBase::sanitizeFileName(QString::fromUtf8(result.str(1).c_str())); - } - } - - return {}; -} - -void DownloadManager::setState(DownloadManager::DownloadInfo* info, - DownloadManager::DownloadState state) -{ - int row = 0; - for (int i = 0; i < m_ActiveDownloads.size(); ++i) { - if (m_ActiveDownloads[i] == info) { - row = i; - break; - } - } - info->m_State = state; - switch (state) { - case STATE_CANCELING: { - // Force termination so the download transitions through finished(). - if (info->m_Reply != nullptr && info->m_Reply->isRunning()) { - info->m_Reply->abort(); - } - } break; - case STATE_PAUSED: { - info->m_Reply->abort(); - info->m_Output.close(); - m_DownloadPaused(row); - } break; - case STATE_ERROR: { - info->m_Reply->abort(); - info->m_Output.close(); - m_DownloadFailed(row); - } break; - case STATE_CANCELED: { - info->m_Reply->abort(); - m_DownloadFailed(row); - } break; - case STATE_FETCHINGMODINFO: { - m_RequestIDs.insert(m_NexusInterface->requestDescription( - info->m_FileInfo->gameName, info->m_FileInfo->modID, this, info->m_DownloadID, - QString())); - } break; - case STATE_FETCHINGFILEINFO: { - m_RequestIDs.insert(m_NexusInterface->requestFiles(info->m_FileInfo->gameName, - info->m_FileInfo->modID, this, - info->m_DownloadID, QString())); - } break; - case STATE_FETCHINGMODINFO_MD5: { - log::debug("Searching {} for MD5 of {}", info->m_GamesToQuery[0], - QString(info->m_Hash.toHex())); - m_RequestIDs.insert(m_NexusInterface->requestInfoFromMd5( - info->m_GamesToQuery[0], info->m_Hash, this, info->m_DownloadID, QString())); - } break; - case STATE_READY: { - createMetaFile(info); - m_DownloadComplete(row); - } break; - default: /* NOP */ - break; - } - emit stateChanged(row, state); -} - -DownloadManager::DownloadInfo* DownloadManager::findDownload(QObject* reply, - int* index) const -{ - // reverse search as newer, thus more relevant, downloads are at the end - for (int i = m_ActiveDownloads.size() - 1; i >= 0; --i) { - if (m_ActiveDownloads[i]->m_Reply == reply) { - if (index != nullptr) { - *index = i; - } - return m_ActiveDownloads[i]; - } - } - return nullptr; -} - -void DownloadManager::downloadProgress(qint64 bytesReceived, qint64 bytesTotal) -{ - if (bytesTotal == 0) { - return; - } - int index = 0; - try { - DownloadInfo* info = findDownload(this->sender(), &index); - if (info != nullptr) { - info->m_HasData = true; - if (info->m_State == STATE_CANCELING) { - setState(info, STATE_CANCELED); - } else if (info->m_State == STATE_PAUSING) { - setState(info, STATE_PAUSED); - } else { - if (bytesTotal > info->m_TotalSize) { - info->m_TotalSize = bytesTotal; - } - int const oldProgress = info->m_Progress.first; - info->m_Progress.first = ((info->m_ResumePos + bytesReceived) * 100) / - (info->m_ResumePos + bytesTotal); - - qint64 const elapsed = info->m_StartTime.elapsed(); - info->m_DownloadAcc(bytesReceived - info->m_DownloadLast); - info->m_DownloadLast = bytesReceived; - info->m_DownloadTimeAcc(elapsed - info->m_DownloadTimeLast); - info->m_DownloadTimeLast = elapsed; - - // calculate the download speed - const double speed = rolling_mean(info->m_DownloadAcc) / - (rolling_mean(info->m_DownloadTimeAcc) / 1000.0); - ; - - const qint64 remaining = (bytesTotal - bytesReceived) / speed * 1000; - - info->m_Progress.second = tr("%1% - %2 - ~%3") - .arg(info->m_Progress.first) - .arg(MOBase::localizedByteSpeed(speed)) - .arg(MOBase::localizedTimeRemaining(remaining)); - - TaskProgressManager::instance().updateProgress(info->m_TaskProgressId, - bytesReceived, bytesTotal); - emit update(index); - } - } - } catch (const std::bad_alloc&) { - reportError(tr("Memory allocation error (in processing progress event).")); - } -} - -void DownloadManager::downloadReadyRead() -{ - try { - writeData(findDownload(this->sender())); - } catch (const std::bad_alloc&) { - reportError(tr("Memory allocation error (in processing downloaded data).")); - } -} - -void DownloadManager::createMetaFile(DownloadInfo* info) -{ - // Avoid triggering refreshes from DirWatcher - ScopedDisableDirWatcher const scopedDirWatcher(this); - - QSettings metaFile(QString("%1.meta").arg(info->m_Output.fileName()), - QSettings::IniFormat); - metaFile.setValue("gameName", info->m_FileInfo->gameName); - metaFile.setValue("modID", info->m_FileInfo->modID); - metaFile.setValue("fileID", info->m_FileInfo->fileID); - metaFile.setValue("url", info->m_Urls.join(";")); - metaFile.setValue("name", info->m_FileInfo->name); - metaFile.setValue("description", info->m_FileInfo->description); - metaFile.setValue("modName", info->m_FileInfo->modName); - metaFile.setValue("version", info->m_FileInfo->version.canonicalString()); - metaFile.setValue("newestVersion", info->m_FileInfo->newestVersion.canonicalString()); - metaFile.setValue("fileTime", info->m_FileInfo->fileTime); - metaFile.setValue("fileCategory", info->m_FileInfo->fileCategory); - metaFile.setValue("category", info->m_FileInfo->categoryID); - metaFile.setValue("repository", info->m_FileInfo->repository); - metaFile.setValue("userData", info->m_FileInfo->userData); - metaFile.setValue("author", info->m_FileInfo->author); - metaFile.setValue("uploader", info->m_FileInfo->uploader); - metaFile.setValue("uploaderUrl", info->m_FileInfo->uploaderUrl); - metaFile.setValue("installed", info->m_State == DownloadManager::STATE_INSTALLED); - metaFile.setValue("uninstalled", info->m_State == DownloadManager::STATE_UNINSTALLED); - metaFile.setValue("paused", (info->m_State == DownloadManager::STATE_PAUSED) || - (info->m_State == DownloadManager::STATE_ERROR)); - metaFile.setValue("removed", info->m_Hidden); - - // slightly hackish... - for (int i = 0; i < m_ActiveDownloads.size(); ++i) { - if (m_ActiveDownloads[i] == info) { - emit update(i); - } - } -} - -void DownloadManager::nxmDescriptionAvailable(QString, int, QVariant userData, - QVariant resultData, int requestID) -{ - std::set::iterator const idIter = m_RequestIDs.find(requestID); - if (idIter == m_RequestIDs.end()) { - return; - } else { - m_RequestIDs.erase(idIter); - } - - QVariantMap result = resultData.toMap(); - - DownloadInfo* info = downloadInfoByID(userData.toInt()); - if (info == nullptr) - return; - info->m_FileInfo->categoryID = result["category_id"].toInt(); - info->m_FileInfo->author = result["author"].toString(); - info->m_FileInfo->uploader = result["uploaded_by"].toString(); - info->m_FileInfo->uploaderUrl = result["uploaded_users_profile_url"].toString(); - QTextDocument doc; - doc.setHtml(result["name"].toString().trimmed()); - info->m_FileInfo->modName = doc.toPlainText(); - if (info->m_FileInfo->fileID != 0) { - setState(info, STATE_READY); - } else { - setState(info, STATE_FETCHINGFILEINFO); - } -} - -void DownloadManager::nxmFilesAvailable(QString, int, QVariant userData, - QVariant resultData, int requestID) -{ - std::set::iterator const idIter = m_RequestIDs.find(requestID); - if (idIter == m_RequestIDs.end()) { - return; - } else { - m_RequestIDs.erase(idIter); - } - - DownloadInfo* info = downloadInfoByID(userData.toInt()); - if (info == nullptr) - return; - - QVariantMap result = resultData.toMap(); - QVariantList files = result["files"].toList(); - - // MO sometimes prepends _ to the filename in case of duplicate downloads. - // this may muck up the file name comparison - QString alternativeLocalName = info->m_FileName; - - QRegularExpression const expression("^\\d_(.*)$"); - auto match = expression.match(alternativeLocalName); - if (match.hasMatch()) { - alternativeLocalName = match.captured(1); - } - - bool found = false; - - for (const QVariant& file : files) { - QVariantMap fileInfo = file.toMap(); - QString const fileName = fileInfo["file_name"].toString(); - QString const fileNameVariant = fileName.mid(0).replace(' ', '_'); - if ((fileName == info->m_RemoteFileName) || - (fileNameVariant == info->m_RemoteFileName) || (fileName == info->m_FileName) || - (fileNameVariant == info->m_FileName) || (fileName == alternativeLocalName) || - (fileNameVariant == alternativeLocalName)) { - info->m_FileInfo->name = fileInfo["name"].toString(); - info->m_FileInfo->version.parse(fileInfo["version"].toString()); - if (!info->m_FileInfo->version.isValid()) { - info->m_FileInfo->version = info->m_FileInfo->newestVersion; - } - info->m_FileInfo->fileCategory = fileInfo["category_id"].toInt(); - info->m_FileInfo->fileTime = - QDateTime::fromMSecsSinceEpoch(fileInfo["uploaded_timestamp"].toLongLong()); - info->m_FileInfo->fileID = fileInfo["file_id"].toInt(); - info->m_FileInfo->fileName = fileInfo["file_name"].toString(); - info->m_FileInfo->description = - BBCode::convertToHTML(fileInfo["description"].toString()); - info->m_FileInfo->author = fileInfo["author"].toString(); - info->m_FileInfo->uploader = fileInfo["uploaded_by"].toString(); - info->m_FileInfo->uploaderUrl = fileInfo["uploaded_users_profile_url"].toString(); - found = true; - break; - } - } - - if (info->m_ReQueried) { - if (found) { - emit showMessage(tr("Information updated")); - } else if (result.count() == 0) { - emit showMessage(tr("No matching file found on Nexus! Maybe this file is no " - "longer available or it was renamed?")); - } else { - SelectionDialog selection(tr("No file on Nexus matches the selected file by " - "name. Please manually choose the correct one.")); - std::sort(files.begin(), files.end(), - [](const QVariant& lhs, const QVariant& rhs) { - return lhs.toMap()["uploaded_timestamp"].toInt() > - rhs.toMap()["uploaded_timestamp"].toInt(); - }); - for (const QVariant& file : files) { - QVariantMap fileInfo = file.toMap(); - if (fileInfo["category_id"].toInt() != NexusInterface::FileStatus::REMOVED && - fileInfo["category_id"].toInt() != NexusInterface::FileStatus::ARCHIVED) - selection.addChoice(fileInfo["file_name"].toString(), "", file); - } - if (selection.exec() == QDialog::Accepted) { - QVariantMap fileInfo = selection.getChoiceData().toMap(); - info->m_FileInfo->name = fileInfo["name"].toString(); - info->m_FileInfo->version.parse(fileInfo["version"].toString()); - info->m_FileInfo->fileCategory = fileInfo["category_id"].toInt(); - info->m_FileInfo->fileID = fileInfo["file_id"].toInt(); - } else { - emit showMessage(tr("No matching file found on Nexus! Maybe this file is no " - "longer available or it was renamed?")); - } - } - } else { - if (info->m_FileInfo->fileID == 0) { - log::warn("could not determine file id for {} (state {})", info->m_FileName, - info->m_State); - } - } - - setState(info, STATE_READY); -} - -void DownloadManager::nxmFileInfoAvailable(QString gameName, int modID, int fileID, - QVariant userData, QVariant resultData, - int requestID) -{ - std::set::iterator const idIter = m_RequestIDs.find(requestID); - if (idIter == m_RequestIDs.end()) { - return; - } else { - m_RequestIDs.erase(idIter); - } - - ModRepositoryFileInfo* info = - qobject_cast(qvariant_cast(userData)); - - QVariantMap result = resultData.toMap(); - info->name = result["name"].toString(); - info->version.parse(result["version"].toString()); - if (!info->version.isValid()) { - info->version = info->newestVersion; - } - info->fileName = result["file_name"].toString(); - info->fileCategory = result["category_id"].toInt(); - info->fileTime = - QDateTime::fromMSecsSinceEpoch(result["uploaded_timestamp"].toLongLong()); - info->description = BBCode::convertToHTML(result["description"].toString()); - - info->repository = "Nexus"; - - // Default to the raw game name; overridden below if a matching plugin is found. - // This preserves special domains like "site" that have no game plugin. - info->gameName = gameName; - QStringList games(m_ManagedGame->validShortNames()); - games += m_ManagedGame->gameShortName(); - for (const auto& game : games) { - MOBase::IPluginGame* gamePlugin = m_OrganizerCore->getGame(game); - if (gamePlugin != nullptr && - gamePlugin->gameNexusName().compare(gameName, Qt::CaseInsensitive) == 0) { - info->gameName = gamePlugin->gameShortName(); - break; - } - } - - info->modID = modID; - info->fileID = fileID; - - QObject* test = info; - m_RequestIDs.insert( - m_NexusInterface->requestDownloadURL(info->gameName, info->modID, info->fileID, - this, QVariant::fromValue(test), QString())); -} - -static int evaluateFileInfoMap(const QVariantMap& map, - const ServerList::container& preferredServers) -{ - int preference = 0; - bool found = false; - const auto name = map["short_name"].toString(); - - for (const auto& server : preferredServers) { - if (server.name() == name) { - preference = server.preferred(); - found = true; - break; - } - } - - if (!found) { - return 0; - } - - return 100 + preference * 20; -} - -// sort function to sort by best download server -// -bool ServerByPreference(const ServerList::container& preferredServers, - const QVariant& LHS, const QVariant& RHS) -{ - const auto a = evaluateFileInfoMap(LHS.toMap(), preferredServers); - const auto b = evaluateFileInfoMap(RHS.toMap(), preferredServers); - return (a > b); -} - -int DownloadManager::startDownloadURLs(const QStringList& urls) -{ - ModRepositoryFileInfo const info; - addDownload(urls, "", -1, -1, &info); - return m_ActiveDownloads.size() - 1; -} - -int DownloadManager::startDownloadNexusFile(const QString& gameName, int modID, - int fileID) -{ - int const newID = m_ActiveDownloads.size(); - addNXMDownload( - QString("nxm://%1/mods/%2/files/%3").arg(gameName).arg(modID).arg(fileID)); - return newID; -} - -QString DownloadManager::downloadPath(int id) const -{ - return getFilePath(id); -} - -boost::signals2::connection -DownloadManager::onDownloadComplete(const std::function& callback) -{ - return m_DownloadComplete.connect(callback); -} - -boost::signals2::connection -DownloadManager::onDownloadPaused(const std::function& callback) -{ - return m_DownloadPaused.connect(callback); -} - -boost::signals2::connection -DownloadManager::onDownloadFailed(const std::function& callback) -{ - return m_DownloadFailed.connect(callback); -} - -boost::signals2::connection -DownloadManager::onDownloadRemoved(const std::function& callback) -{ - return m_DownloadRemoved.connect(callback); -} - -int DownloadManager::indexByName(const QString& fileName) const -{ - for (int i = 0; i < m_ActiveDownloads.size(); ++i) { - if (m_ActiveDownloads[i]->m_FileName == fileName) { - return i; - } - } - return -1; -} - -int DownloadManager::indexByInfo(const DownloadInfo* info) const -{ - for (int i = 0; i < m_ActiveDownloads.size(); ++i) { - if (m_ActiveDownloads[i] == info) { - return i; - } - } - return -1; -} - -void DownloadManager::nxmDownloadURLsAvailable(QString gameName, int modID, int fileID, - QVariant userData, QVariant resultData, - int requestID) -{ - using namespace boost::placeholders; - - std::set::iterator const idIter = m_RequestIDs.find(requestID); - if (idIter == m_RequestIDs.end()) { - return; - } else { - m_RequestIDs.erase(idIter); - } - - ModRepositoryFileInfo* info = - qobject_cast(qvariant_cast(userData)); - QVariantList resultList = resultData.toList(); - if (resultList.empty()) { - removePending(gameName, modID, fileID); - emit showMessage(tr("No download server available. Please try again later.")); - return; - } - - const auto servers = m_OrganizerCore->settings().network().servers(); - - std::sort(resultList.begin(), resultList.end(), - boost::bind(&ServerByPreference, servers.getPreferred(), _1, _2)); - - info->userData["downloadMap"] = resultList; - - QStringList URLs; - - foreach (const QVariant& server, resultList) { - URLs.append(server.toMap()["URI"].toString()); - } - addDownload(URLs, gameName, modID, fileID, info); -} - -void DownloadManager::nxmFileInfoFromMd5Available(QString gameName, QVariant userData, - QVariant resultData, int requestID) -{ - std::set::iterator const idIter = m_RequestIDs.find(requestID); - if (idIter == m_RequestIDs.end()) { - return; - } else { - m_RequestIDs.erase(idIter); - } - - DownloadInfo* info = downloadInfoByID(userData.toInt()); - - // This can come back with multiple results with the same file was uploaded multiple - // times (for whatever reason) - auto resultlist = resultData.toList(); - int chosenIdx = resultlist.count() > 1 ? -1 : 0; - - // Look for the exact file name - if (chosenIdx < 0) { - for (int i = 0; i < resultlist.count(); i++) { - auto results = resultlist[i].toMap(); - auto fileDetails = results["file_details"].toMap(); - - if (fileDetails["file_name"].toString().compare(info->m_FileName, - Qt::CaseInsensitive) == 0) { - if (chosenIdx < 0) { - chosenIdx = i; // intentional to not break in order to check other results - } else { - log::debug("Multiple files with same name found during MD5 search."); - chosenIdx = -1; - break; - } - } - } - } - - // Look for the only active one - if (chosenIdx < 0) { - for (int i = 0; i < resultlist.count(); i++) { - auto results = resultlist[i].toMap(); - auto fileDetails = results["file_details"].toMap(); - - if (fileDetails["category_id"].toInt() != NexusInterface::FileStatus::REMOVED && - fileDetails["category_id"].toInt() != NexusInterface::FileStatus::ARCHIVED) { - if (chosenIdx < 0) { - chosenIdx = i; // intentional to not break in order to check other results - } else { - log::debug("Multiple active files found during MD5 search."); - chosenIdx = -1; - break; - } - } - } - } - - // Unable to determine the correct mod / file. Revert to the old method - if (chosenIdx < 0) { - // don't use the normal state set function as we don't want to create a meta file - info->m_State = DownloadManager::STATE_READY; - queryInfo(m_ActiveDownloads.indexOf(info)); - return; - } - - auto results = resultlist[chosenIdx].toMap(); - auto fileDetails = results["file_details"].toMap(); - auto modDetails = results["mod"].toMap(); - - info->m_FileInfo->name = fileDetails["name"].toString(); - info->m_FileInfo->fileID = fileDetails["file_id"].toInt(); - info->m_FileInfo->description = - BBCode::convertToHTML(fileDetails["description"].toString()); - info->m_FileInfo->version.parse(fileDetails["version"].toString()); - if (!info->m_FileInfo->version.isValid()) - info->m_FileInfo->version.parse(fileDetails["mod_version"].toString()); - info->m_FileInfo->fileCategory = fileDetails["category_id"].toInt(); - - info->m_FileInfo->modID = modDetails["mod_id"].toInt(); - info->m_FileInfo->modName = modDetails["name"].toString(); - info->m_FileInfo->categoryID = modDetails["category_id"].toInt(); - info->m_FileInfo->author = modDetails["author"].toString(); - info->m_FileInfo->uploader = modDetails["uploaded_by"].toString(); - info->m_FileInfo->uploaderUrl = modDetails["uploaded_users_profile_url"].toString(); - - QString gameShortName = gameName; - QStringList games(m_ManagedGame->validShortNames()); - games += m_ManagedGame->gameShortName(); - for (const auto& game : games) { - MOBase::IPluginGame* gamePlugin = m_OrganizerCore->getGame(game); - if (gamePlugin != nullptr && - gamePlugin->gameNexusName().compare(gameName, Qt::CaseInsensitive) == 0) { - gameShortName = gamePlugin->gameShortName(); - break; - } - } - - info->m_FileInfo->gameName = gameShortName; - - // If the file description is not present, send another query to get it - if (!fileDetails["description"].isValid()) { - info->m_RemoteFileName = fileDetails["file_name"].toString(); - setState(info, STATE_FETCHINGFILEINFO); - } else { - setState(info, STATE_READY); - } -} - -void DownloadManager::nxmRequestFailed(QString gameName, int modID, int fileID, - QVariant userData, int requestID, int errorCode, - const QString& errorString) -{ - std::set::iterator const idIter = m_RequestIDs.find(requestID); - if (idIter == m_RequestIDs.end()) { - return; - } else { - m_RequestIDs.erase(idIter); - } - - DownloadInfo* userDataInfo = downloadInfoByID(userData.toInt()); - - int index = 0; - - for (QVector::iterator iter = m_ActiveDownloads.begin(); - iter != m_ActiveDownloads.end(); ++iter, ++index) { - DownloadInfo* info = *iter; - if (info != userDataInfo) - continue; - - // MD5 searches continue until all possible games are done - if (info->m_State == STATE_FETCHINGMODINFO_MD5) { - if (info->m_GamesToQuery.count() >= 2) { - info->m_GamesToQuery.pop_front(); - setState(info, STATE_FETCHINGMODINFO_MD5); - return; - } else if (errorCode == 404 && !info->m_AskIfNotFound) { - // prevent re-querying (only possible with repository = "Nexus") - info->m_FileInfo->repository = ""; - setState(info, STATE_READY); - return; - } else { - info->m_State = STATE_READY; - queryInfo(index); - emit update(index); - return; - } - } - - if (info->m_FileInfo->modID == modID) { - if (info->m_State < STATE_FETCHINGMODINFO) { - m_ActiveDownloads.erase(iter); - delete info; - } else { - setState(info, STATE_READY); - } - emit update(index); - break; - } - } - - removePending(gameName, modID, fileID); - emit showMessage(tr("Failed to request file info from nexus: %1").arg(errorString)); -} - -void DownloadManager::downloadFinished(int index) -{ - DownloadInfo* info; - if (index > 0) - info = m_ActiveDownloads[index]; - else { - info = findDownload(this->sender(), &index); - if (info == nullptr && index == 0) { - info = m_ActiveDownloads[index]; - } - } - - if (info != nullptr) { - QNetworkReply* reply = info->m_Reply; - QByteArray data; - if (reply->isOpen() && info->m_HasData) { - data = reply->readAll(); - info->m_Output.write(data); - } - info->m_Output.close(); - TaskProgressManager::instance().forgetMe(info->m_TaskProgressId); - - bool error = false; - if ((info->m_State != STATE_CANCELING) && (info->m_State != STATE_PAUSING)) { - bool const textData = reply->header(QNetworkRequest::ContentTypeHeader) - .toString() - .startsWith("text", Qt::CaseInsensitive); - if (textData) - emit showMessage( - tr("Warning: Content type is: %1") - .arg(reply->header(QNetworkRequest::ContentTypeHeader).toString())); - if ((info->m_Output.size() == 0) || - ((reply->error() != QNetworkReply::NoError) && - (reply->error() != QNetworkReply::OperationCanceledError))) { - if (reply->error() == QNetworkReply::UnknownContentError) - emit showMessage( - tr("Download header content length: %1 downloaded file size: %2") - .arg(reply->header(QNetworkRequest::ContentLengthHeader).toLongLong()) - .arg(info->m_Output.size())); - if (info->m_Tries == 0) { - emit showMessage(tr("Download failed: %1 (%2)") - .arg(reply->errorString()) - .arg(reply->error())); - } - error = true; - setState(info, STATE_ERROR); - } - } - - if (info->m_State == STATE_CANCELING) { - setState(info, STATE_CANCELED); - } else if (info->m_State == STATE_PAUSING) { - if (info->m_Output.isOpen() && info->m_HasData) { - info->m_Output.write(info->m_Reply->readAll()); - } - setState(info, STATE_PAUSED); - } - - if (info->m_State == STATE_CANCELED || (info->m_Tries == 0 && error)) { - emit aboutToUpdate(); - info->m_Output.remove(); - delete info; - m_ActiveDownloads.erase(m_ActiveDownloads.begin() + index); - if (error) - emit showMessage( - tr("We were unable to download the file due to errors after four retries. " - "There may be an issue with the Nexus servers.")); - emit update(-1); - } else if (info->isPausedState() || info->m_State == STATE_PAUSING) { - info->m_Output.close(); - createMetaFile(info); - emit update(index); - } else { - QString const url = info->m_Urls[info->m_CurrentUrl]; - if (info->m_FileInfo->userData.contains("downloadMap")) { - foreach (const QVariant& server, - info->m_FileInfo->userData["downloadMap"].toList()) { - QVariantMap serverMap = server.toMap(); - if (serverMap["URI"].toString() == url) { - int const deltaTime = info->m_StartTime.elapsed() / 1000; - if (deltaTime > 5) { - emit downloadSpeed(serverMap["short_name"].toString(), - (info->m_TotalSize - info->m_PreResumeSize) / - deltaTime); - } // no division by zero please! Also, if the download is shorter than a - // few seconds, the result is way to inprecise - break; - } - } - } - - bool const isNexus = info->m_FileInfo->repository == "Nexus"; - // need to change state before changing the file name, otherwise .unfinished is - // appended - if (isNexus) { - setState(info, STATE_FETCHINGMODINFO); - } else { - setState(info, STATE_NOFETCH); - } - - QString const newName = getFileNameFromNetworkReply(reply); - QString const oldName = QFileInfo(info->m_Output).fileName(); - - startDisableDirWatcher(); - // Rename to Content-Disposition if either we have no name yet, or the - // name we seeded from the API looks like a CDN object key. - if (!newName.isEmpty() && - (oldName.isEmpty() || looksLikeCdnObjectKey(info->m_FileName))) { - info->setName(getDownloadFileName(newName), true); - } else { - info->setName(m_OutputDirectory + "/" + info->m_FileName, - true); // don't rename but remove the ".unfinished" extension - } - endDisableDirWatcher(); - - if (!isNexus) { - setState(info, STATE_READY); - } - - emit update(index); - } - reply->close(); - reply->deleteLater(); - - if ((info->m_Tries > 0) && error) { - --info->m_Tries; - resumeDownloadInt(index); - } - } else { - log::warn("no download index {}", index); - } -} - -void DownloadManager::downloadError(QNetworkReply::NetworkError error) -{ - if (error != QNetworkReply::OperationCanceledError) { - QNetworkReply* reply = qobject_cast(sender()); - log::warn("{} ({})", - reply != nullptr ? reply->errorString() : "Download error occured", - error); - } -} - -void DownloadManager::metaDataChanged() -{ - int index = 0; - - DownloadInfo* info = findDownload(this->sender(), &index); - if (info != nullptr) { - const QString newName = getFileNameFromNetworkReply(info->m_Reply); - // Prefer Content-Disposition over whatever we seeded from the API when - // the seeded name looks like a CDN object key. Otherwise only take it - // if we have no name at all yet. - const bool shouldReplace = - !newName.isEmpty() && - (info->m_FileName.isEmpty() || looksLikeCdnObjectKey(info->m_FileName)); - - if (shouldReplace) { - log::info("metaDataChanged: replacing '{}' with Content-Disposition '{}'", - info->m_FileName, newName); - startDisableDirWatcher(); - info->setName(getDownloadFileName(newName), true); - endDisableDirWatcher(); - refreshAlphabeticalTranslation(); - if (!info->m_Output.isOpen() && - !info->m_Output.open(QIODevice::WriteOnly | QIODevice::Append)) { - reportError(tr("failed to re-open %1").arg(info->m_FileName)); - setState(info, STATE_CANCELING); - } - } - } else { - log::warn("meta data event for unknown download"); - } -} - -void DownloadManager::directoryChanged(const QString&) -{ - if (DownloadManager::m_DirWatcherDisabler == 0) - refreshList(); -} - -void DownloadManager::managedGameChanged(MOBase::IPluginGame const* managedGame) -{ - m_ManagedGame = managedGame; -} - -void DownloadManager::checkDownloadTimeout() -{ - for (int i = 0; i < m_ActiveDownloads.size(); ++i) { - DownloadInfo* info = m_ActiveDownloads[i]; - if (info->m_State != STATE_DOWNLOADING || info->m_Reply == nullptr || - !info->m_Reply->isOpen()) { - continue; - } - - const bool stalled = - (info->m_StartTime.elapsed() - info->m_DownloadTimeLast) > (5 * 1000); - if (!stalled) { - continue; - } - - pauseDownload(i); - downloadFinished(i); - - // downloadFinished() can remove the download entry, so find it again. - const int index = indexByInfo(info); - if (index < 0) { - continue; - } - - if (info->m_Tries <= 0) { - emit showMessage(tr("Download stalled repeatedly and was paused. " - "Please retry manually.")); - continue; - } - - --info->m_Tries; - log::warn("download '{}' stalled, retrying ({} retries left)", info->m_FileName, - info->m_Tries); - resumeDownloadInt(index); - emit update(index); - } -} - -void DownloadManager::writeData(DownloadInfo* info) -{ - if (info != nullptr) { - qint64 ret = info->m_Output.write(info->m_Reply->readAll()); - if (ret < info->m_Reply->size()) { - QString const fileName = - info->m_FileName; // m_FileName may be destroyed after setState - setState(info, DownloadState::STATE_CANCELED); - - log::error("Unable to write download \"{}\" to drive (return {})", - info->m_FileName, ret); - - reportError(tr("Unable to write download to drive (return %1).\n" - "Check the drive's available storage.\n\n" - "Canceling download \"%2\"...") - .arg(ret) - .arg(fileName)); - } - } -} +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "downloadmanager.h" + +#include "bbcode.h" +#include "envfs.h" +#include "filesystemutilities.h" +#include "iplugingame.h" +#include "nexusinterface.h" +#include "nxmaccessmanager.h" +#include "nxmurl.h" +#include "organizercore.h" +#include "selectiondialog.h" +#include "shared/util.h" +#include "utility.h" +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +using namespace MOBase; + +// TODO limit number of downloads, also display download during nxm requests, store +// modid/fileid with downloads + +static const char UNFINISHED[] = ".unfinished"; + +// Heuristic: does this filename look like a CDN object key that should be +// replaced by the server's Content-Disposition filename if available? +// Nexus v2 file metadata sometimes returns `file_name` as a CDN-path UUID +// (e.g. "91/83/bb/9183bbff-...") rather than the actual archive name. +static bool looksLikeCdnObjectKey(const QString& name) +{ + if (name.isEmpty()) { + return true; + } + // Real archive filenames always end in an archive suffix. If the name + // has no recognized archive extension, assume it's a CDN key. + static const QStringList kArchiveSuffixes = { + ".zip", ".rar", ".7z", ".tar", ".gz", ".bz2", + ".xz", ".exe", ".msi", ".bsa", ".ba2"}; + for (const auto& suf : kArchiveSuffixes) { + if (name.endsWith(suf, Qt::CaseInsensitive)) { + return false; + } + } + return true; +} + +unsigned int DownloadManager::DownloadInfo::s_NextDownloadID = 1U; +int DownloadManager::m_DirWatcherDisabler = 0; + +DownloadManager::DownloadInfo* +DownloadManager::DownloadInfo::createNew(const ModRepositoryFileInfo* fileInfo, + const QStringList& URLs) +{ + DownloadInfo* info = new DownloadInfo; + info->m_DownloadID = s_NextDownloadID++; + info->m_StartTime.start(); + info->m_PreResumeSize = 0LL; + info->m_Progress = std::make_pair(0, "0.0 B/s "); + info->m_ResumePos = 0; + info->m_FileInfo = new ModRepositoryFileInfo(*fileInfo); + info->m_Urls = URLs; + info->m_CurrentUrl = 0; + info->m_Tries = AUTOMATIC_RETRIES; + info->m_State = STATE_STARTED; + info->m_TaskProgressId = TaskProgressManager::instance().getId(); + info->m_Reply = nullptr; + + return info; +} + +DownloadManager::DownloadInfo* +DownloadManager::DownloadInfo::createFromMeta(const QString& filePath, bool showHidden, + const QString outputDirectory, + std::optional fileSize) +{ + DownloadInfo* info = new DownloadInfo; + + QString const metaFileName = filePath + ".meta"; + QFileInfo const metaFileInfo(metaFileName); + if (QDir::fromNativeSeparators(metaFileInfo.path()) + .compare(QDir::fromNativeSeparators(outputDirectory), Qt::CaseInsensitive) != + 0) + return nullptr; + QSettings const metaFile(metaFileName, QSettings::IniFormat); + if (!showHidden && metaFile.value("removed", false).toBool()) { + return nullptr; + } else { + info->m_Hidden = metaFile.value("removed", false).toBool(); + } + + QString const fileName = QFileInfo(filePath).fileName(); + + if (fileName.endsWith(UNFINISHED)) { + info->m_FileName = + fileName.mid(0, fileName.length() - static_cast(strlen(UNFINISHED))); + info->m_State = STATE_PAUSED; + } else { + info->m_FileName = fileName; + + if (metaFile.value("paused", false).toBool()) { + info->m_State = STATE_PAUSED; + } else if (metaFile.value("uninstalled", false).toBool()) { + info->m_State = STATE_UNINSTALLED; + } else if (metaFile.value("installed", false).toBool()) { + info->m_State = STATE_INSTALLED; + } else { + info->m_State = STATE_READY; + } + } + + info->m_DownloadID = s_NextDownloadID++; + info->m_Output.setFileName(filePath); + info->m_TotalSize = fileSize ? *fileSize : QFileInfo(filePath).size(); + info->m_PreResumeSize = info->m_TotalSize; + info->m_CurrentUrl = 0; + info->m_Urls = metaFile.value("url", "").toString().split(";"); + info->m_Tries = 0; + info->m_TaskProgressId = TaskProgressManager::instance().getId(); + QString const gameName = metaFile.value("gameName", "").toString(); + int const modID = metaFile.value("modID", 0).toInt(); + int const fileID = metaFile.value("fileID", 0).toInt(); + info->m_FileInfo = new ModRepositoryFileInfo(gameName, modID, fileID); + info->m_FileInfo->name = metaFile.value("name", "").toString(); + if (info->m_FileInfo->name == "0") { + // bug in earlier version + info->m_FileInfo->name = ""; + } + info->m_FileInfo->modName = metaFile.value("modName", "").toString(); + info->m_FileInfo->gameName = gameName; + info->m_FileInfo->modID = modID; + info->m_FileInfo->fileID = fileID; + info->m_FileInfo->description = metaFile.value("description").toString(); + info->m_FileInfo->version.parse(metaFile.value("version", "0").toString()); + info->m_FileInfo->newestVersion.parse( + metaFile.value("newestVersion", "0").toString()); + info->m_FileInfo->categoryID = metaFile.value("category", 0).toInt(); + info->m_FileInfo->fileCategory = metaFile.value("fileCategory", 0).toInt(); + info->m_FileInfo->repository = metaFile.value("repository", "Nexus").toString(); + info->m_FileInfo->userData = metaFile.value("userData").toMap(); + info->m_FileInfo->author = metaFile.value("author", "").toString(); + info->m_FileInfo->uploader = metaFile.value("uploader", "").toString(); + info->m_FileInfo->uploaderUrl = metaFile.value("uploaderUrl", "").toString(); + info->m_Reply = nullptr; + + return info; +} + +ScopedDisableDirWatcher::ScopedDisableDirWatcher(DownloadManager* downloadManager) +{ + m_downloadManager = downloadManager; + DownloadManager::startDisableDirWatcher(); + log::debug("Scoped Disable DirWatcher: Started"); +} + +ScopedDisableDirWatcher::~ScopedDisableDirWatcher() +{ + DownloadManager::endDisableDirWatcher(); + m_downloadManager = nullptr; + log::debug("Scoped Disable DirWatcher: Stopped"); +} + +void DownloadManager::startDisableDirWatcher() +{ + DownloadManager::m_DirWatcherDisabler++; +} + +void DownloadManager::endDisableDirWatcher() +{ + if (DownloadManager::m_DirWatcherDisabler > 0) { + if (DownloadManager::m_DirWatcherDisabler == 1) + QCoreApplication::processEvents(); + DownloadManager::m_DirWatcherDisabler--; + } else { + DownloadManager::m_DirWatcherDisabler = 0; + } +} + +void DownloadManager::DownloadInfo::setName(QString newName, bool renameFile) +{ + QString oldMetaFileName = QString("%1.meta").arg(m_FileName); + m_FileName = QFileInfo(newName).fileName(); + if ((m_State == DownloadManager::STATE_STARTED) || + (m_State == DownloadManager::STATE_DOWNLOADING) || + (m_State == DownloadManager::STATE_PAUSED)) { + newName.append(UNFINISHED); + oldMetaFileName = QString("%1%2.meta").arg(m_FileName).arg(UNFINISHED); + } + if (renameFile) { + if ((newName != m_Output.fileName()) && !m_Output.rename(newName)) { + reportError(tr(R"(failed to rename "%1" to "%2")") + .arg(m_Output.fileName()) + .arg(newName)); + return; + } + + QFile metaFile(QFileInfo(newName).path() + "/" + oldMetaFileName); + if (metaFile.exists()) + metaFile.rename(newName.mid(0).append(".meta")); + } + if (!m_Output.isOpen()) { + // can't set file name if it's open + m_Output.setFileName(newName); + } +} + +bool DownloadManager::DownloadInfo::isPausedState() const +{ + return m_State == STATE_PAUSED || m_State == STATE_ERROR; +} + +QString DownloadManager::DownloadInfo::currentURL() +{ + return m_Urls[m_CurrentUrl]; +} + +DownloadManager::DownloadManager(NexusInterface* nexusInterface, QObject* parent) + : m_NexusInterface(nexusInterface) + +{ + m_OrganizerCore = dynamic_cast(parent); + connect(&m_DirWatcher, SIGNAL(directoryChanged(QString)), this, + SLOT(directoryChanged(QString))); + m_TimeoutTimer.setSingleShot(false); + connect(&m_TimeoutTimer, &QTimer::timeout, this, + &DownloadManager::checkDownloadTimeout); + m_TimeoutTimer.start(5 * 1000); +} + +DownloadManager::~DownloadManager() +{ + for (QVector::iterator iter = m_ActiveDownloads.begin(); + iter != m_ActiveDownloads.end(); ++iter) { + delete *iter; + } + m_ActiveDownloads.clear(); +} + +void DownloadManager::setParentWidget(QWidget* w) +{ + m_ParentWidget = w; +} + +bool DownloadManager::downloadsInProgress() +{ + for (QVector::iterator iter = m_ActiveDownloads.begin(); + iter != m_ActiveDownloads.end(); ++iter) { + if ((*iter)->m_State < STATE_READY) { + return true; + } + } + return false; +} + +bool DownloadManager::downloadsInProgressNoPause() +{ + for (QVector::iterator iter = m_ActiveDownloads.begin(); + iter != m_ActiveDownloads.end(); ++iter) { + if ((*iter)->m_State < STATE_READY && (*iter)->m_State != STATE_PAUSED) { + return true; + } + } + return false; +} + +void DownloadManager::pauseAll() +{ + + // first loop: pause all downloads + for (int i = 0; i < m_ActiveDownloads.count(); ++i) { + if (m_ActiveDownloads[i]->m_State < STATE_READY) { + pauseDownload(i); + } + } + + QThread::msleep(100); + + bool done = false; + QTime const startTime = QTime::currentTime(); + // further loops: busy waiting for all downloads to complete. This could be neater... + while (!done && (startTime.secsTo(QTime::currentTime()) < 5)) { + QCoreApplication::processEvents(); + done = true; + foreach (DownloadInfo* info, m_ActiveDownloads) { + if ((info->m_State < STATE_CANCELED) || + (info->m_State == STATE_FETCHINGFILEINFO) || + (info->m_State == STATE_FETCHINGMODINFO) || + (info->m_State == STATE_FETCHINGMODINFO_MD5)) { + done = false; + break; + } + } + if (!done) { + QThread::msleep(100); + } + } +} + +void DownloadManager::setOutputDirectory(const QString& outputDirectory, + const bool refresh) +{ + QStringList const directories = m_DirWatcher.directories(); + if (!directories.empty()) { + m_DirWatcher.removePaths(directories); + } + m_OutputDirectory = QDir::fromNativeSeparators(outputDirectory); + if (refresh) { + refreshList(); + } + m_DirWatcher.addPath(m_OutputDirectory); +} + +void DownloadManager::setShowHidden(bool showHidden) +{ + m_ShowHidden = showHidden; + refreshList(); +} + +void DownloadManager::setPluginContainer(PluginContainer* pluginContainer) +{ + m_NexusInterface->setPluginContainer(pluginContainer); +} + +void DownloadManager::refreshList() +{ + TimeThis const tt("DownloadManager::refreshList()"); + + try { + emit aboutToUpdate(); + // avoid triggering other refreshes + ScopedDisableDirWatcher const scopedDirWatcher(this); + + int const downloadsBefore = m_ActiveDownloads.size(); + + // remove finished downloads + for (QVector::iterator iter = m_ActiveDownloads.begin(); + iter != m_ActiveDownloads.end();) { + if (((*iter)->m_State == STATE_READY) || ((*iter)->m_State == STATE_INSTALLED) || + ((*iter)->m_State == STATE_UNINSTALLED)) { + delete *iter; + iter = m_ActiveDownloads.erase(iter); + } else { + ++iter; + } + } + + const QStringList supportedExtensions = + m_OrganizerCore->installationManager()->getSupportedExtensions(); + std::vector nameFilters; + for (const auto& extension : supportedExtensions) { + nameFilters.push_back(L"." + extension.toLower().toStdWString()); + } + + nameFilters.push_back(QString(UNFINISHED).toLower().toStdWString()); + + QDir const dir(QDir::fromNativeSeparators(m_OutputDirectory)); + + // find orphaned meta files and delete them (sounds cruel but it's better for + // everyone) + QStringList orphans; + QStringList const metaFiles = dir.entryList(QStringList() << "*.meta"); + foreach (const QString& metaFile, metaFiles) { + QString const baseFile = metaFile.left(metaFile.length() - 5); + if (!QFile::exists(dir.absoluteFilePath(baseFile))) { + orphans.append(dir.absoluteFilePath(metaFile)); + } + } + if (!orphans.empty()) { + log::debug("{} orphaned meta files will be deleted", orphans.size()); + shellDelete(orphans, true); + } + + std::set seen; + + struct Context + { + DownloadManager& self; + std::set& seen; + std::vector& extensions; + }; + + Context cx = {.self=*this, .seen=seen, .extensions=nameFilters}; + + for (auto&& d : m_ActiveDownloads) { + cx.seen.insert(d->m_FileName.toLower().toStdWString()); + cx.seen.insert( + QFileInfo(d->m_Output.fileName()).fileName().toLower().toStdWString()); + } + + env::forEachEntry( + QDir::toNativeSeparators(m_OutputDirectory).toStdWString(), &cx, nullptr, + nullptr, [](void* data, std::wstring_view f, FILETIME, uint64_t size) { + auto& cx = *static_cast(data); + + std::wstring lc = MOShared::ToLowerCopy(f); + + bool interestingExt = false; + for (auto&& ext : cx.extensions) { + if (lc.ends_with(ext)) { + interestingExt = true; + break; + } + } + + if (!interestingExt) { + return; + } + + if (cx.seen.contains(lc)) { + return; + } + + QString const fileName = QDir::fromNativeSeparators(cx.self.m_OutputDirectory) + + "/" + QString::fromWCharArray(f.data(), f.size()); + + DownloadInfo* info = DownloadInfo::createFromMeta( + fileName, cx.self.m_ShowHidden, cx.self.m_OutputDirectory, size); + + if (info == nullptr) { + return; + } + + cx.self.m_ActiveDownloads.push_front(info); + cx.seen.insert(std::move(lc)); + cx.seen.insert( + QFileInfo(info->m_Output.fileName()).fileName().toLower().toStdWString()); + }); + + log::debug("saw {} downloads", m_ActiveDownloads.size()); + + emit update(-1); + + } catch (const std::bad_alloc&) { + reportError(tr("Memory allocation error (in refreshing directory).")); + } +} + +void DownloadManager::queryDownloadListInfo() +{ + int incompleteCount = 0; + for (size_t i = 0; std::cmp_less(i , m_ActiveDownloads.size()); i++) { + if (isInfoIncomplete(i)) { + incompleteCount++; + } + } + + if (incompleteCount <= 5 || + QMessageBox::question( + m_ParentWidget, tr("Query Metadata"), + tr("There are %1 downloads with incomplete metadata.\n\n" + "Do you want to fetch all incomplete metadata?\n" + "API requests will be consumed, and Mod Organizer may stutter.") + .arg(incompleteCount), + QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { + TimeThis const tt("DownloadManager::queryDownloadListInfo()"); + log::info("Querying metadata for every download with incomplete info..."); + startDisableDirWatcher(); + for (size_t i = 0; std::cmp_less(i , m_ActiveDownloads.size()); i++) { + if (isInfoIncomplete(i)) { + queryInfoMd5(i, false); + } + } + endDisableDirWatcher(); + log::info("Metadata has been retrieved successfully!"); + } +} + +bool DownloadManager::addDownload(const QStringList& URLs, QString gameName, int modID, + int fileID, const ModRepositoryFileInfo* fileInfo) +{ + // Parse the URL properly instead of feeding it to QFileInfo — QFileInfo + // doesn't understand URLs, so its fileName() can pull in query-string junk + // on Linux and we end up saving downloads as UUIDs from the S3 object key. + const QUrl parsedUrl = QUrl::fromEncoded(URLs.first().toLocal8Bit()); + + QString fileName; + + // Nexus S3-signed download URLs carry the desired filename in the + // `response-content-disposition` query parameter, e.g. + // response-content-disposition=attachment;filename="JohnnyGuitar NVSE - 5.06.7z" + // (percent-encoded on the wire). QUrlQuery decodes values for us. This + // must be checked first — the URL path itself is just the CDN object key, + // which looks like "9183bb...b-fdcf" and is useless as a filename. + const QUrlQuery query(parsedUrl); + const QString kDispoKey = QStringLiteral("response-content-disposition"); + if (query.hasQueryItem(kDispoKey)) { + const QString disposition = + query.queryItemValue(kDispoKey, QUrl::FullyDecoded); + // RFC 6266: filename="quoted" or filename=unquoted-token or filename*=ext-value. + static const QRegularExpression rxQuoted( + QStringLiteral("filename=\"([^\"]+)\"")); + static const QRegularExpression rxUnquoted( + QStringLiteral("filename=([^;\\s]+)")); + static const QRegularExpression rxExtValue( + QStringLiteral("filename\\*=[^']*'[^']*'([^;\\s]+)")); + QRegularExpressionMatch m = rxQuoted.match(disposition); + if (!m.hasMatch()) m = rxExtValue.match(disposition); + if (!m.hasMatch()) m = rxUnquoted.match(disposition); + if (m.hasMatch()) { + fileName = QUrl::fromPercentEncoding(m.captured(1).toUtf8()); + } + } + + // Fall back to the last path segment of the URL if the disposition trick + // didn't produce anything. + if (fileName.isEmpty()) { + fileName = parsedUrl.fileName(); + if (!fileName.isEmpty()) { + fileName = QUrl::fromPercentEncoding(fileName.toUtf8()); + } + } + + if (fileName.isEmpty()) { + fileName = "unknown"; + } + + QUrl const preferredUrl = QUrl::fromEncoded(URLs.first().toLocal8Bit()); + log::debug("selected download url: {}", preferredUrl.toString()); + QHttp2Configuration h2Conf; + h2Conf.setSessionReceiveWindowSize( + 16777215); // 16 MiB, based on Chrome and Firefox values + h2Conf.setStreamReceiveWindowSize(16777215); + QNetworkRequest request(preferredUrl); + request.setHeader(QNetworkRequest::UserAgentHeader, + m_NexusInterface->getAccessManager()->userAgent()); + request.setAttribute(QNetworkRequest::CacheSaveControlAttribute, false); + request.setAttribute(QNetworkRequest::CacheLoadControlAttribute, + QNetworkRequest::AlwaysNetwork); + request.setHttp2Configuration(h2Conf); + return addDownload(m_NexusInterface->getAccessManager()->get(request), URLs, fileName, + gameName, modID, fileID, fileInfo); +} + +bool DownloadManager::addDownload(QNetworkReply* reply, + const ModRepositoryFileInfo* fileInfo) +{ + QString fileName = getFileNameFromNetworkReply(reply); + if (fileName.isEmpty()) { + fileName = "unknown"; + } + + return addDownload(reply, QStringList(reply->url().toString()), fileName, + fileInfo->gameName, fileInfo->modID, fileInfo->fileID, fileInfo); +} + +bool DownloadManager::addDownload(QNetworkReply* reply, const QStringList& URLs, + const QString& fileName, QString gameName, int modID, + int fileID, const ModRepositoryFileInfo* fileInfo) +{ + // download invoked from an already open network reply (i.e. download link in the + // browser) + DownloadInfo* newDownload = DownloadInfo::createNew(fileInfo, URLs); + + QString baseName = fileName; + if (!fileInfo->fileName.isEmpty()) { + baseName = fileInfo->fileName; + } else { + QString const dispoName = getFileNameFromNetworkReply(reply); + + if (!dispoName.isEmpty()) { + baseName = dispoName; + } + } + + // baseName could be a URL at this point so strip out the URL query + int const queryIndex = baseName.indexOf("?"); + if (queryIndex >= 0) { + baseName.truncate(queryIndex); + } + + startDisableDirWatcher(); + newDownload->setName(getDownloadFileName(baseName), false); + endDisableDirWatcher(); + + startDownload(reply, newDownload, false); + // emit update(-1); + return true; +} + +void DownloadManager::removePending(QString gameName, int modID, int fileID) +{ + QString gameShortName = gameName; + QStringList games(m_ManagedGame->validShortNames()); + games += m_ManagedGame->gameShortName(); + for (const auto& game : games) { + MOBase::IPluginGame* gamePlugin = m_OrganizerCore->getGame(game); + if (gamePlugin != nullptr && + gamePlugin->gameNexusName().compare(gameName, Qt::CaseInsensitive) == 0) { + gameShortName = gamePlugin->gameShortName(); + break; + } + } + emit aboutToUpdate(); + for (auto iter : m_PendingDownloads) { + if (gameShortName.compare(std::get<0>(iter), Qt::CaseInsensitive) == 0 && + (std::get<1>(iter) == modID) && (std::get<2>(iter) == fileID)) { + m_PendingDownloads.removeAt(m_PendingDownloads.indexOf(iter)); + break; + } + } + emit update(-1); +} + +void DownloadManager::startDownload(QNetworkReply* reply, DownloadInfo* newDownload, + bool resume) +{ + reply->setReadBufferSize( + 1024 * 1024); // don't read more than 1MB at once to avoid memory troubles + newDownload->m_Reply = reply; + setState(newDownload, STATE_DOWNLOADING); + if (newDownload->m_Urls.count() == 0) { + newDownload->m_Urls = QStringList(reply->url().toString()); + } + + QIODevice::OpenMode mode = QIODevice::WriteOnly; + if (resume) { + mode |= QIODevice::Append; + } + + newDownload->m_StartTime.start(); + createMetaFile(newDownload); + + if (!newDownload->m_Output.open(mode)) { + reportError(tr("failed to download %1: could not open output file: %2") + .arg(reply->url().toString()) + .arg(newDownload->m_Output.fileName())); + return; + } + + connect(newDownload->m_Reply, SIGNAL(downloadProgress(qint64, qint64)), this, + SLOT(downloadProgress(qint64, qint64))); + connect(newDownload->m_Reply, SIGNAL(errorOccurred(QNetworkReply::NetworkError)), + this, SLOT(downloadError(QNetworkReply::NetworkError))); + connect(newDownload->m_Reply, SIGNAL(readyRead()), this, SLOT(downloadReadyRead())); + connect(newDownload->m_Reply, SIGNAL(metaDataChanged()), this, + SLOT(metaDataChanged())); + + if (!resume) { + newDownload->m_PreResumeSize = newDownload->m_Output.size(); + removePending(newDownload->m_FileInfo->gameName, newDownload->m_FileInfo->modID, + newDownload->m_FileInfo->fileID); + + emit aboutToUpdate(); + m_ActiveDownloads.append(newDownload); + + emit update(-1); + emit downloadAdded(); + + if (QFile::exists(m_OutputDirectory + "/" + newDownload->m_FileName)) { + setState(newDownload, STATE_PAUSING); + QCoreApplication::processEvents(); + if (QMessageBox::question( + m_ParentWidget, tr("Download again?"), + tr("A file with the same name \"%1\" has already been downloaded. " + "Do you want to download it again? The new file will receive a " + "different name.") + .arg(newDownload->m_FileName), + QMessageBox::Yes | QMessageBox::No) == QMessageBox::No) { + if (reply->isFinished()) + setState(newDownload, STATE_CANCELED); + else + setState(newDownload, STATE_CANCELING); + } else { + startDisableDirWatcher(); + newDownload->setName(getDownloadFileName(newDownload->m_FileName, true), true); + endDisableDirWatcher(); + if (newDownload->m_State == STATE_PAUSED) + resumeDownload(indexByInfo(newDownload)); + else + setState(newDownload, STATE_DOWNLOADING); + } + } else + connect(newDownload->m_Reply, SIGNAL(finished()), this, SLOT(downloadFinished())); + + QCoreApplication::processEvents(); + + if (newDownload->m_State != STATE_DOWNLOADING && + newDownload->m_State != STATE_READY && + newDownload->m_State != STATE_FETCHINGMODINFO && reply->isFinished()) { + int const index = indexByInfo(newDownload); + if (index >= 0) { + downloadFinished(index); + } + return; + } + } else + connect(newDownload->m_Reply, SIGNAL(finished()), this, SLOT(downloadFinished())); +} + +void DownloadManager::addNXMDownload(const QString& url) +{ + NXMUrl const nxmInfo(url); + + QStringList validGames; + const MOBase::IPluginGame* foundGame = nullptr; + validGames.append(m_ManagedGame->gameShortName()); + validGames.append(m_ManagedGame->validShortNames()); + for (auto game : validGames) { + MOBase::IPluginGame* gamePlugin = m_OrganizerCore->getGame(game); + + // some game plugins give names in validShortNames() that may refer to other + // plugins, like ttw returning "FalloutNV" and "Fallout3"; if these plugins + // are not loaded, getGame() might return null + if (!gamePlugin) { + // log an error, it's most probably not normal + log::error("no plugin for game '{}', an antivirus might have deleted it", game); + continue; + } + + if (nxmInfo.game().compare(gamePlugin->gameShortName(), Qt::CaseInsensitive) == 0 || + nxmInfo.game().compare(gamePlugin->gameNexusName(), Qt::CaseInsensitive) == 0) { + foundGame = gamePlugin; + break; + } + } + log::debug("add nxm download: {}", url); + // The Nexus API game name to use for requests (may differ from foundGame's short name + // for special domains like "site" that have no matching game plugin). + QString apiGameName; + if (foundGame == nullptr) { + // "site" is Nexus Mods' game-agnostic domain for tools/utilities (e.g. BethINI Pie). + // Allow these downloads using the current managed game's API context. + if (nxmInfo.game().compare("site", Qt::CaseInsensitive) == 0) { + log::debug("NXM link is for nexusmods.com/site (game-agnostic tool), allowing download"); + foundGame = m_ManagedGame; + apiGameName = nxmInfo.game(); // keep "site" for correct API URLs + } else { + log::debug("download requested for wrong game (game: {}, url: {})", + m_ManagedGame->gameShortName(), nxmInfo.game()); + QMessageBox::information( + m_ParentWidget, tr("Wrong Game"), + tr("The download link is for a mod for \"%1\" but this instance of MO " + "has been set up for \"%2\".") + .arg(nxmInfo.game()) + .arg(m_ManagedGame->gameShortName()), + QMessageBox::Ok); + return; + } + } else { + apiGameName = foundGame->gameShortName(); + } + + for (auto tuple : m_PendingDownloads) { + if (std::get<0>(tuple).compare(apiGameName, Qt::CaseInsensitive) == + 0, + std::get<1>(tuple) == nxmInfo.modId() && + std::get<2>(tuple) == nxmInfo.fileId()) { + const auto infoStr = + tr("There is already a download queued for this file.\n\nMod %1\nFile %2") + .arg(nxmInfo.modId()) + .arg(nxmInfo.fileId()); + + log::debug("download requested is already queued (mod: {}, file: {})", + nxmInfo.modId(), nxmInfo.fileId()); + + QMessageBox::information(m_ParentWidget, tr("Already Queued"), infoStr, + QMessageBox::Ok); + return; + } + } + + for (DownloadInfo* download : m_ActiveDownloads) { + if (download->m_FileInfo->modID == nxmInfo.modId() && + download->m_FileInfo->fileID == nxmInfo.fileId()) { + if (download->m_State == STATE_DOWNLOADING || download->m_State == STATE_PAUSED || + download->m_State == STATE_STARTED) { + QString debugStr( + "download requested is already started (mod %1: %2, file %3: %4)"); + QString infoStr(tr("There is already a download started for this file.\n\nMod " + "%1:\t%2\nFile %3:\t%4")); + + // %1 + debugStr = debugStr.arg(download->m_FileInfo->modID); + infoStr = infoStr.arg(download->m_FileInfo->modID); + + // %2 + if (!download->m_FileInfo->name.isEmpty()) { + debugStr = debugStr.arg(download->m_FileInfo->name); + infoStr = infoStr.arg(download->m_FileInfo->name); + } else if (!download->m_FileInfo->modName.isEmpty()) { + debugStr = debugStr.arg(download->m_FileInfo->modName); + infoStr = infoStr.arg(download->m_FileInfo->modName); + } else { + debugStr = debugStr.arg(QStringLiteral("")); + infoStr = infoStr.arg(QStringLiteral("")); + } + + // %3 + debugStr = debugStr.arg(download->m_FileInfo->fileID); + infoStr = infoStr.arg(download->m_FileInfo->fileID); + + // %4 + if (!download->m_FileInfo->fileName.isEmpty()) { + debugStr = debugStr.arg(download->m_FileInfo->fileName); + infoStr = infoStr.arg(download->m_FileInfo->fileName); + } else if (!download->m_FileName.isEmpty()) { + debugStr = debugStr.arg(download->m_FileName); + infoStr = infoStr.arg(download->m_FileName); + } else { + debugStr = debugStr.arg(QStringLiteral("")); + infoStr = infoStr.arg(QStringLiteral("")); + } + + log::debug("{}", debugStr); + QMessageBox::information(m_ParentWidget, tr("Already Started"), infoStr, + QMessageBox::Ok); + return; + } + } + } + + emit aboutToUpdate(); + + m_PendingDownloads.append( + std::make_tuple(apiGameName, nxmInfo.modId(), nxmInfo.fileId())); + + emit update(-1); + emit downloadAdded(); + ModRepositoryFileInfo* info = new ModRepositoryFileInfo(); + + info->nexusKey = nxmInfo.key(); + info->nexusExpires = nxmInfo.expires(); + info->nexusDownloadUser = nxmInfo.userId(); + + QObject* test = info; + m_RequestIDs.insert(m_NexusInterface->requestFileInfo( + apiGameName, nxmInfo.modId(), nxmInfo.fileId(), this, + QVariant::fromValue(test), "")); +} + +void DownloadManager::removeFile(int index, bool deleteFile) +{ + // Avoid triggering refreshes from DirWatcher + ScopedDisableDirWatcher const scopedDirWatcher(this); + + if (index >= m_ActiveDownloads.size()) { + throw MyException(tr("remove: invalid download index %1").arg(index)); + } + + DownloadInfo* download = m_ActiveDownloads.at(index); + QString filePath = m_OutputDirectory + "/" + download->m_FileName; + if ((download->m_State == STATE_STARTED) || + (download->m_State == STATE_DOWNLOADING)) { + // shouldn't have been possible + log::error("tried to remove active download"); + return; + } + + if ((download->m_State == STATE_PAUSED) || (download->m_State == STATE_ERROR)) { + filePath = download->m_Output.fileName(); + } + + if (deleteFile) { + if (!shellDelete(QStringList(filePath), true)) { + reportError(tr("failed to delete %1").arg(filePath)); + return; + } + + QFile const metaFile(filePath.append(".meta")); + if (metaFile.exists() && !shellDelete(QStringList(filePath), true)) { + reportError(tr("failed to delete meta file for %1").arg(filePath)); + } + } else { + QSettings metaSettings(filePath.append(".meta"), QSettings::IniFormat); + if (!download->m_Hidden) + metaSettings.setValue("removed", true); + } + m_DownloadRemoved(index); +} + +class LessThanWrapper +{ +public: + LessThanWrapper(DownloadManager* manager) : m_Manager(manager) {} + bool operator()(int LHS, int RHS) + { + return m_Manager->getFileName(LHS).compare(m_Manager->getFileName(RHS), + Qt::CaseInsensitive) < 0; + } + +private: + DownloadManager* m_Manager; +}; + +bool DownloadManager::ByName(int LHS, int RHS) +{ + return m_ActiveDownloads[LHS]->m_FileName < m_ActiveDownloads[RHS]->m_FileName; +} + +void DownloadManager::refreshAlphabeticalTranslation() +{ + m_AlphabeticalTranslation.clear(); + int pos = 0; + for (QVector::iterator iter = m_ActiveDownloads.begin(); + iter != m_ActiveDownloads.end(); ++iter, ++pos) { + m_AlphabeticalTranslation.push_back(pos); + } + + std::sort(m_AlphabeticalTranslation.begin(), m_AlphabeticalTranslation.end(), + LessThanWrapper(this)); +} + +void DownloadManager::restoreDownload(int index) +{ + + if (index < 0) { + DownloadState const minState = STATE_READY; + index = 0; + + for (QVector::const_iterator iter = m_ActiveDownloads.begin(); + iter != m_ActiveDownloads.end(); ++iter) { + + if ((*iter)->m_State >= minState) { + restoreDownload(index); + } + index++; + } + } else { + if (index >= m_ActiveDownloads.size()) { + throw MyException(tr("restore: invalid download index: %1").arg(index)); + } + + DownloadInfo* download = m_ActiveDownloads.at(index); + if (download->m_Hidden) { + download->m_Hidden = false; + + QString filePath = m_OutputDirectory + "/" + download->m_FileName; + + // avoid dirWatcher triggering refreshes + startDisableDirWatcher(); + QSettings metaSettings(filePath.append(".meta"), QSettings::IniFormat); + metaSettings.setValue("removed", false); + + endDisableDirWatcher(); + } + } +} + +void DownloadManager::removeDownload(int index, bool deleteFile) +{ + try { + // avoid dirWatcher triggering refreshes + ScopedDisableDirWatcher const scopedDirWatcher(this); + + emit aboutToUpdate(); + + if (index < 0) { + bool const removeAll = (index == -1); + DownloadState const removeState = (index == -2 ? STATE_INSTALLED : STATE_UNINSTALLED); + + index = 0; + for (QVector::iterator iter = m_ActiveDownloads.begin(); + iter != m_ActiveDownloads.end();) { + DownloadState const downloadState = (*iter)->m_State; + if ((removeAll && (downloadState >= STATE_READY)) || + (removeState == downloadState)) { + removeFile(index, deleteFile); + delete *iter; + iter = m_ActiveDownloads.erase(iter); + } else { + ++iter; + ++index; + } + } + } else { + if (index >= m_ActiveDownloads.size()) { + reportError(tr("remove: invalid download index %1").arg(index)); + // emit update(-1); + return; + } + + removeFile(index, deleteFile); + delete m_ActiveDownloads.at(index); + m_ActiveDownloads.erase(m_ActiveDownloads.begin() + index); + } + emit update(-1); + } catch (const std::exception& e) { + log::error("failed to remove download: {}", e.what()); + } + refreshList(); +} + +void DownloadManager::cancelDownload(int index) +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + reportError(tr("cancel: invalid download index %1").arg(index)); + return; + } + + DownloadInfo* info = m_ActiveDownloads.at(index); + if (info->m_State == STATE_DOWNLOADING || info->m_State == STATE_STARTED || + info->m_State == STATE_PAUSING) { + setState(info, STATE_CANCELING); + } +} + +void DownloadManager::pauseDownload(int index) +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + reportError(tr("pause: invalid download index %1").arg(index)); + return; + } + + DownloadInfo* info = m_ActiveDownloads.at(index); + + if (info->m_State == STATE_DOWNLOADING) { + if ((info->m_Reply != nullptr) && (info->m_Reply->isRunning())) { + setState(info, STATE_PAUSING); + } else { + setState(info, STATE_PAUSED); + } + } else if ((info->m_State == STATE_FETCHINGMODINFO) || + (info->m_State == STATE_FETCHINGFILEINFO) || + (info->m_State == STATE_FETCHINGMODINFO_MD5)) { + setState(info, STATE_READY); + } +} + +void DownloadManager::resumeDownload(int index) +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + reportError(tr("resume: invalid download index %1").arg(index)); + return; + } + DownloadInfo* info = m_ActiveDownloads[index]; + info->m_Tries = AUTOMATIC_RETRIES; + resumeDownloadInt(index); +} + +void DownloadManager::resumeDownloadInt(int index) +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + reportError(tr("resume (int): invalid download index %1").arg(index)); + return; + } + DownloadInfo* info = m_ActiveDownloads[index]; + + // Check for finished download; + if (info->m_TotalSize <= info->m_Output.size() && info->m_Reply != nullptr && + info->m_Reply->isFinished() && info->m_State != STATE_ERROR) { + setState(info, STATE_DOWNLOADING); + downloadFinished(index); + return; + } + + if (info->isPausedState() || info->m_State == STATE_PAUSING) { + if (info->m_State == STATE_PAUSING) { + if (info->m_Output.isOpen()) { + writeData(info); + if (info->m_State == STATE_PAUSING) { + setState(info, STATE_PAUSED); + } + } + } + if ((info->m_Urls.empty()) || + ((info->m_Urls.size() == 1) && (info->m_Urls[0].size() == 0))) { + emit showMessage( + tr("No known download urls. Sorry, this download can't be resumed.")); + return; + } + if (info->m_State == STATE_ERROR) { + info->m_CurrentUrl = (info->m_CurrentUrl + 1) % info->m_Urls.count(); + } + log::debug("request resume from url {}", info->currentURL()); + QNetworkRequest request(QUrl::fromEncoded(info->currentURL().toLocal8Bit())); + request.setHeader(QNetworkRequest::UserAgentHeader, + m_NexusInterface->getAccessManager()->userAgent()); + if (info->m_State != STATE_ERROR) { + info->m_ResumePos = info->m_Output.size(); + QByteArray const rangeHeader = "bytes=" + QByteArray::number(info->m_ResumePos) + "-"; + request.setRawHeader("Range", rangeHeader); + } + info->m_DownloadLast = 0; + info->m_DownloadTimeLast = 0; + info->m_DownloadAcc = accumulator_set>( + tag::rolling_window::window_size = 200); + info->m_DownloadTimeAcc = accumulator_set>( + tag::rolling_window::window_size = 200); + log::debug("resume at {} bytes", info->m_ResumePos); + startDownload(m_NexusInterface->getAccessManager()->get(request), info, true); + } + emit update(index); +} + +DownloadManager::DownloadInfo* DownloadManager::downloadInfoByID(unsigned int id) +{ + auto iter = std::find_if(m_ActiveDownloads.begin(), m_ActiveDownloads.end(), + [id](DownloadInfo* info) { + return info->m_DownloadID == id; + }); + if (iter != m_ActiveDownloads.end()) { + return *iter; + } else { + return nullptr; + } +} + +void DownloadManager::queryInfo(int index) +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + reportError(tr("query: invalid download index %1").arg(index)); + return; + } + DownloadInfo* info = m_ActiveDownloads[index]; + + if (info->m_FileInfo->repository != "Nexus") { + log::warn("re-querying file info is currently only possible with Nexus"); + return; + } + + if (info->m_State < DownloadManager::STATE_READY) { + // UI shouldn't allow this + return; + } + + if (info->m_FileInfo->modID <= 0) { + QString const fileName = getFileName(index); + QString ignore; + NexusInterface::interpretNexusFileName(fileName, ignore, info->m_FileInfo->modID, + info->m_AskIfNotFound); + if (info->m_FileInfo->modID < 0) { + bool ok = false; + int const modId = QInputDialog::getInt(nullptr, tr("Please enter the Nexus mod ID"), + tr("Mod ID:"), 1, 1, + std::numeric_limits::max(), 1, &ok); + // careful now: while the dialog was displayed, events were processed. + // the download list might have changed and our info-ptr invalidated. + if (ok) + m_ActiveDownloads[index]->m_FileInfo->modID = modId; + return; + } + } + + if (info->m_FileInfo->gameName.isEmpty()) { + if (!info->m_AskIfNotFound) { + // in case the info couldn't be retrieved from Nexus due to connection issues, + // don't create a meta file so you can query again after the issues are resolved. + return; + } + + SelectionDialog selection( + tr("Please select the source game code for %1").arg(getFileName(index))); + + std::vector> choices = + m_NexusInterface->getGameChoices(m_ManagedGame); + if (choices.size() == 1) { + info->m_FileInfo->gameName = choices[0].first; + } else { + for (const auto& choice : choices) { + selection.addChoice(choice.first, choice.second, choice.first); + } + if (selection.exec() == QDialog::Accepted) { + info->m_FileInfo->gameName = selection.getChoiceData().toString(); + } else { + info->m_FileInfo->gameName = m_ManagedGame->gameShortName(); + } + } + } + info->m_ReQueried = true; + setState(info, STATE_FETCHINGMODINFO); +} + +void DownloadManager::queryInfoMd5(int index, bool askIfNotFound) +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + reportError(tr("query: invalid download index %1").arg(index)); + return; + } + DownloadInfo* info = m_ActiveDownloads[index]; + + if (info->m_FileInfo->repository != "Nexus") { + log::warn("re-querying file info is currently only possible with Nexus"); + return; + } + + if (info->m_State < DownloadManager::STATE_READY) { + // UI shouldn't allow this + return; + } + + info->m_GamesToQuery << m_ManagedGame->gameShortName(); + info->m_GamesToQuery << m_ManagedGame->validShortNames(); + + QFile downloadFile(info->m_FileName); + if (!downloadFile.exists()) { + downloadFile.setFileName(m_OrganizerCore->downloadsPath() + "/" + + info->m_FileName); + } + if (!downloadFile.exists()) { + log::error("Can't find download file '{}'", info->m_FileName); + return; + } + if (!downloadFile.open(QIODevice::ReadOnly)) { + log::error("Can't open download file '{}'", info->m_FileName); + return; + } + + QCryptographicHash hash(QCryptographicHash::Md5); + const qint64 progressStep = 10 * 1024 * 1024; + QProgressDialog progress(tr("Hashing download file '%1'").arg(info->m_FileName), + tr("Cancel"), 0, downloadFile.size() / progressStep); + progress.setWindowModality(Qt::WindowModal); + progress.setMinimumDuration(1000); + + for (qint64 i = 0; i < downloadFile.size(); i += progressStep) { + progress.setValue(progress.value() + 1); + if (progress.wasCanceled()) { + break; + } + hash.addData(downloadFile.read(progressStep)); + } + if (progress.wasCanceled()) { + downloadFile.close(); + return; + } + + progress.close(); + downloadFile.close(); + + info->m_Hash = hash.result(); + info->m_ReQueried = true; + info->m_AskIfNotFound = askIfNotFound; + setState(info, STATE_FETCHINGMODINFO_MD5); +} + +void DownloadManager::visitOnNexus(int index) +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + reportError(tr("VisitNexus: invalid download index %1").arg(index)); + return; + } + DownloadInfo* info = m_ActiveDownloads[index]; + + if (info->m_FileInfo->repository != "Nexus") { + log::warn("Visiting mod page is currently only possible with Nexus"); + return; + } + + if (info->m_State < DownloadManager::STATE_READY) { + // UI shouldn't allow this + return; + } + int const modID = info->m_FileInfo->modID; + + QString const gameName = info->m_FileInfo->gameName; + if (modID > 0) { + shell::Open(QUrl(m_NexusInterface->getModURL(modID, gameName))); + } else { + emit showMessage(tr("Nexus ID for this Mod is unknown")); + } +} + +void DownloadManager::visitUploaderProfile(int index) +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + reportError(tr("VisitUploaderProfile: invalid download index %1").arg(index)); + return; + } + DownloadInfo* info = m_ActiveDownloads[index]; + + if (info->m_State < DownloadManager::STATE_READY) { + // UI shouldn't allow this + return; + } + + const auto& uploaderUrl = info->m_FileInfo->uploaderUrl; + if (!uploaderUrl.isEmpty()) { + shell::Open(QUrl(uploaderUrl)); + } else { + emit showMessage(tr("Uploader for this Mod is unknown")); + } +} + +void DownloadManager::openFile(int index) +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + reportError(tr("OpenFile: invalid download index %1").arg(index)); + return; + } + + QDir const path = QDir(m_OutputDirectory); + if (path.exists(getFileName(index))) { + shell::Open(getFilePath(index)); + return; + } + + shell::Explore(m_OutputDirectory); + return; +} + +void DownloadManager::openMetaFile(int index) +{ + if (index < 0 || index >= m_ActiveDownloads.size()) { + log::error("OpenMetaFile: invalid download index {}", index); + return; + } + + const auto path = QDir(m_OutputDirectory); + const auto filePath = getFilePath(index); + const auto metaPath = filePath + ".meta"; + + if (path.exists(metaPath)) { + shell::Open(metaPath); + return; + } else { + QSettings metaFile(metaPath, QSettings::IniFormat); + metaFile.setValue("removed", false); + } + + if (path.exists(metaPath)) { + shell::Open(metaPath); + return; + } + + shell::Explore(filePath); +} + +void DownloadManager::openInDownloadsFolder(int index) +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + reportError(tr("OpenFileInDownloadsFolder: invalid download index %1").arg(index)); + return; + } + + const auto path = getFilePath(index); + + if (QFile::exists(path)) { + shell::Explore(path); + return; + } else { + const auto unfinished = path + ".unfinished"; + if (QFile::exists(unfinished)) { + shell::Explore(unfinished); + return; + } + } + + shell::Explore(m_OutputDirectory); +} + +int DownloadManager::numTotalDownloads() const +{ + return m_ActiveDownloads.size(); +} + +int DownloadManager::numPendingDownloads() const +{ + return m_PendingDownloads.size(); +} + +std::tuple DownloadManager::getPendingDownload(int index) +{ + if ((index < 0) || (index >= m_PendingDownloads.size())) { + throw MyException(tr("get pending: invalid download index %1").arg(index)); + } + + return m_PendingDownloads.at(index); +} + +QString DownloadManager::getFilePath(int index) const +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + throw MyException(tr("get path: invalid download index %1").arg(index)); + } + + return m_OutputDirectory + "/" + m_ActiveDownloads.at(index)->m_FileName; +} + +QString DownloadManager::getFileTypeString(int fileType) +{ + switch (fileType) { + case 1: + return tr("Main"); + case 2: + return tr("Update"); + case 3: + return tr("Optional"); + case 4: + return tr("Old"); + case 5: + return tr("Miscellaneous"); + case 6: + return tr("Deleted"); + case 7: + return tr("Archived"); + default: + return tr("Unknown"); + } +} + +QString DownloadManager::getDisplayName(int index) const +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + throw MyException(tr("display name: invalid download index %1").arg(index)); + } + + DownloadInfo* info = m_ActiveDownloads.at(index); + + QTextDocument doc; + if (!info->m_FileInfo->name.isEmpty()) { + doc.setHtml(info->m_FileInfo->name); + return QString("%1 (%2, v%3)") + .arg(doc.toPlainText()) + .arg(getFileTypeString(info->m_FileInfo->fileCategory)) + .arg(info->m_FileInfo->version.displayString()); + } else { + doc.setHtml(info->m_FileName); + return doc.toPlainText(); + } +} + +QString DownloadManager::getFileName(int index) const +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + throw MyException(tr("file name: invalid download index %1").arg(index)); + } + + return m_ActiveDownloads.at(index)->m_FileName; +} + +QDateTime DownloadManager::getFileTime(int index) const +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + throw MyException(tr("file time: invalid download index %1").arg(index)); + } + + DownloadInfo* info = m_ActiveDownloads.at(index); + if (!info->m_Created.isValid()) { + QFileInfo const fileInfo(info->m_Output); + info->m_Created = fileInfo.birthTime(); + if (!info->m_Created.isValid()) + info->m_Created = fileInfo.metadataChangeTime(); + if (!info->m_Created.isValid()) + info->m_Created = fileInfo.lastModified(); + } + + return info->m_Created; +} + +qint64 DownloadManager::getFileSize(int index) const +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + throw MyException(tr("file size: invalid download index %1").arg(index)); + } + + return m_ActiveDownloads.at(index)->m_TotalSize; +} + +std::pair DownloadManager::getProgress(int index) const +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + throw MyException(tr("progress: invalid download index %1").arg(index)); + } + + return m_ActiveDownloads.at(index)->m_Progress; +} + +DownloadManager::DownloadState DownloadManager::getState(int index) const +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + throw MyException(tr("state: invalid download index %1").arg(index)); + } + + return m_ActiveDownloads.at(index)->m_State; +} + +bool DownloadManager::isInfoIncomplete(int index) const +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + throw MyException(tr("infocomplete: invalid download index %1").arg(index)); + } + + DownloadInfo* info = m_ActiveDownloads.at(index); + if (info->m_FileInfo->repository != "Nexus") { + // other repositories currently don't support re-querying info anyway + return false; + } + return (info->m_FileInfo->fileID == 0) || (info->m_FileInfo->modID == 0); +} + +int DownloadManager::getModID(int index) const +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + throw MyException(tr("mod id: invalid download index %1").arg(index)); + } + return m_ActiveDownloads.at(index)->m_FileInfo->modID; +} + +QString DownloadManager::getDisplayGameName(int index) const +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + throw MyException(tr("mod id: invalid download index %1").arg(index)); + } + QString gameName = m_ActiveDownloads.at(index)->m_FileInfo->gameName; + IPluginGame* gamePlugin = m_OrganizerCore->getGame(gameName); + if (gamePlugin) { + gameName = gamePlugin->gameName(); + } + return gameName; +} + +QString DownloadManager::getGameName(int index) const +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + throw MyException(tr("mod id: invalid download index %1").arg(index)); + } + return m_ActiveDownloads.at(index)->m_FileInfo->gameName; +} + +bool DownloadManager::isHidden(int index) const +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + throw MyException(tr("ishidden: invalid download index %1").arg(index)); + } + return m_ActiveDownloads.at(index)->m_Hidden; +} + +const ModRepositoryFileInfo* DownloadManager::getFileInfo(int index) const +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + throw MyException(tr("file info: invalid download index %1").arg(index)); + } + + return m_ActiveDownloads.at(index)->m_FileInfo; +} + +void DownloadManager::markInstalled(int index) +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + throw MyException(tr("mark installed: invalid download index %1").arg(index)); + } + + // Avoid triggering refreshes from DirWatcher + ScopedDisableDirWatcher const scopedDirWatcher(this); + + DownloadInfo* info = m_ActiveDownloads.at(index); + QSettings metaFile(info->m_Output.fileName() + ".meta", QSettings::IniFormat); + metaFile.setValue("installed", true); + metaFile.setValue("uninstalled", false); + + setState(m_ActiveDownloads.at(index), STATE_INSTALLED); +} + +void DownloadManager::markInstalled(QString fileName) +{ + int const index = indexByName(fileName); + if (index >= 0) { + markInstalled(index); + } else { + DownloadInfo* info = getDownloadInfo(fileName); + if (info != nullptr) { + // Avoid triggering refreshes from DirWatcher + ScopedDisableDirWatcher const scopedDirWatcher(this); + + QSettings metaFile(info->m_Output.fileName() + ".meta", QSettings::IniFormat); + metaFile.setValue("installed", true); + metaFile.setValue("uninstalled", false); + delete info; + } + } +} + +DownloadManager::DownloadInfo* DownloadManager::getDownloadInfo(QString fileName) +{ + return DownloadInfo::createFromMeta(fileName, true, m_OutputDirectory); +} + +void DownloadManager::markUninstalled(int index) +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + throw MyException(tr("mark uninstalled: invalid download index %1").arg(index)); + } + + // Avoid triggering refreshes from DirWatcher + ScopedDisableDirWatcher const scopedDirWatcher(this); + + DownloadInfo* info = m_ActiveDownloads.at(index); + QSettings metaFile(info->m_Output.fileName() + ".meta", QSettings::IniFormat); + metaFile.setValue("uninstalled", true); + + setState(m_ActiveDownloads.at(index), STATE_UNINSTALLED); +} + +void DownloadManager::markUninstalled(QString fileName) +{ + int const index = indexByName(fileName); + if (index >= 0) { + markUninstalled(index); + } else { + QString const filePath = QDir::fromNativeSeparators(m_OutputDirectory) + "/" + fileName; + DownloadInfo* info = getDownloadInfo(filePath); + if (info != nullptr) { + + // Avoid triggering refreshes from DirWatcher + ScopedDisableDirWatcher const scopedDirWatcher(this); + + QSettings metaFile(info->m_Output.fileName() + ".meta", QSettings::IniFormat); + metaFile.setValue("uninstalled", true); + delete info; + } + } +} + +QString DownloadManager::getDownloadFileName(const QString& baseName, bool rename) const +{ + QString fullPath = m_OutputDirectory + "/" + MOBase::sanitizeFileName(baseName); + if (QFile::exists(fullPath) && rename) { + int i = 1; + while (QFile::exists( + QString("%1/%2_%3").arg(m_OutputDirectory).arg(i).arg(baseName))) { + ++i; + } + + fullPath = QString("%1/%2_%3").arg(m_OutputDirectory).arg(i).arg(baseName); + } + return fullPath; +} + +QString DownloadManager::getFileNameFromNetworkReply(QNetworkReply* reply) +{ + if (reply->hasRawHeader("Content-Disposition")) { + std::regex const exp("filename=\"(.+)\""); + + std::cmatch result; + if (std::regex_search(reply->rawHeader("Content-Disposition").constData(), result, + exp)) { + return MOBase::sanitizeFileName(QString::fromUtf8(result.str(1).c_str())); + } + } + + return {}; +} + +void DownloadManager::setState(DownloadManager::DownloadInfo* info, + DownloadManager::DownloadState state) +{ + int row = 0; + for (int i = 0; i < m_ActiveDownloads.size(); ++i) { + if (m_ActiveDownloads[i] == info) { + row = i; + break; + } + } + info->m_State = state; + switch (state) { + case STATE_CANCELING: { + // Force termination so the download transitions through finished(). + if (info->m_Reply != nullptr && info->m_Reply->isRunning()) { + info->m_Reply->abort(); + } + } break; + case STATE_PAUSED: { + info->m_Reply->abort(); + info->m_Output.close(); + m_DownloadPaused(row); + } break; + case STATE_ERROR: { + info->m_Reply->abort(); + info->m_Output.close(); + m_DownloadFailed(row); + } break; + case STATE_CANCELED: { + info->m_Reply->abort(); + m_DownloadFailed(row); + } break; + case STATE_FETCHINGMODINFO: { + m_RequestIDs.insert(m_NexusInterface->requestDescription( + info->m_FileInfo->gameName, info->m_FileInfo->modID, this, info->m_DownloadID, + QString())); + } break; + case STATE_FETCHINGFILEINFO: { + m_RequestIDs.insert(m_NexusInterface->requestFiles(info->m_FileInfo->gameName, + info->m_FileInfo->modID, this, + info->m_DownloadID, QString())); + } break; + case STATE_FETCHINGMODINFO_MD5: { + log::debug("Searching {} for MD5 of {}", info->m_GamesToQuery[0], + QString(info->m_Hash.toHex())); + m_RequestIDs.insert(m_NexusInterface->requestInfoFromMd5( + info->m_GamesToQuery[0], info->m_Hash, this, info->m_DownloadID, QString())); + } break; + case STATE_READY: { + createMetaFile(info); + m_DownloadComplete(row); + } break; + default: /* NOP */ + break; + } + emit stateChanged(row, state); +} + +DownloadManager::DownloadInfo* DownloadManager::findDownload(QObject* reply, + int* index) const +{ + // reverse search as newer, thus more relevant, downloads are at the end + for (int i = m_ActiveDownloads.size() - 1; i >= 0; --i) { + if (m_ActiveDownloads[i]->m_Reply == reply) { + if (index != nullptr) { + *index = i; + } + return m_ActiveDownloads[i]; + } + } + return nullptr; +} + +void DownloadManager::downloadProgress(qint64 bytesReceived, qint64 bytesTotal) +{ + if (bytesTotal == 0) { + return; + } + int index = 0; + try { + DownloadInfo* info = findDownload(this->sender(), &index); + if (info != nullptr) { + info->m_HasData = true; + if (info->m_State == STATE_CANCELING) { + setState(info, STATE_CANCELED); + } else if (info->m_State == STATE_PAUSING) { + setState(info, STATE_PAUSED); + } else { + if (bytesTotal > info->m_TotalSize) { + info->m_TotalSize = bytesTotal; + } + int const oldProgress = info->m_Progress.first; + info->m_Progress.first = ((info->m_ResumePos + bytesReceived) * 100) / + (info->m_ResumePos + bytesTotal); + + qint64 const elapsed = info->m_StartTime.elapsed(); + info->m_DownloadAcc(bytesReceived - info->m_DownloadLast); + info->m_DownloadLast = bytesReceived; + info->m_DownloadTimeAcc(elapsed - info->m_DownloadTimeLast); + info->m_DownloadTimeLast = elapsed; + + // calculate the download speed + const double speed = rolling_mean(info->m_DownloadAcc) / + (rolling_mean(info->m_DownloadTimeAcc) / 1000.0); + ; + + const qint64 remaining = (bytesTotal - bytesReceived) / speed * 1000; + + info->m_Progress.second = tr("%1% - %2 - ~%3") + .arg(info->m_Progress.first) + .arg(MOBase::localizedByteSpeed(speed)) + .arg(MOBase::localizedTimeRemaining(remaining)); + + TaskProgressManager::instance().updateProgress(info->m_TaskProgressId, + bytesReceived, bytesTotal); + emit update(index); + } + } + } catch (const std::bad_alloc&) { + reportError(tr("Memory allocation error (in processing progress event).")); + } +} + +void DownloadManager::downloadReadyRead() +{ + try { + writeData(findDownload(this->sender())); + } catch (const std::bad_alloc&) { + reportError(tr("Memory allocation error (in processing downloaded data).")); + } +} + +void DownloadManager::createMetaFile(DownloadInfo* info) +{ + // Avoid triggering refreshes from DirWatcher + ScopedDisableDirWatcher const scopedDirWatcher(this); + + QSettings metaFile(QString("%1.meta").arg(info->m_Output.fileName()), + QSettings::IniFormat); + metaFile.setValue("gameName", info->m_FileInfo->gameName); + metaFile.setValue("modID", info->m_FileInfo->modID); + metaFile.setValue("fileID", info->m_FileInfo->fileID); + metaFile.setValue("url", info->m_Urls.join(";")); + metaFile.setValue("name", info->m_FileInfo->name); + metaFile.setValue("description", info->m_FileInfo->description); + metaFile.setValue("modName", info->m_FileInfo->modName); + metaFile.setValue("version", info->m_FileInfo->version.canonicalString()); + metaFile.setValue("newestVersion", info->m_FileInfo->newestVersion.canonicalString()); + metaFile.setValue("fileTime", info->m_FileInfo->fileTime); + metaFile.setValue("fileCategory", info->m_FileInfo->fileCategory); + metaFile.setValue("category", info->m_FileInfo->categoryID); + metaFile.setValue("repository", info->m_FileInfo->repository); + metaFile.setValue("userData", info->m_FileInfo->userData); + metaFile.setValue("author", info->m_FileInfo->author); + metaFile.setValue("uploader", info->m_FileInfo->uploader); + metaFile.setValue("uploaderUrl", info->m_FileInfo->uploaderUrl); + metaFile.setValue("installed", info->m_State == DownloadManager::STATE_INSTALLED); + metaFile.setValue("uninstalled", info->m_State == DownloadManager::STATE_UNINSTALLED); + metaFile.setValue("paused", (info->m_State == DownloadManager::STATE_PAUSED) || + (info->m_State == DownloadManager::STATE_ERROR)); + metaFile.setValue("removed", info->m_Hidden); + + // slightly hackish... + for (int i = 0; i < m_ActiveDownloads.size(); ++i) { + if (m_ActiveDownloads[i] == info) { + emit update(i); + } + } +} + +void DownloadManager::nxmDescriptionAvailable(QString, int, QVariant userData, + QVariant resultData, int requestID) +{ + std::set::iterator const idIter = m_RequestIDs.find(requestID); + if (idIter == m_RequestIDs.end()) { + return; + } else { + m_RequestIDs.erase(idIter); + } + + QVariantMap result = resultData.toMap(); + + DownloadInfo* info = downloadInfoByID(userData.toInt()); + if (info == nullptr) + return; + info->m_FileInfo->categoryID = result["category_id"].toInt(); + info->m_FileInfo->author = result["author"].toString(); + info->m_FileInfo->uploader = result["uploaded_by"].toString(); + info->m_FileInfo->uploaderUrl = result["uploaded_users_profile_url"].toString(); + QTextDocument doc; + doc.setHtml(result["name"].toString().trimmed()); + info->m_FileInfo->modName = doc.toPlainText(); + if (info->m_FileInfo->fileID != 0) { + setState(info, STATE_READY); + } else { + setState(info, STATE_FETCHINGFILEINFO); + } +} + +void DownloadManager::nxmFilesAvailable(QString, int, QVariant userData, + QVariant resultData, int requestID) +{ + std::set::iterator const idIter = m_RequestIDs.find(requestID); + if (idIter == m_RequestIDs.end()) { + return; + } else { + m_RequestIDs.erase(idIter); + } + + DownloadInfo* info = downloadInfoByID(userData.toInt()); + if (info == nullptr) + return; + + QVariantMap result = resultData.toMap(); + QVariantList files = result["files"].toList(); + + // MO sometimes prepends _ to the filename in case of duplicate downloads. + // this may muck up the file name comparison + QString alternativeLocalName = info->m_FileName; + + QRegularExpression const expression("^\\d_(.*)$"); + auto match = expression.match(alternativeLocalName); + if (match.hasMatch()) { + alternativeLocalName = match.captured(1); + } + + bool found = false; + + for (const QVariant& file : files) { + QVariantMap fileInfo = file.toMap(); + QString const fileName = fileInfo["file_name"].toString(); + QString const fileNameVariant = fileName.mid(0).replace(' ', '_'); + if ((fileName == info->m_RemoteFileName) || + (fileNameVariant == info->m_RemoteFileName) || (fileName == info->m_FileName) || + (fileNameVariant == info->m_FileName) || (fileName == alternativeLocalName) || + (fileNameVariant == alternativeLocalName)) { + info->m_FileInfo->name = fileInfo["name"].toString(); + info->m_FileInfo->version.parse(fileInfo["version"].toString()); + if (!info->m_FileInfo->version.isValid()) { + info->m_FileInfo->version = info->m_FileInfo->newestVersion; + } + info->m_FileInfo->fileCategory = fileInfo["category_id"].toInt(); + info->m_FileInfo->fileTime = + QDateTime::fromMSecsSinceEpoch(fileInfo["uploaded_timestamp"].toLongLong()); + info->m_FileInfo->fileID = fileInfo["file_id"].toInt(); + info->m_FileInfo->fileName = fileInfo["file_name"].toString(); + info->m_FileInfo->description = + BBCode::convertToHTML(fileInfo["description"].toString()); + info->m_FileInfo->author = fileInfo["author"].toString(); + info->m_FileInfo->uploader = fileInfo["uploaded_by"].toString(); + info->m_FileInfo->uploaderUrl = fileInfo["uploaded_users_profile_url"].toString(); + found = true; + break; + } + } + + if (info->m_ReQueried) { + if (found) { + emit showMessage(tr("Information updated")); + } else if (result.count() == 0) { + emit showMessage(tr("No matching file found on Nexus! Maybe this file is no " + "longer available or it was renamed?")); + } else { + SelectionDialog selection(tr("No file on Nexus matches the selected file by " + "name. Please manually choose the correct one.")); + std::sort(files.begin(), files.end(), + [](const QVariant& lhs, const QVariant& rhs) { + return lhs.toMap()["uploaded_timestamp"].toInt() > + rhs.toMap()["uploaded_timestamp"].toInt(); + }); + for (const QVariant& file : files) { + QVariantMap fileInfo = file.toMap(); + if (fileInfo["category_id"].toInt() != NexusInterface::FileStatus::REMOVED && + fileInfo["category_id"].toInt() != NexusInterface::FileStatus::ARCHIVED) + selection.addChoice(fileInfo["file_name"].toString(), "", file); + } + if (selection.exec() == QDialog::Accepted) { + QVariantMap fileInfo = selection.getChoiceData().toMap(); + info->m_FileInfo->name = fileInfo["name"].toString(); + info->m_FileInfo->version.parse(fileInfo["version"].toString()); + info->m_FileInfo->fileCategory = fileInfo["category_id"].toInt(); + info->m_FileInfo->fileID = fileInfo["file_id"].toInt(); + } else { + emit showMessage(tr("No matching file found on Nexus! Maybe this file is no " + "longer available or it was renamed?")); + } + } + } else { + if (info->m_FileInfo->fileID == 0) { + log::warn("could not determine file id for {} (state {})", info->m_FileName, + info->m_State); + } + } + + setState(info, STATE_READY); +} + +void DownloadManager::nxmFileInfoAvailable(QString gameName, int modID, int fileID, + QVariant userData, QVariant resultData, + int requestID) +{ + std::set::iterator const idIter = m_RequestIDs.find(requestID); + if (idIter == m_RequestIDs.end()) { + return; + } else { + m_RequestIDs.erase(idIter); + } + + ModRepositoryFileInfo* info = + qobject_cast(qvariant_cast(userData)); + + QVariantMap result = resultData.toMap(); + info->name = result["name"].toString(); + info->version.parse(result["version"].toString()); + if (!info->version.isValid()) { + info->version = info->newestVersion; + } + info->fileName = result["file_name"].toString(); + info->fileCategory = result["category_id"].toInt(); + info->fileTime = + QDateTime::fromMSecsSinceEpoch(result["uploaded_timestamp"].toLongLong()); + info->description = BBCode::convertToHTML(result["description"].toString()); + + info->repository = "Nexus"; + + // Default to the raw game name; overridden below if a matching plugin is found. + // This preserves special domains like "site" that have no game plugin. + info->gameName = gameName; + QStringList games(m_ManagedGame->validShortNames()); + games += m_ManagedGame->gameShortName(); + for (const auto& game : games) { + MOBase::IPluginGame* gamePlugin = m_OrganizerCore->getGame(game); + if (gamePlugin != nullptr && + gamePlugin->gameNexusName().compare(gameName, Qt::CaseInsensitive) == 0) { + info->gameName = gamePlugin->gameShortName(); + break; + } + } + + info->modID = modID; + info->fileID = fileID; + + QObject* test = info; + m_RequestIDs.insert( + m_NexusInterface->requestDownloadURL(info->gameName, info->modID, info->fileID, + this, QVariant::fromValue(test), QString())); +} + +static int evaluateFileInfoMap(const QVariantMap& map, + const ServerList::container& preferredServers) +{ + int preference = 0; + bool found = false; + const auto name = map["short_name"].toString(); + + for (const auto& server : preferredServers) { + if (server.name() == name) { + preference = server.preferred(); + found = true; + break; + } + } + + if (!found) { + return 0; + } + + return 100 + preference * 20; +} + +// sort function to sort by best download server +// +bool ServerByPreference(const ServerList::container& preferredServers, + const QVariant& LHS, const QVariant& RHS) +{ + const auto a = evaluateFileInfoMap(LHS.toMap(), preferredServers); + const auto b = evaluateFileInfoMap(RHS.toMap(), preferredServers); + return (a > b); +} + +int DownloadManager::startDownloadURLs(const QStringList& urls) +{ + ModRepositoryFileInfo const info; + addDownload(urls, "", -1, -1, &info); + return m_ActiveDownloads.size() - 1; +} + +int DownloadManager::startDownloadNexusFile(const QString& gameName, int modID, + int fileID) +{ + int const newID = m_ActiveDownloads.size(); + addNXMDownload( + QString("nxm://%1/mods/%2/files/%3").arg(gameName).arg(modID).arg(fileID)); + return newID; +} + +QString DownloadManager::downloadPath(int id) const +{ + return getFilePath(id); +} + +boost::signals2::connection +DownloadManager::onDownloadComplete(const std::function& callback) +{ + return m_DownloadComplete.connect(callback); +} + +boost::signals2::connection +DownloadManager::onDownloadPaused(const std::function& callback) +{ + return m_DownloadPaused.connect(callback); +} + +boost::signals2::connection +DownloadManager::onDownloadFailed(const std::function& callback) +{ + return m_DownloadFailed.connect(callback); +} + +boost::signals2::connection +DownloadManager::onDownloadRemoved(const std::function& callback) +{ + return m_DownloadRemoved.connect(callback); +} + +int DownloadManager::indexByName(const QString& fileName) const +{ + for (int i = 0; i < m_ActiveDownloads.size(); ++i) { + if (m_ActiveDownloads[i]->m_FileName == fileName) { + return i; + } + } + return -1; +} + +int DownloadManager::indexByInfo(const DownloadInfo* info) const +{ + for (int i = 0; i < m_ActiveDownloads.size(); ++i) { + if (m_ActiveDownloads[i] == info) { + return i; + } + } + return -1; +} + +void DownloadManager::nxmDownloadURLsAvailable(QString gameName, int modID, int fileID, + QVariant userData, QVariant resultData, + int requestID) +{ + using namespace boost::placeholders; + + std::set::iterator const idIter = m_RequestIDs.find(requestID); + if (idIter == m_RequestIDs.end()) { + return; + } else { + m_RequestIDs.erase(idIter); + } + + ModRepositoryFileInfo* info = + qobject_cast(qvariant_cast(userData)); + QVariantList resultList = resultData.toList(); + if (resultList.empty()) { + removePending(gameName, modID, fileID); + emit showMessage(tr("No download server available. Please try again later.")); + return; + } + + const auto servers = m_OrganizerCore->settings().network().servers(); + + std::sort(resultList.begin(), resultList.end(), + boost::bind(&ServerByPreference, servers.getPreferred(), _1, _2)); + + info->userData["downloadMap"] = resultList; + + QStringList URLs; + + foreach (const QVariant& server, resultList) { + URLs.append(server.toMap()["URI"].toString()); + } + addDownload(URLs, gameName, modID, fileID, info); +} + +void DownloadManager::nxmFileInfoFromMd5Available(QString gameName, QVariant userData, + QVariant resultData, int requestID) +{ + std::set::iterator const idIter = m_RequestIDs.find(requestID); + if (idIter == m_RequestIDs.end()) { + return; + } else { + m_RequestIDs.erase(idIter); + } + + DownloadInfo* info = downloadInfoByID(userData.toInt()); + + // This can come back with multiple results with the same file was uploaded multiple + // times (for whatever reason) + auto resultlist = resultData.toList(); + int chosenIdx = resultlist.count() > 1 ? -1 : 0; + + // Look for the exact file name + if (chosenIdx < 0) { + for (int i = 0; i < resultlist.count(); i++) { + auto results = resultlist[i].toMap(); + auto fileDetails = results["file_details"].toMap(); + + if (fileDetails["file_name"].toString().compare(info->m_FileName, + Qt::CaseInsensitive) == 0) { + if (chosenIdx < 0) { + chosenIdx = i; // intentional to not break in order to check other results + } else { + log::debug("Multiple files with same name found during MD5 search."); + chosenIdx = -1; + break; + } + } + } + } + + // Look for the only active one + if (chosenIdx < 0) { + for (int i = 0; i < resultlist.count(); i++) { + auto results = resultlist[i].toMap(); + auto fileDetails = results["file_details"].toMap(); + + if (fileDetails["category_id"].toInt() != NexusInterface::FileStatus::REMOVED && + fileDetails["category_id"].toInt() != NexusInterface::FileStatus::ARCHIVED) { + if (chosenIdx < 0) { + chosenIdx = i; // intentional to not break in order to check other results + } else { + log::debug("Multiple active files found during MD5 search."); + chosenIdx = -1; + break; + } + } + } + } + + // Unable to determine the correct mod / file. Revert to the old method + if (chosenIdx < 0) { + // don't use the normal state set function as we don't want to create a meta file + info->m_State = DownloadManager::STATE_READY; + queryInfo(m_ActiveDownloads.indexOf(info)); + return; + } + + auto results = resultlist[chosenIdx].toMap(); + auto fileDetails = results["file_details"].toMap(); + auto modDetails = results["mod"].toMap(); + + info->m_FileInfo->name = fileDetails["name"].toString(); + info->m_FileInfo->fileID = fileDetails["file_id"].toInt(); + info->m_FileInfo->description = + BBCode::convertToHTML(fileDetails["description"].toString()); + info->m_FileInfo->version.parse(fileDetails["version"].toString()); + if (!info->m_FileInfo->version.isValid()) + info->m_FileInfo->version.parse(fileDetails["mod_version"].toString()); + info->m_FileInfo->fileCategory = fileDetails["category_id"].toInt(); + + info->m_FileInfo->modID = modDetails["mod_id"].toInt(); + info->m_FileInfo->modName = modDetails["name"].toString(); + info->m_FileInfo->categoryID = modDetails["category_id"].toInt(); + info->m_FileInfo->author = modDetails["author"].toString(); + info->m_FileInfo->uploader = modDetails["uploaded_by"].toString(); + info->m_FileInfo->uploaderUrl = modDetails["uploaded_users_profile_url"].toString(); + + QString gameShortName = gameName; + QStringList games(m_ManagedGame->validShortNames()); + games += m_ManagedGame->gameShortName(); + for (const auto& game : games) { + MOBase::IPluginGame* gamePlugin = m_OrganizerCore->getGame(game); + if (gamePlugin != nullptr && + gamePlugin->gameNexusName().compare(gameName, Qt::CaseInsensitive) == 0) { + gameShortName = gamePlugin->gameShortName(); + break; + } + } + + info->m_FileInfo->gameName = gameShortName; + + // If the file description is not present, send another query to get it + if (!fileDetails["description"].isValid()) { + info->m_RemoteFileName = fileDetails["file_name"].toString(); + setState(info, STATE_FETCHINGFILEINFO); + } else { + setState(info, STATE_READY); + } +} + +void DownloadManager::nxmRequestFailed(QString gameName, int modID, int fileID, + QVariant userData, int requestID, int errorCode, + const QString& errorString) +{ + std::set::iterator const idIter = m_RequestIDs.find(requestID); + if (idIter == m_RequestIDs.end()) { + return; + } else { + m_RequestIDs.erase(idIter); + } + + DownloadInfo* userDataInfo = downloadInfoByID(userData.toInt()); + + int index = 0; + + for (QVector::iterator iter = m_ActiveDownloads.begin(); + iter != m_ActiveDownloads.end(); ++iter, ++index) { + DownloadInfo* info = *iter; + if (info != userDataInfo) + continue; + + // MD5 searches continue until all possible games are done + if (info->m_State == STATE_FETCHINGMODINFO_MD5) { + if (info->m_GamesToQuery.count() >= 2) { + info->m_GamesToQuery.pop_front(); + setState(info, STATE_FETCHINGMODINFO_MD5); + return; + } else if (errorCode == 404 && !info->m_AskIfNotFound) { + // prevent re-querying (only possible with repository = "Nexus") + info->m_FileInfo->repository = ""; + setState(info, STATE_READY); + return; + } else { + info->m_State = STATE_READY; + queryInfo(index); + emit update(index); + return; + } + } + + if (info->m_FileInfo->modID == modID) { + if (info->m_State < STATE_FETCHINGMODINFO) { + m_ActiveDownloads.erase(iter); + delete info; + } else { + setState(info, STATE_READY); + } + emit update(index); + break; + } + } + + removePending(gameName, modID, fileID); + emit showMessage(tr("Failed to request file info from nexus: %1").arg(errorString)); +} + +void DownloadManager::downloadFinished(int index) +{ + DownloadInfo* info; + if (index > 0) + info = m_ActiveDownloads[index]; + else { + info = findDownload(this->sender(), &index); + if (info == nullptr && index == 0) { + info = m_ActiveDownloads[index]; + } + } + + if (info != nullptr) { + QNetworkReply* reply = info->m_Reply; + QByteArray data; + if (reply->isOpen() && info->m_HasData) { + data = reply->readAll(); + info->m_Output.write(data); + } + info->m_Output.close(); + TaskProgressManager::instance().forgetMe(info->m_TaskProgressId); + + bool error = false; + if ((info->m_State != STATE_CANCELING) && (info->m_State != STATE_PAUSING)) { + bool const textData = reply->header(QNetworkRequest::ContentTypeHeader) + .toString() + .startsWith("text", Qt::CaseInsensitive); + if (textData) + emit showMessage( + tr("Warning: Content type is: %1") + .arg(reply->header(QNetworkRequest::ContentTypeHeader).toString())); + if ((info->m_Output.size() == 0) || + ((reply->error() != QNetworkReply::NoError) && + (reply->error() != QNetworkReply::OperationCanceledError))) { + if (reply->error() == QNetworkReply::UnknownContentError) + emit showMessage( + tr("Download header content length: %1 downloaded file size: %2") + .arg(reply->header(QNetworkRequest::ContentLengthHeader).toLongLong()) + .arg(info->m_Output.size())); + if (info->m_Tries == 0) { + emit showMessage(tr("Download failed: %1 (%2)") + .arg(reply->errorString()) + .arg(reply->error())); + } + error = true; + setState(info, STATE_ERROR); + } + } + + if (info->m_State == STATE_CANCELING) { + setState(info, STATE_CANCELED); + } else if (info->m_State == STATE_PAUSING) { + if (info->m_Output.isOpen() && info->m_HasData) { + info->m_Output.write(info->m_Reply->readAll()); + } + setState(info, STATE_PAUSED); + } + + if (info->m_State == STATE_CANCELED || (info->m_Tries == 0 && error)) { + emit aboutToUpdate(); + info->m_Output.remove(); + delete info; + m_ActiveDownloads.erase(m_ActiveDownloads.begin() + index); + if (error) + emit showMessage( + tr("We were unable to download the file due to errors after four retries. " + "There may be an issue with the Nexus servers.")); + emit update(-1); + } else if (info->isPausedState() || info->m_State == STATE_PAUSING) { + info->m_Output.close(); + createMetaFile(info); + emit update(index); + } else { + QString const url = info->m_Urls[info->m_CurrentUrl]; + if (info->m_FileInfo->userData.contains("downloadMap")) { + foreach (const QVariant& server, + info->m_FileInfo->userData["downloadMap"].toList()) { + QVariantMap serverMap = server.toMap(); + if (serverMap["URI"].toString() == url) { + int const deltaTime = info->m_StartTime.elapsed() / 1000; + if (deltaTime > 5) { + emit downloadSpeed(serverMap["short_name"].toString(), + (info->m_TotalSize - info->m_PreResumeSize) / + deltaTime); + } // no division by zero please! Also, if the download is shorter than a + // few seconds, the result is way to inprecise + break; + } + } + } + + bool const isNexus = info->m_FileInfo->repository == "Nexus"; + // need to change state before changing the file name, otherwise .unfinished is + // appended + if (isNexus) { + setState(info, STATE_FETCHINGMODINFO); + } else { + setState(info, STATE_NOFETCH); + } + + QString const newName = getFileNameFromNetworkReply(reply); + QString const oldName = QFileInfo(info->m_Output).fileName(); + + startDisableDirWatcher(); + // Rename to Content-Disposition if either we have no name yet, or the + // name we seeded from the API looks like a CDN object key. + if (!newName.isEmpty() && + (oldName.isEmpty() || looksLikeCdnObjectKey(info->m_FileName))) { + info->setName(getDownloadFileName(newName), true); + } else { + info->setName(m_OutputDirectory + "/" + info->m_FileName, + true); // don't rename but remove the ".unfinished" extension + } + endDisableDirWatcher(); + + if (!isNexus) { + setState(info, STATE_READY); + } + + emit update(index); + } + reply->close(); + reply->deleteLater(); + + if ((info->m_Tries > 0) && error) { + --info->m_Tries; + resumeDownloadInt(index); + } + } else { + log::warn("no download index {}", index); + } +} + +void DownloadManager::downloadError(QNetworkReply::NetworkError error) +{ + if (error != QNetworkReply::OperationCanceledError) { + QNetworkReply* reply = qobject_cast(sender()); + log::warn("{} ({})", + reply != nullptr ? reply->errorString() : "Download error occured", + error); + } +} + +void DownloadManager::metaDataChanged() +{ + int index = 0; + + DownloadInfo* info = findDownload(this->sender(), &index); + if (info != nullptr) { + const QString newName = getFileNameFromNetworkReply(info->m_Reply); + // Prefer Content-Disposition over whatever we seeded from the API when + // the seeded name looks like a CDN object key. Otherwise only take it + // if we have no name at all yet. + const bool shouldReplace = + !newName.isEmpty() && + (info->m_FileName.isEmpty() || looksLikeCdnObjectKey(info->m_FileName)); + + if (shouldReplace) { + log::info("metaDataChanged: replacing '{}' with Content-Disposition '{}'", + info->m_FileName, newName); + startDisableDirWatcher(); + info->setName(getDownloadFileName(newName), true); + endDisableDirWatcher(); + refreshAlphabeticalTranslation(); + if (!info->m_Output.isOpen() && + !info->m_Output.open(QIODevice::WriteOnly | QIODevice::Append)) { + reportError(tr("failed to re-open %1").arg(info->m_FileName)); + setState(info, STATE_CANCELING); + } + } + } else { + log::warn("meta data event for unknown download"); + } +} + +void DownloadManager::directoryChanged(const QString&) +{ + if (DownloadManager::m_DirWatcherDisabler == 0) + refreshList(); +} + +void DownloadManager::managedGameChanged(MOBase::IPluginGame const* managedGame) +{ + m_ManagedGame = managedGame; +} + +void DownloadManager::checkDownloadTimeout() +{ + for (int i = 0; i < m_ActiveDownloads.size(); ++i) { + DownloadInfo* info = m_ActiveDownloads[i]; + if (info->m_State != STATE_DOWNLOADING || info->m_Reply == nullptr || + !info->m_Reply->isOpen()) { + continue; + } + + const bool stalled = + (info->m_StartTime.elapsed() - info->m_DownloadTimeLast) > (5 * 1000); + if (!stalled) { + continue; + } + + pauseDownload(i); + downloadFinished(i); + + // downloadFinished() can remove the download entry, so find it again. + const int index = indexByInfo(info); + if (index < 0) { + continue; + } + + if (info->m_Tries <= 0) { + emit showMessage(tr("Download stalled repeatedly and was paused. " + "Please retry manually.")); + continue; + } + + --info->m_Tries; + log::warn("download '{}' stalled, retrying ({} retries left)", info->m_FileName, + info->m_Tries); + resumeDownloadInt(index); + emit update(index); + } +} + +void DownloadManager::writeData(DownloadInfo* info) +{ + if (info != nullptr) { + qint64 ret = info->m_Output.write(info->m_Reply->readAll()); + if (ret < info->m_Reply->size()) { + QString const fileName = + info->m_FileName; // m_FileName may be destroyed after setState + setState(info, DownloadState::STATE_CANCELED); + + log::error("Unable to write download \"{}\" to drive (return {})", + info->m_FileName, ret); + + reportError(tr("Unable to write download to drive (return %1).\n" + "Check the drive's available storage.\n\n" + "Canceling download \"%2\"...") + .arg(ret) + .arg(fileName)); + } + } +} diff --git a/src/src/downloadmanager.h b/src/src/downloadmanager.h index b061788..581fe84 100644 --- a/src/src/downloadmanager.h +++ b/src/src/downloadmanager.h @@ -1,646 +1,646 @@ -/* -Copyright (C) 2012 Sebastian Herbord. All rights reserved. - -This file is part of Mod Organizer. - -Mod Organizer is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -Mod Organizer is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with Mod Organizer. If not, see . -*/ - -#ifndef DOWNLOADMANAGER_H -#define DOWNLOADMANAGER_H - -#include "serverinfo.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -using namespace boost::accumulators; - -namespace MOBase -{ -class IPluginGame; -} - -class NexusInterface; -class PluginContainer; -class OrganizerCore; - -/*! - * \brief manages downloading of files and provides progress information for gui - *elements - **/ -class DownloadManager : public QObject -{ - Q_OBJECT - -public: - enum DownloadState - { - STATE_STARTED = 0, - STATE_DOWNLOADING, - STATE_CANCELING, - STATE_PAUSING, - STATE_CANCELED, - STATE_PAUSED, - STATE_ERROR, - STATE_FETCHINGMODINFO, - STATE_FETCHINGFILEINFO, - STATE_FETCHINGMODINFO_MD5, - STATE_NOFETCH, - STATE_READY, - STATE_INSTALLED, - STATE_UNINSTALLED - }; - -private: - struct DownloadInfo - { - ~DownloadInfo() { delete m_FileInfo; } - accumulator_set> m_DownloadAcc; - accumulator_set> m_DownloadTimeAcc; - qint64 m_DownloadLast{0}; - qint64 m_DownloadTimeLast{0}; - unsigned int m_DownloadID; - QString m_FileName; - QFile m_Output; - QNetworkReply* m_Reply; - QElapsedTimer m_StartTime; - qint64 m_PreResumeSize; - std::pair m_Progress; - bool m_HasData{false}; - DownloadState m_State; - int m_CurrentUrl; - QStringList m_Urls; - qint64 m_ResumePos; - qint64 m_TotalSize{0}; - QDateTime m_Created; // used as a cache in DownloadManager::getFileTime, may not be - // valid elsewhere - QByteArray m_Hash; - QStringList m_GamesToQuery; - QString m_RemoteFileName; - - int m_Tries; - bool m_ReQueried{false}; - bool m_AskIfNotFound{true}; - - quint32 m_TaskProgressId; - - MOBase::ModRepositoryFileInfo* m_FileInfo{nullptr}; - - bool m_Hidden{false}; - - static DownloadInfo* createNew(const MOBase::ModRepositoryFileInfo* fileInfo, - const QStringList& URLs); - static DownloadInfo* createFromMeta(const QString& filePath, bool showHidden, - const QString outputDirectory, - std::optional fileSize = {}); - - /** - * @brief rename the file - * this will change the file name as well as the display name. It will automatically - * append .unfinished to the name if this file is still being downloaded - * @param newName the new name to setName - * @param renameFile if true, the file is assumed to exist and renamed. If the file - *does not yet exist, set this to false - **/ - void setName(QString newName, bool renameFile); - - unsigned int downloadID() const { return m_DownloadID; } - - bool isPausedState() const; - - QString currentURL(); - - private: - static unsigned int s_NextDownloadID; - - private: - DownloadInfo() - : - m_DownloadAcc(tag::rolling_window::window_size = 200), - m_DownloadTimeAcc(tag::rolling_window::window_size = 200) - {} - }; - - friend class DownloadManagerProxy; - - using SignalDownloadCallback = boost::signals2::signal; - -public: - /** - * @brief constructor - * - * @param nexusInterface interface to use to retrieve information from the relevant - *nexus page - * @param parent parent object - **/ - explicit DownloadManager(NexusInterface* nexusInterface, QObject* parent); - - ~DownloadManager() override; - - void setParentWidget(QWidget* w); - - /** - * @brief determine if a download is currently in progress - * - * @return true if there is currently a download in progress - **/ - bool downloadsInProgress(); - - /** - * @brief determine if a download is currently in progress, does not count paused - *ones. - * - * @return true if there is currently a download in progress (that is not paused - *already). - **/ - bool downloadsInProgressNoPause(); - - /** - * @brief set the output directory to write to - * - * @param outputDirectory the new output directory - **/ - void setOutputDirectory(const QString& outputDirectory, const bool refresh = true); - - /** - * @brief disables feedback from the downlods fileSystemWhatcher untill - *disableDownloadsWatcherEnd() is called - * - **/ - static void startDisableDirWatcher(); - - /** - * @brief re-enables feedback from the downlods fileSystemWhatcher after - *disableDownloadsWatcherStart() was called - **/ - static void endDisableDirWatcher(); - - /** - * @return current download directory - **/ - QString getOutputDirectory() const { return m_OutputDirectory; } - - /** - * @brief sets whether hidden files are to be shown after all - */ - void setShowHidden(bool showHidden); - - void setPluginContainer(PluginContainer* pluginContainer); - - /** - * @brief download from an already open network connection - * - * @param reply the network reply to download from - * @param fileInfo information about the file, like mod id, file id, version, ... - * @return true if the download was started, false if it wasn't. The latter currently - *only happens if there is a duplicate and the user decides not to download again - **/ - bool addDownload(QNetworkReply* reply, const MOBase::ModRepositoryFileInfo* fileInfo); - - /** - * @brief download from an already open network connection - * - * @param reply the network reply to download from - * @param fileName the name to use for the file. This may be overridden by the name in - *the fileInfo-structure or if the http stream specifies a name - * @param fileInfo information previously retrieved from the nexus network - * @return true if the download was started, false if it wasn't. The latter currently - *only happens if there is a duplicate and the user decides not to download again - **/ - bool addDownload(QNetworkReply* reply, const QStringList& URLs, - const QString& fileName, QString gameName, int modID, int fileID = 0, - const MOBase::ModRepositoryFileInfo* fileInfo = - new MOBase::ModRepositoryFileInfo()); - - /** - * @brief start a download using a nxm-link - * - * starts a download using a nxm-link. The download manager will first query the nexus - * page for file information. - * @param url a nxm link looking like this: nxm://skyrim/mods/1234/files/4711 - * @todo the game name encoded into the link is currently ignored, all downloads are - *incorrectly assumed to be for the identified game - **/ - void addNXMDownload(const QString& url); - - /** - * @brief retrieve the total number of downloads, both finished and unfinished - *including downloads from previous sessions - * - * @return total number of downloads - **/ - int numTotalDownloads() const; - - /** - * @brief retrieve number of pending downloads (nexus downloads for which we don't - * know the name and url yet) - * @return number of pending downloads - */ - int numPendingDownloads() const; - - /** - * @brief retrieve the info of a pending download - * @param index index of the pending download (index in the range [0, - * numPendingDownloads()[) - * @return pair of modid, fileid - */ - std::tuple getPendingDownload(int index); - - /** - * @brief retrieve the full path to the download specified by index - * - * @param index the index to look up - * @return absolute path of the file - **/ - QString getFilePath(int index) const; - - /** - * @brief retrieve a descriptive name of the download specified by index - * - * @param index index of the file to look up - * @return display name of the file - **/ - QString getDisplayName(int index) const; - - /** - * @brief retrieve the filename of the download specified by index - * - * @param index index of the file to look up - * @return name of the file - **/ - QString getFileName(int index) const; - - /** - * @brief retrieve the file size of the download specified by index - * - * @param index index of the file to look up - * @return size of the file (total size during download) - */ - qint64 getFileSize(int index) const; - - /** - * @brief retrieve the creation time of the download specified by index - * @param index index of the file to look up - * @return size of the file (total size during download) - */ - QDateTime getFileTime(int index) const; - - /** - * @brief retrieve the current progress of the download specified by index - * - * @param index index of the file to look up - * @return progress of the download in percent (integer) - **/ - std::pair getProgress(int index) const; - - /** - * @brief retrieve the current state of the download - * - * retrieve the current state of the download. A download usually goes through - * the following states: - * started -> downloading -> fetching mod info -> fetching file info -> done - * in case of downloads started via nxm-link, file information is fetched first - * - * @param index index of the file to look up - * @return the download state - **/ - DownloadState getState(int index) const; - - /** - * @param index index of the file to look up - * @return true if the nexus information for this download is not complete - **/ - bool isInfoIncomplete(int index) const; - - /** - * @brief retrieve the nexus mod id of the download specified by index - * - * @param index index of the file to look up - * @return the nexus mod id - **/ - int getModID(int index) const; - - /** - * @brief retrieve the displayable game name of the download specified by the index - * - * @param index index of the file to look up - * @return the displayable game name - **/ - QString getDisplayGameName(int index) const; - - /** - * @brief retrieve the game name of the downlaod specified by the index - * - * @param index index of the file to look up - * @return the game name - **/ - QString getGameName(int index) const; - - /** - * @brief determine if the specified file is supposed to be hidden - * @param index index of the file to look up - * @return true if the specified file is supposed to be hidden - */ - bool isHidden(int index) const; - - /** - * @brief retrieve all nexus info of the download specified by index - * - * @param index index of the file to look up - * @return the nexus mod information - **/ - const MOBase::ModRepositoryFileInfo* getFileInfo(int index) const; - - /** - * @brief mark a download as installed - * - * @param index index of the file to mark installed - */ - void markInstalled(int index); - - void markInstalled(QString download); - - /** - * @brief mark a download as uninstalled - * - * @param index index of the file to mark uninstalled - */ - void markUninstalled(int index); - - void markUninstalled(QString download); - - /** - * @brief refreshes the list of downloads - */ - void refreshList(); - - /** - * @brief Query infos for every download in the list - */ - void queryDownloadListInfo(); - -public: // IDownloadManager interface: - int startDownloadURLs(const QStringList& urls); - int startDownloadNexusFile(const QString& gameName, int modID, int fileID); - QString downloadPath(int id) const; - - boost::signals2::connection - onDownloadComplete(const std::function& callback); - boost::signals2::connection - onDownloadPaused(const std::function& callback); - boost::signals2::connection - onDownloadFailed(const std::function& callback); - boost::signals2::connection - onDownloadRemoved(const std::function& callback); - - /** - * @brief retrieve a download index from the filename - * @param fileName file to look up - * @return index of that download or -1 if it wasn't found - */ - int indexByName(const QString& fileName) const; - int indexByInfo(const DownloadInfo* info) const; - - void pauseAll(); - -Q_SIGNALS: - - void aboutToUpdate(); - - /** - * @brief signals that the specified download has changed - * - * @param row the row that changed. This corresponds to the download index - **/ - void update(int row); - - /** - * @brief signals the ui that a message should be displayed - * - * @param message the message to display - **/ - void showMessage(const QString& message); - - /** - * @brief emitted whenever the state of a download changes - * @param row the row that changed - * @param state the new state - */ - void stateChanged(int row, DownloadManager::DownloadState state); - - /** - * @brief emitted whenever a download completes successfully, reporting the download - * speed for the server used - */ - void downloadSpeed(const QString& serverName, int bytesPerSecond); - - /** - * @brief emitted whenever a new download is added to the list - */ - void downloadAdded(); - -public slots: - - /** - * @brief removes the specified download - * - * @param index index of the download to remove - * @param deleteFile if true, the file will also be deleted from disc, otherwise it is - *only marked as hidden. - **/ - void removeDownload(int index, bool deleteFile); - - /** - * @brief restores the specified download to view (which was previously hidden - * @param index index of the download to restore - */ - void restoreDownload(int index); - - /** - * @brief cancel the specified download. This will lead to the corresponding file to - *be deleted - * - * @param index index of the download to cancel - **/ - void cancelDownload(int index); - - void pauseDownload(int index); - - void resumeDownload(int index); - - void queryInfo(int index); - - void queryInfoMd5(int index, bool askIfNotFound = true); - - void visitOnNexus(int index); - - void visitUploaderProfile(int index); - - void openFile(int index); - - void openMetaFile(int index); - - void openInDownloadsFolder(int index); - - void nxmDescriptionAvailable(QString gameName, int modID, QVariant userData, - QVariant resultData, int requestID); - - void nxmFilesAvailable(QString gameName, int modID, QVariant userData, - QVariant resultData, int requestID); - - void nxmFileInfoAvailable(QString gameName, int modID, int fileID, QVariant userData, - QVariant resultData, int requestID); - - void nxmDownloadURLsAvailable(QString gameName, int modID, int fileID, - QVariant userData, QVariant resultData, int requestID); - - void nxmFileInfoFromMd5Available(QString gameName, QVariant userData, - QVariant resultData, int requestID); - - void nxmRequestFailed(QString gameName, int modID, int fileID, QVariant userData, - int requestID, int errorCode, const QString& errorString); - - void managedGameChanged(MOBase::IPluginGame const* gamePlugin); - -private slots: - - void downloadProgress(qint64 bytesReceived, qint64 bytesTotal); - void downloadReadyRead(); - void downloadFinished(int index = 0); - void downloadError(QNetworkReply::NetworkError error); - void metaDataChanged(); - void directoryChanged(const QString& dirctory); - void checkDownloadTimeout(); - -private: - void createMetaFile(DownloadInfo* info); - DownloadManager::DownloadInfo* getDownloadInfo(QString fileName); - -public: - /** Get a unique filename for a download. - * - * This allows you multiple versions of download files, useful if the file - * comes from a web site with no version control - * - * @param basename: Name of the file - * - * @return Unique(ish) name - */ - QString getDownloadFileName(const QString& baseName, bool rename = false) const; - -private: - void startDownload(QNetworkReply* reply, DownloadInfo* newDownload, bool resume); - void resumeDownloadInt(int index); - - /** - * @brief start a download from a url - * - * @param url the url to download from - * @param fileInfo information previously retrieved from the mod page - * @return true if the download was started, false if it wasn't. The latter currently - *only happens if there is a duplicate and the user decides not to download again - **/ - bool addDownload(const QStringList& URLs, QString gameName, int modID, int fileID, - const MOBase::ModRepositoryFileInfo* fileInfo); - - // important: the caller has to lock the list-mutex, otherwise the - // DownloadInfo-pointer might get invalidated at any time - DownloadInfo* findDownload(QObject* reply, int* index = nullptr) const; - - void removeFile(int index, bool deleteFile); - - void refreshAlphabeticalTranslation(); - - bool ByName(int LHS, int RHS); - - static QString getFileNameFromNetworkReply(QNetworkReply* reply); - - void setState(DownloadInfo* info, DownloadManager::DownloadState state); - - DownloadInfo* downloadInfoByID(unsigned int id); - - void removePending(QString gameName, int modID, int fileID); - - static QString getFileTypeString(int fileType); - - void writeData(DownloadInfo* info); - -private: - static const int AUTOMATIC_RETRIES = 3; - -private: - NexusInterface* m_NexusInterface; - - OrganizerCore* m_OrganizerCore; - QWidget* m_ParentWidget{nullptr}; - - QVector> m_PendingDownloads; - - QVector m_ActiveDownloads; - - QString m_OutputDirectory; - std::set m_RequestIDs; - QVector m_AlphabeticalTranslation; - - QFileSystemWatcher m_DirWatcher; - - SignalDownloadCallback m_DownloadComplete; - SignalDownloadCallback m_DownloadPaused; - SignalDownloadCallback m_DownloadFailed; - SignalDownloadCallback m_DownloadRemoved; - - // The dirWatcher is actually triggering off normal Mo operations such as deleting - // downloads or editing .meta files so it needs to be disabled during operations that - // are known to cause the creation or deletion of files in the Downloads folder. - // Notably using QSettings to edit a file creates a temporarily .lock file that causes - // the Watcher to trigger multiple listRefreshes freezing the ui. - static int m_DirWatcherDisabler; - - std::map m_DownloadFails; - - bool m_ShowHidden{false}; - - MOBase::IPluginGame const* m_ManagedGame; - - QTimer m_TimeoutTimer; -}; - -class ScopedDisableDirWatcher -{ -public: - ScopedDisableDirWatcher(DownloadManager* downloadManager); - ~ScopedDisableDirWatcher(); - -private: - DownloadManager* m_downloadManager; -}; - -#endif // DOWNLOADMANAGER_H +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef DOWNLOADMANAGER_H +#define DOWNLOADMANAGER_H + +#include "serverinfo.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +using namespace boost::accumulators; + +namespace MOBase +{ +class IPluginGame; +} + +class NexusInterface; +class PluginContainer; +class OrganizerCore; + +/*! + * \brief manages downloading of files and provides progress information for gui + *elements + **/ +class DownloadManager : public QObject +{ + Q_OBJECT + +public: + enum DownloadState + { + STATE_STARTED = 0, + STATE_DOWNLOADING, + STATE_CANCELING, + STATE_PAUSING, + STATE_CANCELED, + STATE_PAUSED, + STATE_ERROR, + STATE_FETCHINGMODINFO, + STATE_FETCHINGFILEINFO, + STATE_FETCHINGMODINFO_MD5, + STATE_NOFETCH, + STATE_READY, + STATE_INSTALLED, + STATE_UNINSTALLED + }; + +private: + struct DownloadInfo + { + ~DownloadInfo() { delete m_FileInfo; } + accumulator_set> m_DownloadAcc; + accumulator_set> m_DownloadTimeAcc; + qint64 m_DownloadLast{0}; + qint64 m_DownloadTimeLast{0}; + unsigned int m_DownloadID; + QString m_FileName; + QFile m_Output; + QNetworkReply* m_Reply; + QElapsedTimer m_StartTime; + qint64 m_PreResumeSize; + std::pair m_Progress; + bool m_HasData{false}; + DownloadState m_State; + int m_CurrentUrl; + QStringList m_Urls; + qint64 m_ResumePos; + qint64 m_TotalSize{0}; + QDateTime m_Created; // used as a cache in DownloadManager::getFileTime, may not be + // valid elsewhere + QByteArray m_Hash; + QStringList m_GamesToQuery; + QString m_RemoteFileName; + + int m_Tries; + bool m_ReQueried{false}; + bool m_AskIfNotFound{true}; + + quint32 m_TaskProgressId; + + MOBase::ModRepositoryFileInfo* m_FileInfo{nullptr}; + + bool m_Hidden{false}; + + static DownloadInfo* createNew(const MOBase::ModRepositoryFileInfo* fileInfo, + const QStringList& URLs); + static DownloadInfo* createFromMeta(const QString& filePath, bool showHidden, + QString outputDirectory, + std::optional fileSize = {}); + + /** + * @brief rename the file + * this will change the file name as well as the display name. It will automatically + * append .unfinished to the name if this file is still being downloaded + * @param newName the new name to setName + * @param renameFile if true, the file is assumed to exist and renamed. If the file + *does not yet exist, set this to false + **/ + void setName(QString newName, bool renameFile); + + unsigned int downloadID() const { return m_DownloadID; } + + bool isPausedState() const; + + QString currentURL(); + + private: + static unsigned int s_NextDownloadID; + + private: + DownloadInfo() + : + m_DownloadAcc(tag::rolling_window::window_size = 200), + m_DownloadTimeAcc(tag::rolling_window::window_size = 200) + {} + }; + + friend class DownloadManagerProxy; + + using SignalDownloadCallback = boost::signals2::signal; + +public: + /** + * @brief constructor + * + * @param nexusInterface interface to use to retrieve information from the relevant + *nexus page + * @param parent parent object + **/ + explicit DownloadManager(NexusInterface* nexusInterface, QObject* parent); + + ~DownloadManager() override; + + void setParentWidget(QWidget* w); + + /** + * @brief determine if a download is currently in progress + * + * @return true if there is currently a download in progress + **/ + bool downloadsInProgress(); + + /** + * @brief determine if a download is currently in progress, does not count paused + *ones. + * + * @return true if there is currently a download in progress (that is not paused + *already). + **/ + bool downloadsInProgressNoPause(); + + /** + * @brief set the output directory to write to + * + * @param outputDirectory the new output directory + **/ + void setOutputDirectory(const QString& outputDirectory, bool refresh = true); + + /** + * @brief disables feedback from the downlods fileSystemWhatcher untill + *disableDownloadsWatcherEnd() is called + * + **/ + static void startDisableDirWatcher(); + + /** + * @brief re-enables feedback from the downlods fileSystemWhatcher after + *disableDownloadsWatcherStart() was called + **/ + static void endDisableDirWatcher(); + + /** + * @return current download directory + **/ + QString getOutputDirectory() const { return m_OutputDirectory; } + + /** + * @brief sets whether hidden files are to be shown after all + */ + void setShowHidden(bool showHidden); + + void setPluginContainer(PluginContainer* pluginContainer); + + /** + * @brief download from an already open network connection + * + * @param reply the network reply to download from + * @param fileInfo information about the file, like mod id, file id, version, ... + * @return true if the download was started, false if it wasn't. The latter currently + *only happens if there is a duplicate and the user decides not to download again + **/ + bool addDownload(QNetworkReply* reply, const MOBase::ModRepositoryFileInfo* fileInfo); + + /** + * @brief download from an already open network connection + * + * @param reply the network reply to download from + * @param fileName the name to use for the file. This may be overridden by the name in + *the fileInfo-structure or if the http stream specifies a name + * @param fileInfo information previously retrieved from the nexus network + * @return true if the download was started, false if it wasn't. The latter currently + *only happens if there is a duplicate and the user decides not to download again + **/ + bool addDownload(QNetworkReply* reply, const QStringList& URLs, + const QString& fileName, QString gameName, int modID, int fileID = 0, + const MOBase::ModRepositoryFileInfo* fileInfo = + new MOBase::ModRepositoryFileInfo()); + + /** + * @brief start a download using a nxm-link + * + * starts a download using a nxm-link. The download manager will first query the nexus + * page for file information. + * @param url a nxm link looking like this: nxm://skyrim/mods/1234/files/4711 + * @todo the game name encoded into the link is currently ignored, all downloads are + *incorrectly assumed to be for the identified game + **/ + void addNXMDownload(const QString& url); + + /** + * @brief retrieve the total number of downloads, both finished and unfinished + *including downloads from previous sessions + * + * @return total number of downloads + **/ + int numTotalDownloads() const; + + /** + * @brief retrieve number of pending downloads (nexus downloads for which we don't + * know the name and url yet) + * @return number of pending downloads + */ + int numPendingDownloads() const; + + /** + * @brief retrieve the info of a pending download + * @param index index of the pending download (index in the range [0, + * numPendingDownloads()[) + * @return pair of modid, fileid + */ + std::tuple getPendingDownload(int index); + + /** + * @brief retrieve the full path to the download specified by index + * + * @param index the index to look up + * @return absolute path of the file + **/ + QString getFilePath(int index) const; + + /** + * @brief retrieve a descriptive name of the download specified by index + * + * @param index index of the file to look up + * @return display name of the file + **/ + QString getDisplayName(int index) const; + + /** + * @brief retrieve the filename of the download specified by index + * + * @param index index of the file to look up + * @return name of the file + **/ + QString getFileName(int index) const; + + /** + * @brief retrieve the file size of the download specified by index + * + * @param index index of the file to look up + * @return size of the file (total size during download) + */ + qint64 getFileSize(int index) const; + + /** + * @brief retrieve the creation time of the download specified by index + * @param index index of the file to look up + * @return size of the file (total size during download) + */ + QDateTime getFileTime(int index) const; + + /** + * @brief retrieve the current progress of the download specified by index + * + * @param index index of the file to look up + * @return progress of the download in percent (integer) + **/ + std::pair getProgress(int index) const; + + /** + * @brief retrieve the current state of the download + * + * retrieve the current state of the download. A download usually goes through + * the following states: + * started -> downloading -> fetching mod info -> fetching file info -> done + * in case of downloads started via nxm-link, file information is fetched first + * + * @param index index of the file to look up + * @return the download state + **/ + DownloadState getState(int index) const; + + /** + * @param index index of the file to look up + * @return true if the nexus information for this download is not complete + **/ + bool isInfoIncomplete(int index) const; + + /** + * @brief retrieve the nexus mod id of the download specified by index + * + * @param index index of the file to look up + * @return the nexus mod id + **/ + int getModID(int index) const; + + /** + * @brief retrieve the displayable game name of the download specified by the index + * + * @param index index of the file to look up + * @return the displayable game name + **/ + QString getDisplayGameName(int index) const; + + /** + * @brief retrieve the game name of the downlaod specified by the index + * + * @param index index of the file to look up + * @return the game name + **/ + QString getGameName(int index) const; + + /** + * @brief determine if the specified file is supposed to be hidden + * @param index index of the file to look up + * @return true if the specified file is supposed to be hidden + */ + bool isHidden(int index) const; + + /** + * @brief retrieve all nexus info of the download specified by index + * + * @param index index of the file to look up + * @return the nexus mod information + **/ + const MOBase::ModRepositoryFileInfo* getFileInfo(int index) const; + + /** + * @brief mark a download as installed + * + * @param index index of the file to mark installed + */ + void markInstalled(int index); + + void markInstalled(QString download); + + /** + * @brief mark a download as uninstalled + * + * @param index index of the file to mark uninstalled + */ + void markUninstalled(int index); + + void markUninstalled(QString download); + + /** + * @brief refreshes the list of downloads + */ + void refreshList(); + + /** + * @brief Query infos for every download in the list + */ + void queryDownloadListInfo(); + +public: // IDownloadManager interface: + int startDownloadURLs(const QStringList& urls); + int startDownloadNexusFile(const QString& gameName, int modID, int fileID); + QString downloadPath(int id) const; + + boost::signals2::connection + onDownloadComplete(const std::function& callback); + boost::signals2::connection + onDownloadPaused(const std::function& callback); + boost::signals2::connection + onDownloadFailed(const std::function& callback); + boost::signals2::connection + onDownloadRemoved(const std::function& callback); + + /** + * @brief retrieve a download index from the filename + * @param fileName file to look up + * @return index of that download or -1 if it wasn't found + */ + int indexByName(const QString& fileName) const; + int indexByInfo(const DownloadInfo* info) const; + + void pauseAll(); + +Q_SIGNALS: + + void aboutToUpdate(); + + /** + * @brief signals that the specified download has changed + * + * @param row the row that changed. This corresponds to the download index + **/ + void update(int row); + + /** + * @brief signals the ui that a message should be displayed + * + * @param message the message to display + **/ + void showMessage(const QString& message); + + /** + * @brief emitted whenever the state of a download changes + * @param row the row that changed + * @param state the new state + */ + void stateChanged(int row, DownloadManager::DownloadState state); + + /** + * @brief emitted whenever a download completes successfully, reporting the download + * speed for the server used + */ + void downloadSpeed(const QString& serverName, int bytesPerSecond); + + /** + * @brief emitted whenever a new download is added to the list + */ + void downloadAdded(); + +public slots: + + /** + * @brief removes the specified download + * + * @param index index of the download to remove + * @param deleteFile if true, the file will also be deleted from disc, otherwise it is + *only marked as hidden. + **/ + void removeDownload(int index, bool deleteFile); + + /** + * @brief restores the specified download to view (which was previously hidden + * @param index index of the download to restore + */ + void restoreDownload(int index); + + /** + * @brief cancel the specified download. This will lead to the corresponding file to + *be deleted + * + * @param index index of the download to cancel + **/ + void cancelDownload(int index); + + void pauseDownload(int index); + + void resumeDownload(int index); + + void queryInfo(int index); + + void queryInfoMd5(int index, bool askIfNotFound = true); + + void visitOnNexus(int index); + + void visitUploaderProfile(int index); + + void openFile(int index); + + void openMetaFile(int index); + + void openInDownloadsFolder(int index); + + void nxmDescriptionAvailable(QString gameName, int modID, QVariant userData, + QVariant resultData, int requestID); + + void nxmFilesAvailable(QString gameName, int modID, QVariant userData, + QVariant resultData, int requestID); + + void nxmFileInfoAvailable(QString gameName, int modID, int fileID, QVariant userData, + QVariant resultData, int requestID); + + void nxmDownloadURLsAvailable(QString gameName, int modID, int fileID, + QVariant userData, QVariant resultData, int requestID); + + void nxmFileInfoFromMd5Available(QString gameName, QVariant userData, + QVariant resultData, int requestID); + + void nxmRequestFailed(QString gameName, int modID, int fileID, QVariant userData, + int requestID, int errorCode, const QString& errorString); + + void managedGameChanged(MOBase::IPluginGame const* gamePlugin); + +private slots: + + void downloadProgress(qint64 bytesReceived, qint64 bytesTotal); + void downloadReadyRead(); + void downloadFinished(int index = 0); + void downloadError(QNetworkReply::NetworkError error); + void metaDataChanged(); + void directoryChanged(const QString& dirctory); + void checkDownloadTimeout(); + +private: + void createMetaFile(DownloadInfo* info); + DownloadManager::DownloadInfo* getDownloadInfo(QString fileName); + +public: + /** Get a unique filename for a download. + * + * This allows you multiple versions of download files, useful if the file + * comes from a web site with no version control + * + * @param basename: Name of the file + * + * @return Unique(ish) name + */ + QString getDownloadFileName(const QString& baseName, bool rename = false) const; + +private: + void startDownload(QNetworkReply* reply, DownloadInfo* newDownload, bool resume); + void resumeDownloadInt(int index); + + /** + * @brief start a download from a url + * + * @param url the url to download from + * @param fileInfo information previously retrieved from the mod page + * @return true if the download was started, false if it wasn't. The latter currently + *only happens if there is a duplicate and the user decides not to download again + **/ + bool addDownload(const QStringList& URLs, QString gameName, int modID, int fileID, + const MOBase::ModRepositoryFileInfo* fileInfo); + + // important: the caller has to lock the list-mutex, otherwise the + // DownloadInfo-pointer might get invalidated at any time + DownloadInfo* findDownload(QObject* reply, int* index = nullptr) const; + + void removeFile(int index, bool deleteFile); + + void refreshAlphabeticalTranslation(); + + bool ByName(int LHS, int RHS); + + static QString getFileNameFromNetworkReply(QNetworkReply* reply); + + void setState(DownloadInfo* info, DownloadManager::DownloadState state); + + DownloadInfo* downloadInfoByID(unsigned int id); + + void removePending(QString gameName, int modID, int fileID); + + static QString getFileTypeString(int fileType); + + void writeData(DownloadInfo* info); + +private: + static const int AUTOMATIC_RETRIES = 3; + +private: + NexusInterface* m_NexusInterface; + + OrganizerCore* m_OrganizerCore; + QWidget* m_ParentWidget{nullptr}; + + QVector> m_PendingDownloads; + + QVector m_ActiveDownloads; + + QString m_OutputDirectory; + std::set m_RequestIDs; + QVector m_AlphabeticalTranslation; + + QFileSystemWatcher m_DirWatcher; + + SignalDownloadCallback m_DownloadComplete; + SignalDownloadCallback m_DownloadPaused; + SignalDownloadCallback m_DownloadFailed; + SignalDownloadCallback m_DownloadRemoved; + + // The dirWatcher is actually triggering off normal Mo operations such as deleting + // downloads or editing .meta files so it needs to be disabled during operations that + // are known to cause the creation or deletion of files in the Downloads folder. + // Notably using QSettings to edit a file creates a temporarily .lock file that causes + // the Watcher to trigger multiple listRefreshes freezing the ui. + static int m_DirWatcherDisabler; + + std::map m_DownloadFails; + + bool m_ShowHidden{false}; + + MOBase::IPluginGame const* m_ManagedGame; + + QTimer m_TimeoutTimer; +}; + +class ScopedDisableDirWatcher +{ +public: + ScopedDisableDirWatcher(DownloadManager* downloadManager); + ~ScopedDisableDirWatcher(); + +private: + DownloadManager* m_downloadManager; +}; + +#endif // DOWNLOADMANAGER_H diff --git a/src/src/downloadstab.cpp b/src/src/downloadstab.cpp index 004ff98..3c85a57 100644 --- a/src/src/downloadstab.cpp +++ b/src/src/downloadstab.cpp @@ -1,110 +1,110 @@ -#include "downloadstab.h" -#include "downloadlist.h" -#include "downloadlistview.h" -#include "organizercore.h" -#include "ui_mainwindow.h" - -DownloadsTab::DownloadsTab(OrganizerCore& core, Ui::MainWindow* mwui) - : m_core(core), - ui{mwui->btnRefreshDownloads, mwui->btnQueryDownloadsInfo, mwui->downloadView, - mwui->showHiddenBox, mwui->downloadFilterEdit} -{ - DownloadList* sourceModel = new DownloadList(m_core, ui.list); - - ui.list->setModel(sourceModel); - ui.list->setManager(m_core.downloadManager()); - ui.list->setItemDelegate( - new DownloadProgressDelegate(m_core.downloadManager(), ui.list)); - - update(); - - m_filter.setEdit(ui.filter); - m_filter.setList(ui.list); - m_filter.setSortPredicate([sourceModel](auto&& left, auto&& right) { - return sourceModel->lessThanPredicate(left, right); - }); - - connect(ui.refresh, &QPushButton::clicked, [&] { - refresh(); - }); - connect(ui.queryInfos, &QPushButton::clicked, [&] { - queryInfos(); - }); - connect(ui.list, SIGNAL(installDownload(int)), &m_core, SLOT(installDownload(int))); - connect(ui.list, SIGNAL(queryInfo(int)), m_core.downloadManager(), - SLOT(queryInfo(int))); - connect(ui.list, SIGNAL(queryInfoMd5(int)), m_core.downloadManager(), - SLOT(queryInfoMd5(int))); - connect(ui.list, SIGNAL(visitOnNexus(int)), m_core.downloadManager(), - SLOT(visitOnNexus(int))); - connect(ui.list, SIGNAL(visitUploaderProfile(int)), m_core.downloadManager(), - SLOT(visitUploaderProfile(int))); - connect(ui.list, SIGNAL(openFile(int)), m_core.downloadManager(), - SLOT(openFile(int))); - connect(ui.list, SIGNAL(openMetaFile(int)), m_core.downloadManager(), - SLOT(openMetaFile(int))); - connect(ui.list, SIGNAL(openInDownloadsFolder(int)), m_core.downloadManager(), - SLOT(openInDownloadsFolder(int))); - connect(ui.list, SIGNAL(removeDownload(int, bool)), m_core.downloadManager(), - SLOT(removeDownload(int, bool))); - connect(ui.list, SIGNAL(restoreDownload(int)), m_core.downloadManager(), - SLOT(restoreDownload(int))); - connect(ui.list, SIGNAL(cancelDownload(int)), m_core.downloadManager(), - SLOT(cancelDownload(int))); - connect(ui.list, SIGNAL(pauseDownload(int)), m_core.downloadManager(), - SLOT(pauseDownload(int))); - connect(ui.list, &DownloadListView::resumeDownload, [&](int i) { - resumeDownload(i); - }); -} - -void DownloadsTab::update() -{ - // this means downloadTab initialization hasn't happened yet - if (ui.list->model() == nullptr) { - return; - } - - // set the view attribute and default row sizes - if (m_core.settings().interface().compactDownloads()) { - ui.list->setProperty("downloadView", "compact"); - ui.list->setStyleSheet("DownloadListView::item { padding: 4px 2px; }"); - } else { - ui.list->setProperty("downloadView", "standard"); - ui.list->setStyleSheet("DownloadListView::item { padding: 16px 4px; }"); - } - - ui.list->style()->unpolish(ui.list); - ui.list->style()->polish(ui.list); - qobject_cast(ui.list->header())->customResizeSections(); - - m_core.downloadManager()->refreshList(); -} - -void DownloadsTab::refresh() -{ - m_core.downloadManager()->refreshList(); -} - -void DownloadsTab::queryInfos() -{ - if (m_core.settings().network().offlineMode()) { - if (QMessageBox::warning(nullptr, tr("Query Metadata"), - tr("Cannot query metadata while offline mode is enabled. " - "Do you want to disable offline mode?"), - QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { - m_core.settings().network().setOfflineMode(false); - } else { - return; - } - } - - m_core.downloadManager()->queryDownloadListInfo(); -} - -void DownloadsTab::resumeDownload(int downloadIndex) -{ - m_core.loggedInAction(ui.list, [this, downloadIndex] { - m_core.downloadManager()->resumeDownload(downloadIndex); - }); -} +#include "downloadstab.h" +#include "downloadlist.h" +#include "downloadlistview.h" +#include "organizercore.h" +#include "ui_mainwindow.h" + +DownloadsTab::DownloadsTab(OrganizerCore& core, Ui::MainWindow* mwui) + : m_core(core), + ui{.refresh=mwui->btnRefreshDownloads, .queryInfos=mwui->btnQueryDownloadsInfo, .list=mwui->downloadView, + .showHidden=mwui->showHiddenBox, .filter=mwui->downloadFilterEdit} +{ + DownloadList* sourceModel = new DownloadList(m_core, ui.list); + + ui.list->setModel(sourceModel); + ui.list->setManager(m_core.downloadManager()); + ui.list->setItemDelegate( + new DownloadProgressDelegate(m_core.downloadManager(), ui.list)); + + update(); + + m_filter.setEdit(ui.filter); + m_filter.setList(ui.list); + m_filter.setSortPredicate([sourceModel](auto&& left, auto&& right) { + return sourceModel->lessThanPredicate(left, right); + }); + + connect(ui.refresh, &QPushButton::clicked, [&] { + refresh(); + }); + connect(ui.queryInfos, &QPushButton::clicked, [&] { + queryInfos(); + }); + connect(ui.list, SIGNAL(installDownload(int)), &m_core, SLOT(installDownload(int))); + connect(ui.list, SIGNAL(queryInfo(int)), m_core.downloadManager(), + SLOT(queryInfo(int))); + connect(ui.list, SIGNAL(queryInfoMd5(int)), m_core.downloadManager(), + SLOT(queryInfoMd5(int))); + connect(ui.list, SIGNAL(visitOnNexus(int)), m_core.downloadManager(), + SLOT(visitOnNexus(int))); + connect(ui.list, SIGNAL(visitUploaderProfile(int)), m_core.downloadManager(), + SLOT(visitUploaderProfile(int))); + connect(ui.list, SIGNAL(openFile(int)), m_core.downloadManager(), + SLOT(openFile(int))); + connect(ui.list, SIGNAL(openMetaFile(int)), m_core.downloadManager(), + SLOT(openMetaFile(int))); + connect(ui.list, SIGNAL(openInDownloadsFolder(int)), m_core.downloadManager(), + SLOT(openInDownloadsFolder(int))); + connect(ui.list, SIGNAL(removeDownload(int, bool)), m_core.downloadManager(), + SLOT(removeDownload(int, bool))); + connect(ui.list, SIGNAL(restoreDownload(int)), m_core.downloadManager(), + SLOT(restoreDownload(int))); + connect(ui.list, SIGNAL(cancelDownload(int)), m_core.downloadManager(), + SLOT(cancelDownload(int))); + connect(ui.list, SIGNAL(pauseDownload(int)), m_core.downloadManager(), + SLOT(pauseDownload(int))); + connect(ui.list, &DownloadListView::resumeDownload, [&](int i) { + resumeDownload(i); + }); +} + +void DownloadsTab::update() +{ + // this means downloadTab initialization hasn't happened yet + if (ui.list->model() == nullptr) { + return; + } + + // set the view attribute and default row sizes + if (m_core.settings().interface().compactDownloads()) { + ui.list->setProperty("downloadView", "compact"); + ui.list->setStyleSheet("DownloadListView::item { padding: 4px 2px; }"); + } else { + ui.list->setProperty("downloadView", "standard"); + ui.list->setStyleSheet("DownloadListView::item { padding: 16px 4px; }"); + } + + ui.list->style()->unpolish(ui.list); + ui.list->style()->polish(ui.list); + qobject_cast(ui.list->header())->customResizeSections(); + + m_core.downloadManager()->refreshList(); +} + +void DownloadsTab::refresh() +{ + m_core.downloadManager()->refreshList(); +} + +void DownloadsTab::queryInfos() +{ + if (m_core.settings().network().offlineMode()) { + if (QMessageBox::warning(nullptr, tr("Query Metadata"), + tr("Cannot query metadata while offline mode is enabled. " + "Do you want to disable offline mode?"), + QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { + m_core.settings().network().setOfflineMode(false); + } else { + return; + } + } + + m_core.downloadManager()->queryDownloadListInfo(); +} + +void DownloadsTab::resumeDownload(int downloadIndex) +{ + m_core.loggedInAction(ui.list, [this, downloadIndex] { + m_core.downloadManager()->resumeDownload(downloadIndex); + }); +} diff --git a/src/src/envmodule.cpp b/src/src/envmodule.cpp index 0cfc6e5..2086fd8 100644 --- a/src/src/envmodule.cpp +++ b/src/src/envmodule.cpp @@ -11,7 +11,7 @@ #include #include #include -#include +#include namespace env { @@ -112,7 +112,7 @@ QString Module::toString() const return sl.join(", "); } -Module::FileInfo Module::getFileInfo() +Module::FileInfo Module::getFileInfo() { // ELF .so files don't carry the equivalent of a Win32 PE version resource. return {}; @@ -129,14 +129,10 @@ QDateTime Module::getTimestamp() const bool Module::interesting() const { - if (m_path.startsWith("/usr/lib/", Qt::CaseInsensitive) || + return !(m_path.startsWith("/usr/lib/", Qt::CaseInsensitive) || m_path.startsWith("/usr/lib64/", Qt::CaseInsensitive) || m_path.startsWith("/lib/", Qt::CaseInsensitive) || - m_path.startsWith("/lib64/", Qt::CaseInsensitive)) { - return false; - } - - return true; + m_path.startsWith("/lib64/", Qt::CaseInsensitive)); } QString Module::getMD5() const @@ -269,7 +265,7 @@ std::vector getLoadedModules() QFileInfo const fi(qpath); if (fi.exists()) { - v.push_back(Module(qpath, fi.size())); + v.emplace_back(qpath, fi.size()); } } @@ -333,7 +329,7 @@ std::vector getRunningProcesses() } } - v.push_back(Process(pid, ppid, name)); + v.emplace_back(pid, ppid, name); } closedir(procDir); diff --git a/src/src/envshortcut.cpp b/src/src/envshortcut.cpp index 63d3eff..45ae80d 100644 --- a/src/src/envshortcut.cpp +++ b/src/src/envshortcut.cpp @@ -1,570 +1,572 @@ #include "envshortcut.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "executableslist.h" -#include "instancemanager.h" - -namespace env -{ - -// Returns the path to the AppImage file itself, or falls back to the -// running binary if not running from an AppImage. -static QString appImageOrBinary() -{ - QString appImage = QProcessEnvironment::systemEnvironment().value("APPIMAGE"); - if (!appImage.isEmpty() && QFile::exists(appImage)) { - return appImage; - } - // Prefer the fluorine-manager launcher script over the bare ModOrganizer-core - // binary — the launcher sets up bundled library paths, Qt plugin paths, etc. - // Without it, shortcuts fail on systems that don't have all deps in PATH. - const QString appDir = QCoreApplication::applicationDirPath(); - const QString launcher = appDir + "/fluorine-manager"; - if (QFile::exists(launcher)) { - return QFileInfo(launcher).absoluteFilePath(); - } - return QFileInfo(qApp->applicationFilePath()).absoluteFilePath(); -} - -Shortcut::Shortcut() : m_iconIndex(0) {} - -// Sanitize a string for use in a .desktop filename (replace spaces/special chars). -static QString sanitizeDesktopName(const QString& s) -{ - QString result; - for (const QChar& c : s) { - if (c.isLetterOrNumber() || c == '-' || c == '_' || c == '.') { - result += c; - } else if (c == ' ') { - result += '-'; - } - } - return result; -} - -// --------------------------------------------------------------------------- -// PE icon extractor — reads the largest icon embedded in a Windows .exe -// and returns it as a QImage. Returns a null QImage on failure. -// --------------------------------------------------------------------------- -static QImage extractIconFromExe(const QString& exePath) -{ - QFile f(exePath); - if (!f.open(QIODevice::ReadOnly)) - return {}; - - auto peek = [&](qint64 offset, qint64 size) -> QByteArray { - if (offset < 0 || offset + size > f.size()) - return {}; - f.seek(offset); - return f.read(size); - }; - - auto u16 = [](const char* p) -> quint16 { - return static_cast(static_cast(p[0])) | - (static_cast(static_cast(p[1])) << 8); - }; - auto u32 = [](const char* p) -> quint32 { - return static_cast(static_cast(p[0])) | - (static_cast(static_cast(p[1])) << 8) | - (static_cast(static_cast(p[2])) << 16) | - (static_cast(static_cast(p[3])) << 24); - }; - - // DOS header: check MZ magic, read PE offset at 0x3C. - QByteArray dosHdr = peek(0, 64); - if (dosHdr.size() < 64 || dosHdr[0] != 'M' || dosHdr[1] != 'Z') - return {}; - - quint32 const peOff = u32(dosHdr.constData() + 0x3C); - - // PE signature. - QByteArray peSig = peek(peOff, 4); - if (peSig.size() < 4 || peSig[0] != 'P' || peSig[1] != 'E') - return {}; - - // COFF header (20 bytes) immediately after the 4-byte PE signature. - QByteArray const coffHdr = peek(peOff + 4, 20); - if (coffHdr.size() < 20) - return {}; - quint16 const numSections = u16(coffHdr.constData() + 2); - quint16 const optionalHdrSize = u16(coffHdr.constData() + 16); - - // Optional header — we need the magic (PE32 vs PE32+) and the - // resource data directory entry (index 2). - qint64 const optOff = peOff + 4 + 20; - QByteArray const optHdr = peek(optOff, optionalHdrSize); - if (optHdr.size() < 4) - return {}; - - quint16 const magic = u16(optHdr.constData()); - int ddOffset; // offset of DataDirectory[0] inside optional header - if (magic == 0x10b) // PE32 - ddOffset = 96; - else if (magic == 0x20b) // PE32+ - ddOffset = 112; - else - return {}; - - // DataDirectory[2] = resource table (each entry is 8 bytes: RVA + Size). - int const rsrcDDOff = ddOffset + 2 * 8; - if (rsrcDDOff + 8 > optHdr.size()) - return {}; - quint32 rsrcRVA = u32(optHdr.constData() + rsrcDDOff); - if (rsrcRVA == 0) - return {}; - - // Section headers — find the section containing rsrcRVA. - qint64 const secOff = optOff + optionalHdrSize; - quint32 rsrcFileOff = 0; - quint32 rsrcVA = 0; - for (int i = 0; i < numSections; ++i) { - QByteArray const sec = peek(secOff + i * 40, 40); - if (sec.size() < 40) - return {}; - quint32 const virtAddr = u32(sec.constData() + 12); - quint32 const virtSize = u32(sec.constData() + 8); - quint32 const rawOff = u32(sec.constData() + 20); - if (rsrcRVA >= virtAddr && rsrcRVA < virtAddr + virtSize) { - rsrcFileOff = rawOff + (rsrcRVA - virtAddr); - rsrcVA = virtAddr; - break; - } - } - if (rsrcFileOff == 0) - return {}; - - // Helper: convert an RVA inside the .rsrc section to a file offset. - auto rvaToFile = [&](quint32 rva) -> qint64 { - return static_cast(rsrcFileOff) + (rva - rsrcRVA); - }; - - // Parse a resource directory and return (id, offset, isDir) entries. - struct DirEntry { quint32 id; quint32 offset; bool isDir; }; - auto readDir = [&](quint32 dirFileOff) -> std::vector { - std::vector entries; - QByteArray const dh = peek(dirFileOff, 16); - if (dh.size() < 16) return entries; - quint16 const numNamed = u16(dh.constData() + 12); - quint16 const numId = u16(dh.constData() + 14); - int const total = numNamed + numId; - QByteArray const ea = peek(dirFileOff + 16, total * 8); - if (ea.size() < total * 8) return entries; - for (int i = 0; i < total; ++i) { - quint32 const nameOrId = u32(ea.constData() + i * 8); - quint32 off = u32(ea.constData() + i * 8 + 4); - bool const isDir = (off & 0x80000000u) != 0; - off &= 0x7FFFFFFFu; - entries.push_back({nameOrId, off, isDir}); - } - return entries; - }; - - // Level 0: find RT_GROUP_ICON (14) and RT_ICON (3). - auto level0 = readDir(rsrcFileOff); - quint32 groupIconOff = 0, iconOff = 0; - for (auto& e : level0) { - if (e.id == 14 && e.isDir) groupIconOff = rsrcFileOff + e.offset; - if (e.id == 3 && e.isDir) iconOff = rsrcFileOff + e.offset; - } - if (groupIconOff == 0 || iconOff == 0) - return {}; - - // Level 1 of RT_GROUP_ICON: pick the first group. - auto groupL1 = readDir(groupIconOff); - if (groupL1.empty() || !groupL1[0].isDir) - return {}; - - // Level 2: pick the first language. - auto groupL2 = readDir(rsrcFileOff + groupL1[0].offset); - if (groupL2.empty() || groupL2[0].isDir) - return {}; - - // Read the data entry (RVA + size). - QByteArray const dataEntry = peek(rsrcFileOff + groupL2[0].offset, 16); - if (dataEntry.size() < 16) - return {}; - quint32 const grpDataRVA = u32(dataEntry.constData()); - quint32 const grpDataSize = u32(dataEntry.constData() + 4); - QByteArray const grpData = peek(rvaToFile(grpDataRVA), grpDataSize); - if (grpData.size() < 6) - return {}; - - // Parse the GRPICONDIR: pick the icon entry with the largest area. - quint16 const iconCount = u16(grpData.constData() + 4); - if (grpData.size() < 6 + iconCount * 14) - return {}; - - int bestIdx = -1; - int bestArea = 0; - quint16 bestId = 0; - for (int i = 0; i < iconCount; ++i) { - const char* e = grpData.constData() + 6 + i * 14; - int w = static_cast(e[0]); - int h = static_cast(e[1]); - if (w == 0) w = 256; - if (h == 0) h = 256; - int const area = w * h; - if (area > bestArea) { - bestArea = area; - bestIdx = i; - bestId = u16(e + 12); - } - } - if (bestIdx < 0) - return {}; - - // Find RT_ICON with matching ID. - auto iconL1 = readDir(iconOff); - quint32 iconEntryOff = 0; - for (auto& e : iconL1) { - if (e.id == bestId && e.isDir) { - auto iconL2 = readDir(rsrcFileOff + e.offset); - if (!iconL2.empty() && !iconL2[0].isDir) - iconEntryOff = rsrcFileOff + iconL2[0].offset; - break; - } - } - if (iconEntryOff == 0) - return {}; - - QByteArray const iconDE = peek(iconEntryOff, 16); - if (iconDE.size() < 16) - return {}; - quint32 const iconDataRVA = u32(iconDE.constData()); - quint32 const iconDataSize = u32(iconDE.constData() + 4); - QByteArray const iconData = peek(rvaToFile(iconDataRVA), iconDataSize); - if (iconData.isEmpty()) - return {}; - - // Try loading as PNG first (256x256 icons are typically stored as PNG). - QImage img; - img.loadFromData(iconData, "PNG"); - if (!img.isNull()) - return img; - - // Otherwise it's a raw BITMAPINFOHEADER (DIB). Wrap it in a minimal - // .ico so Qt's ICO reader can handle it. - QByteArray ico; - QBuffer buf(&ico); - buf.open(QIODevice::WriteOnly); - // ICONDIR header - const char icoHdr[6] = {0, 0, 1, 0, 1, 0}; // reserved=0, type=1, count=1 - buf.write(icoHdr, 6); - // ICONDIRENTRY (use the values from the group icon entry) - const char* grpE = grpData.constData() + 6 + bestIdx * 14; - char entry[16]; - memcpy(entry, grpE, 12); // copy w,h,colorCount,reserved,planes,bitCount,size - quint32 const dataOff = 6 + 16; // offset to icon data = after header + 1 entry - entry[12] = dataOff & 0xFF; - entry[13] = (dataOff >> 8) & 0xFF; - entry[14] = (dataOff >> 16) & 0xFF; - entry[15] = (dataOff >> 24) & 0xFF; - // Fix the size field (bytes 8-11) to match actual data size - entry[8] = iconDataSize & 0xFF; - entry[9] = (iconDataSize >> 8) & 0xFF; - entry[10] = (iconDataSize >> 16) & 0xFF; - entry[11] = (iconDataSize >> 24) & 0xFF; - buf.write(entry, 16); - buf.write(iconData); - buf.close(); - - img.loadFromData(ico, "ICO"); - return img; -} - -// Return the path to the bundled Fluorine icon inside the AppImage, -// or the installed hicolor copy. Empty string if neither exists. -static QString bundledFluorineIcon() -{ - QString const appDir = QProcessEnvironment::systemEnvironment().value("APPDIR"); - if (!appDir.isEmpty()) { - QString bundled = appDir + "/usr/share/icons/hicolor/256x256/apps/com.fluorine.manager.png"; - if (QFile::exists(bundled)) - return bundled; - } - QString hicolor = QDir::homePath() + - "/.local/share/icons/hicolor/256x256/apps/com.fluorine.manager.png"; - if (QFile::exists(hicolor)) - return hicolor; - return {}; -} - -// Check whether an icon file is just the Fluorine fallback icon -// (same file size as the bundled one). -static bool isFallbackIcon(const QString& iconPath) -{ - QString const bundled = bundledFluorineIcon(); - if (bundled.isEmpty()) - return false; - return QFileInfo(iconPath).size() == QFileInfo(bundled).size(); -} - -// Install a game-specific icon to ~/.local/share/icons/fluorine/ and return -// the absolute path. Tries to extract the icon from exePath (.exe) first, -// then falls back to the bundled Fluorine icon. -// -// If a cached icon exists but is just the fallback, re-attempts extraction -// in case the executable has changed or was previously unavailable. -static QString installIcon(const QString& iconBaseName, const QString& exePath = {}) -{ - QString const iconDir = QDir::homePath() + "/.local/share/icons/fluorine"; - QString iconDest = iconDir + "/" + iconBaseName + ".png"; - - // If the icon already exists and is NOT the fallback, keep it. - if (QFile::exists(iconDest) && !isFallbackIcon(iconDest)) { - return iconDest; - } - - QDir().mkpath(iconDir); - - // Try to extract the icon from the .exe file. - if (!exePath.isEmpty()) { - QImage icon = extractIconFromExe(exePath); - if (!icon.isNull()) { - if (icon.width() != 256 || icon.height() != 256) { - icon = icon.scaled(256, 256, Qt::KeepAspectRatio, Qt::SmoothTransformation); - } - // Remove stale fallback before saving the real icon. - QFile::remove(iconDest); - if (icon.save(iconDest, "PNG")) { - return iconDest; - } - } - } - - // Already have a fallback cached — no need to copy again. - if (QFile::exists(iconDest)) { - return iconDest; - } - - // Install the bundled Fluorine icon as a fallback. - QString const bundled = bundledFluorineIcon(); - if (!bundled.isEmpty()) { - QFile::copy(bundled, iconDest); - return iconDest; - } - - return "com.fluorine.manager"; -} - -Shortcut::Shortcut(const Executable& exe) : Shortcut() -{ - const auto i = *InstanceManager::singleton().currentInstance(); - - m_name = exe.title(); - m_instanceName = i.displayName(); - m_target = appImageOrBinary(); - - // For portable instances, use the absolute directory path so MO2 can - // find it (line 595 in instancemanager.cpp handles abs path lookup). - // For global instances, use the display name. - QString const instanceId = i.isPortable() ? QDir(i.directory()).absolutePath() - : i.displayName(); - m_arguments = QString("\"moshortcut://%1:%2\"") - .arg(instanceId) - .arg(exe.title()); - - m_description = QString("Run %1 with Fluorine").arg(exe.title()); - - // Try to extract the icon from the executable (works for .exe files). - // For native Linux binaries with a custom icon, use that directly. - // Falls back to the bundled Fluorine icon. - QString const exePath = exe.binaryInfo().absoluteFilePath(); - if (exe.usesOwnIcon() && !exePath.endsWith(".exe", Qt::CaseInsensitive)) { - m_icon = exePath; - } - if (m_icon.isEmpty()) { - QString const iconBase = sanitizeDesktopName(m_instanceName) + "-" + - sanitizeDesktopName(m_name); - m_icon = installIcon(iconBase, exePath); - } - - m_workingDirectory = QFileInfo(m_target).absolutePath(); -} - -Shortcut& Shortcut::name(const QString& s) -{ - m_name = s; - return *this; -} - -Shortcut& Shortcut::target(const QString& s) -{ - m_target = s; - return *this; -} - -Shortcut& Shortcut::arguments(const QString& s) -{ - m_arguments = s; - return *this; -} - -Shortcut& Shortcut::description(const QString& s) -{ - m_description = s; - return *this; -} - -Shortcut& Shortcut::icon(const QString& s, int index) -{ - m_icon = s; - m_iconIndex = index; - return *this; -} - -Shortcut& Shortcut::workingDirectory(const QString& s) -{ - m_workingDirectory = s; - return *this; -} - -bool Shortcut::exists(Locations loc) const -{ - return QFile::exists(shortcutPath(loc)); -} - -bool Shortcut::toggle(Locations loc) -{ - if (exists(loc)) { - return remove(loc); - } else { - return add(loc); - } -} - -bool Shortcut::add(Locations loc) -{ - if (loc != Desktop && loc != ApplicationMenu) { - return false; - } - - const QString path = shortcutPath(loc); - if (path.isEmpty()) { - return false; - } - - QDir().mkpath(QFileInfo(path).absolutePath()); - - QFile file(path); - if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { - return false; - } - - // Include instance name in the display name to distinguish shortcuts - // for the same executable across different instances (e.g. "NVSE (New Vegas)" - // vs "NVSE (New Vegas 2)"). - QString displayName = m_name.isEmpty() ? "Fluorine" : m_name; - if (!m_instanceName.isEmpty()) { - displayName += " (" + m_instanceName + ")"; - } - - QTextStream out(&file); - out << "[Desktop Entry]\n"; - out << "Type=Application\n"; - out << "Name=" << displayName << "\n"; - if (!m_description.isEmpty()) { - out << "Comment=" << m_description << "\n"; - } - // .desktop Exec values require quoting paths that contain spaces - out << "Exec=\"" << m_target << "\""; - if (!m_arguments.isEmpty()) { - out << " " << m_arguments; - } - out << "\n"; - if (!m_workingDirectory.isEmpty()) { - out << "Path=" << m_workingDirectory << "\n"; - } - if (!m_icon.isEmpty()) { - out << "Icon=" << m_icon << "\n"; - } - out << "Terminal=false\n"; - out << "Categories=Game;\n"; - - file.close(); - - // Make it executable (required by some desktop environments) - file.setPermissions(file.permissions() | QFileDevice::ExeUser); - - return true; -} - -bool Shortcut::remove(Locations loc) -{ - const QString path = shortcutPath(loc); - if (path.isEmpty()) { - return false; - } - return QFile::remove(path); -} - -QString Shortcut::shortcutPath(Locations loc) const -{ - const QString dir = shortcutDirectory(loc); - if (dir.isEmpty()) { - return {}; - } - return dir + "/" + shortcutFilename(); -} - -QString Shortcut::shortcutDirectory(Locations loc) -{ - if (loc == Desktop) { - // XDG desktop directory - QString desktop = QStandardPaths::writableLocation(QStandardPaths::DesktopLocation); - if (!desktop.isEmpty()) { - return desktop; - } - return QDir::homePath() + "/Desktop"; - } - if (loc == ApplicationMenu) { - return QDir::homePath() + "/.local/share/applications"; - } - return {}; -} - -QString Shortcut::shortcutFilename() const -{ - if (m_name.isEmpty()) { - return "fluorine.desktop"; - } - // Include instance name in the filename to avoid collisions when - // multiple instances have the same executable (e.g. BodySlide_x64 - // for both FNV and SkyrimSE). Example: "New Vegas-NVSE.desktop" - QString base = m_name; - if (!m_instanceName.isEmpty()) { - base = sanitizeDesktopName(m_instanceName) + "-" + base; - } - return base + ".desktop"; -} - -QString toString(Shortcut::Locations loc) -{ - switch (loc) { - case Shortcut::None: - return "none"; - - case Shortcut::Desktop: - return "desktop"; - - case Shortcut::StartMenu: - return "start menu"; - - case Shortcut::ApplicationMenu: - return "application menu"; - - default: - return QString("? (%1)").arg(static_cast(loc)); - } -} - -} // namespace env +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "executableslist.h" +#include "instancemanager.h" + +namespace env +{ + +// Returns the path to the AppImage file itself, or falls back to the +// running binary if not running from an AppImage. +static QString appImageOrBinary() +{ + QString appImage = QProcessEnvironment::systemEnvironment().value("APPIMAGE"); + if (!appImage.isEmpty() && QFile::exists(appImage)) { + return appImage; + } + // Prefer the fluorine-manager launcher script over the bare ModOrganizer-core + // binary — the launcher sets up bundled library paths, Qt plugin paths, etc. + // Without it, shortcuts fail on systems that don't have all deps in PATH. + const QString appDir = QCoreApplication::applicationDirPath(); + const QString launcher = appDir + "/fluorine-manager"; + if (QFile::exists(launcher)) { + return QFileInfo(launcher).absoluteFilePath(); + } + return QFileInfo(qApp->applicationFilePath()).absoluteFilePath(); +} + +Shortcut::Shortcut() : m_iconIndex(0) {} + +// Sanitize a string for use in a .desktop filename (replace spaces/special chars). +static QString sanitizeDesktopName(const QString& s) +{ + QString result; + for (const QChar& c : s) { + if (c.isLetterOrNumber() || c == '-' || c == '_' || c == '.') { + result += c; + } else if (c == ' ') { + result += '-'; + } + } + return result; +} + +// --------------------------------------------------------------------------- +// PE icon extractor — reads the largest icon embedded in a Windows .exe +// and returns it as a QImage. Returns a null QImage on failure. +// --------------------------------------------------------------------------- +static QImage extractIconFromExe(const QString& exePath) +{ + QFile f(exePath); + if (!f.open(QIODevice::ReadOnly)) + return {}; + + auto peek = [&](qint64 offset, qint64 size) -> QByteArray { + if (offset < 0 || offset + size > f.size()) + return {}; + f.seek(offset); + return f.read(size); + }; + + auto u16 = [](const char* p) -> quint16 { + return static_cast(static_cast(p[0])) | + (static_cast(static_cast(p[1])) << 8); + }; + auto u32 = [](const char* p) -> quint32 { + return static_cast(static_cast(p[0])) | + (static_cast(static_cast(p[1])) << 8) | + (static_cast(static_cast(p[2])) << 16) | + (static_cast(static_cast(p[3])) << 24); + }; + + // DOS header: check MZ magic, read PE offset at 0x3C. + QByteArray dosHdr = peek(0, 64); + if (dosHdr.size() < 64 || dosHdr[0] != 'M' || dosHdr[1] != 'Z') + return {}; + + quint32 const peOff = u32(dosHdr.constData() + 0x3C); + + // PE signature. + QByteArray peSig = peek(peOff, 4); + if (peSig.size() < 4 || peSig[0] != 'P' || peSig[1] != 'E') + return {}; + + // COFF header (20 bytes) immediately after the 4-byte PE signature. + QByteArray const coffHdr = peek(peOff + 4, 20); + if (coffHdr.size() < 20) + return {}; + quint16 const numSections = u16(coffHdr.constData() + 2); + quint16 const optionalHdrSize = u16(coffHdr.constData() + 16); + + // Optional header — we need the magic (PE32 vs PE32+) and the + // resource data directory entry (index 2). + qint64 const optOff = peOff + 4 + 20; + QByteArray const optHdr = peek(optOff, optionalHdrSize); + if (optHdr.size() < 4) + return {}; + + quint16 const magic = u16(optHdr.constData()); + int ddOffset; // offset of DataDirectory[0] inside optional header + if (magic == 0x10b) // PE32 + ddOffset = 96; + else if (magic == 0x20b) // PE32+ + ddOffset = 112; + else + return {}; + + // DataDirectory[2] = resource table (each entry is 8 bytes: RVA + Size). + int const rsrcDDOff = ddOffset + 2 * 8; + if (rsrcDDOff + 8 > optHdr.size()) + return {}; + quint32 rsrcRVA = u32(optHdr.constData() + rsrcDDOff); + if (rsrcRVA == 0) + return {}; + + // Section headers — find the section containing rsrcRVA. + qint64 const secOff = optOff + optionalHdrSize; + quint32 rsrcFileOff = 0; + quint32 rsrcVA = 0; + for (int i = 0; std::cmp_less(i , numSections); ++i) { + QByteArray const sec = peek(secOff + i * 40, 40); + if (sec.size() < 40) + return {}; + quint32 const virtAddr = u32(sec.constData() + 12); + quint32 const virtSize = u32(sec.constData() + 8); + quint32 const rawOff = u32(sec.constData() + 20); + if (rsrcRVA >= virtAddr && rsrcRVA < virtAddr + virtSize) { + rsrcFileOff = rawOff + (rsrcRVA - virtAddr); + rsrcVA = virtAddr; + break; + } + } + if (rsrcFileOff == 0) + return {}; + + // Helper: convert an RVA inside the .rsrc section to a file offset. + auto rvaToFile = [&](quint32 rva) -> qint64 { + return static_cast(rsrcFileOff) + (rva - rsrcRVA); + }; + + // Parse a resource directory and return (id, offset, isDir) entries. + struct DirEntry { quint32 id; quint32 offset; bool isDir; }; + auto readDir = [&](quint32 dirFileOff) -> std::vector { + std::vector entries; + QByteArray const dh = peek(dirFileOff, 16); + if (dh.size() < 16) return entries; + quint16 const numNamed = u16(dh.constData() + 12); + quint16 const numId = u16(dh.constData() + 14); + int const total = numNamed + numId; + QByteArray const ea = peek(dirFileOff + 16, total * 8); + if (ea.size() < total * 8) return entries; + for (int i = 0; i < total; ++i) { + quint32 const nameOrId = u32(ea.constData() + i * 8); + quint32 off = u32(ea.constData() + i * 8 + 4); + bool const isDir = (off & 0x80000000u) != 0; + off &= 0x7FFFFFFFu; + entries.push_back({nameOrId, off, isDir}); + } + return entries; + }; + + // Level 0: find RT_GROUP_ICON (14) and RT_ICON (3). + auto level0 = readDir(rsrcFileOff); + quint32 groupIconOff = 0; + quint32 iconOff = 0; + for (auto& e : level0) { + if (e.id == 14 && e.isDir) groupIconOff = rsrcFileOff + e.offset; + if (e.id == 3 && e.isDir) iconOff = rsrcFileOff + e.offset; + } + if (groupIconOff == 0 || iconOff == 0) + return {}; + + // Level 1 of RT_GROUP_ICON: pick the first group. + auto groupL1 = readDir(groupIconOff); + if (groupL1.empty() || !groupL1[0].isDir) + return {}; + + // Level 2: pick the first language. + auto groupL2 = readDir(rsrcFileOff + groupL1[0].offset); + if (groupL2.empty() || groupL2[0].isDir) + return {}; + + // Read the data entry (RVA + size). + QByteArray const dataEntry = peek(rsrcFileOff + groupL2[0].offset, 16); + if (dataEntry.size() < 16) + return {}; + quint32 const grpDataRVA = u32(dataEntry.constData()); + quint32 const grpDataSize = u32(dataEntry.constData() + 4); + QByteArray const grpData = peek(rvaToFile(grpDataRVA), grpDataSize); + if (grpData.size() < 6) + return {}; + + // Parse the GRPICONDIR: pick the icon entry with the largest area. + quint16 const iconCount = u16(grpData.constData() + 4); + if (grpData.size() < 6 + iconCount * 14) + return {}; + + int bestIdx = -1; + int bestArea = 0; + quint16 bestId = 0; + for (int i = 0; std::cmp_less(i , iconCount); ++i) { + const char* e = grpData.constData() + 6 + i * 14; + int w = static_cast(e[0]); + int h = static_cast(e[1]); + if (w == 0) w = 256; + if (h == 0) h = 256; + int const area = w * h; + if (area > bestArea) { + bestArea = area; + bestIdx = i; + bestId = u16(e + 12); + } + } + if (bestIdx < 0) + return {}; + + // Find RT_ICON with matching ID. + auto iconL1 = readDir(iconOff); + quint32 iconEntryOff = 0; + for (auto& e : iconL1) { + if (e.id == bestId && e.isDir) { + auto iconL2 = readDir(rsrcFileOff + e.offset); + if (!iconL2.empty() && !iconL2[0].isDir) + iconEntryOff = rsrcFileOff + iconL2[0].offset; + break; + } + } + if (iconEntryOff == 0) + return {}; + + QByteArray const iconDE = peek(iconEntryOff, 16); + if (iconDE.size() < 16) + return {}; + quint32 const iconDataRVA = u32(iconDE.constData()); + quint32 const iconDataSize = u32(iconDE.constData() + 4); + QByteArray const iconData = peek(rvaToFile(iconDataRVA), iconDataSize); + if (iconData.isEmpty()) + return {}; + + // Try loading as PNG first (256x256 icons are typically stored as PNG). + QImage img; + img.loadFromData(iconData, "PNG"); + if (!img.isNull()) + return img; + + // Otherwise it's a raw BITMAPINFOHEADER (DIB). Wrap it in a minimal + // .ico so Qt's ICO reader can handle it. + QByteArray ico; + QBuffer buf(&ico); + buf.open(QIODevice::WriteOnly); + // ICONDIR header + const char icoHdr[6] = {0, 0, 1, 0, 1, 0}; // reserved=0, type=1, count=1 + buf.write(icoHdr, 6); + // ICONDIRENTRY (use the values from the group icon entry) + const char* grpE = grpData.constData() + 6 + bestIdx * 14; + char entry[16]; + memcpy(entry, grpE, 12); // copy w,h,colorCount,reserved,planes,bitCount,size + quint32 const dataOff = 6 + 16; // offset to icon data = after header + 1 entry + entry[12] = dataOff & 0xFF; + entry[13] = (dataOff >> 8) & 0xFF; + entry[14] = (dataOff >> 16) & 0xFF; + entry[15] = (dataOff >> 24) & 0xFF; + // Fix the size field (bytes 8-11) to match actual data size + entry[8] = iconDataSize & 0xFF; + entry[9] = (iconDataSize >> 8) & 0xFF; + entry[10] = (iconDataSize >> 16) & 0xFF; + entry[11] = (iconDataSize >> 24) & 0xFF; + buf.write(entry, 16); + buf.write(iconData); + buf.close(); + + img.loadFromData(ico, "ICO"); + return img; +} + +// Return the path to the bundled Fluorine icon inside the AppImage, +// or the installed hicolor copy. Empty string if neither exists. +static QString bundledFluorineIcon() +{ + QString const appDir = QProcessEnvironment::systemEnvironment().value("APPDIR"); + if (!appDir.isEmpty()) { + QString bundled = appDir + "/usr/share/icons/hicolor/256x256/apps/com.fluorine.manager.png"; + if (QFile::exists(bundled)) + return bundled; + } + QString hicolor = QDir::homePath() + + "/.local/share/icons/hicolor/256x256/apps/com.fluorine.manager.png"; + if (QFile::exists(hicolor)) + return hicolor; + return {}; +} + +// Check whether an icon file is just the Fluorine fallback icon +// (same file size as the bundled one). +static bool isFallbackIcon(const QString& iconPath) +{ + QString const bundled = bundledFluorineIcon(); + if (bundled.isEmpty()) + return false; + return QFileInfo(iconPath).size() == QFileInfo(bundled).size(); +} + +// Install a game-specific icon to ~/.local/share/icons/fluorine/ and return +// the absolute path. Tries to extract the icon from exePath (.exe) first, +// then falls back to the bundled Fluorine icon. +// +// If a cached icon exists but is just the fallback, re-attempts extraction +// in case the executable has changed or was previously unavailable. +static QString installIcon(const QString& iconBaseName, const QString& exePath = {}) +{ + QString const iconDir = QDir::homePath() + "/.local/share/icons/fluorine"; + QString iconDest = iconDir + "/" + iconBaseName + ".png"; + + // If the icon already exists and is NOT the fallback, keep it. + if (QFile::exists(iconDest) && !isFallbackIcon(iconDest)) { + return iconDest; + } + + QDir().mkpath(iconDir); + + // Try to extract the icon from the .exe file. + if (!exePath.isEmpty()) { + QImage icon = extractIconFromExe(exePath); + if (!icon.isNull()) { + if (icon.width() != 256 || icon.height() != 256) { + icon = icon.scaled(256, 256, Qt::KeepAspectRatio, Qt::SmoothTransformation); + } + // Remove stale fallback before saving the real icon. + QFile::remove(iconDest); + if (icon.save(iconDest, "PNG")) { + return iconDest; + } + } + } + + // Already have a fallback cached — no need to copy again. + if (QFile::exists(iconDest)) { + return iconDest; + } + + // Install the bundled Fluorine icon as a fallback. + QString const bundled = bundledFluorineIcon(); + if (!bundled.isEmpty()) { + QFile::copy(bundled, iconDest); + return iconDest; + } + + return "com.fluorine.manager"; +} + +Shortcut::Shortcut(const Executable& exe) : Shortcut() +{ + const auto i = *InstanceManager::singleton().currentInstance(); + + m_name = exe.title(); + m_instanceName = i.displayName(); + m_target = appImageOrBinary(); + + // For portable instances, use the absolute directory path so MO2 can + // find it (line 595 in instancemanager.cpp handles abs path lookup). + // For global instances, use the display name. + QString const instanceId = i.isPortable() ? QDir(i.directory()).absolutePath() + : i.displayName(); + m_arguments = QString("\"moshortcut://%1:%2\"") + .arg(instanceId) + .arg(exe.title()); + + m_description = QString("Run %1 with Fluorine").arg(exe.title()); + + // Try to extract the icon from the executable (works for .exe files). + // For native Linux binaries with a custom icon, use that directly. + // Falls back to the bundled Fluorine icon. + QString const exePath = exe.binaryInfo().absoluteFilePath(); + if (exe.usesOwnIcon() && !exePath.endsWith(".exe", Qt::CaseInsensitive)) { + m_icon = exePath; + } + if (m_icon.isEmpty()) { + QString const iconBase = sanitizeDesktopName(m_instanceName) + "-" + + sanitizeDesktopName(m_name); + m_icon = installIcon(iconBase, exePath); + } + + m_workingDirectory = QFileInfo(m_target).absolutePath(); +} + +Shortcut& Shortcut::name(const QString& s) +{ + m_name = s; + return *this; +} + +Shortcut& Shortcut::target(const QString& s) +{ + m_target = s; + return *this; +} + +Shortcut& Shortcut::arguments(const QString& s) +{ + m_arguments = s; + return *this; +} + +Shortcut& Shortcut::description(const QString& s) +{ + m_description = s; + return *this; +} + +Shortcut& Shortcut::icon(const QString& s, int index) +{ + m_icon = s; + m_iconIndex = index; + return *this; +} + +Shortcut& Shortcut::workingDirectory(const QString& s) +{ + m_workingDirectory = s; + return *this; +} + +bool Shortcut::exists(Locations loc) const +{ + return QFile::exists(shortcutPath(loc)); +} + +bool Shortcut::toggle(Locations loc) +{ + if (exists(loc)) { + return remove(loc); + } else { + return add(loc); + } +} + +bool Shortcut::add(Locations loc) +{ + if (loc != Desktop && loc != ApplicationMenu) { + return false; + } + + const QString path = shortcutPath(loc); + if (path.isEmpty()) { + return false; + } + + QDir().mkpath(QFileInfo(path).absolutePath()); + + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { + return false; + } + + // Include instance name in the display name to distinguish shortcuts + // for the same executable across different instances (e.g. "NVSE (New Vegas)" + // vs "NVSE (New Vegas 2)"). + QString displayName = m_name.isEmpty() ? "Fluorine" : m_name; + if (!m_instanceName.isEmpty()) { + displayName += " (" + m_instanceName + ")"; + } + + QTextStream out(&file); + out << "[Desktop Entry]\n"; + out << "Type=Application\n"; + out << "Name=" << displayName << "\n"; + if (!m_description.isEmpty()) { + out << "Comment=" << m_description << "\n"; + } + // .desktop Exec values require quoting paths that contain spaces + out << "Exec=\"" << m_target << "\""; + if (!m_arguments.isEmpty()) { + out << " " << m_arguments; + } + out << "\n"; + if (!m_workingDirectory.isEmpty()) { + out << "Path=" << m_workingDirectory << "\n"; + } + if (!m_icon.isEmpty()) { + out << "Icon=" << m_icon << "\n"; + } + out << "Terminal=false\n"; + out << "Categories=Game;\n"; + + file.close(); + + // Make it executable (required by some desktop environments) + file.setPermissions(file.permissions() | QFileDevice::ExeUser); + + return true; +} + +bool Shortcut::remove(Locations loc) +{ + const QString path = shortcutPath(loc); + if (path.isEmpty()) { + return false; + } + return QFile::remove(path); +} + +QString Shortcut::shortcutPath(Locations loc) const +{ + const QString dir = shortcutDirectory(loc); + if (dir.isEmpty()) { + return {}; + } + return dir + "/" + shortcutFilename(); +} + +QString Shortcut::shortcutDirectory(Locations loc) +{ + if (loc == Desktop) { + // XDG desktop directory + QString desktop = QStandardPaths::writableLocation(QStandardPaths::DesktopLocation); + if (!desktop.isEmpty()) { + return desktop; + } + return QDir::homePath() + "/Desktop"; + } + if (loc == ApplicationMenu) { + return QDir::homePath() + "/.local/share/applications"; + } + return {}; +} + +QString Shortcut::shortcutFilename() const +{ + if (m_name.isEmpty()) { + return "fluorine.desktop"; + } + // Include instance name in the filename to avoid collisions when + // multiple instances have the same executable (e.g. BodySlide_x64 + // for both FNV and SkyrimSE). Example: "New Vegas-NVSE.desktop" + QString base = m_name; + if (!m_instanceName.isEmpty()) { + base = sanitizeDesktopName(m_instanceName) + "-" + base; + } + return base + ".desktop"; +} + +QString toString(Shortcut::Locations loc) +{ + switch (loc) { + case Shortcut::None: + return "none"; + + case Shortcut::Desktop: + return "desktop"; + + case Shortcut::StartMenu: + return "start menu"; + + case Shortcut::ApplicationMenu: + return "application menu"; + + default: + return QString("? (%1)").arg(static_cast(loc)); + } +} + +} // namespace env diff --git a/src/src/executableslist.cpp b/src/src/executableslist.cpp index 9fdf9ca..ca4f295 100644 --- a/src/src/executableslist.cpp +++ b/src/src/executableslist.cpp @@ -1,525 +1,525 @@ -/* -Copyright (C) 2012 Sebastian Herbord. All rights reserved. - -This file is part of Mod Organizer. - -Mod Organizer is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -Mod Organizer is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with Mod Organizer. If not, see . -*/ - -#include "executableslist.h" - -#include "iplugingame.h" -#include "settings.h" -#include "utility.h" -#include - -#include -#include -#include -#include - -#include - -using namespace MOBase; - -ExecutablesList::iterator ExecutablesList::begin() -{ - return m_Executables.begin(); -} - -ExecutablesList::const_iterator ExecutablesList::begin() const -{ - return m_Executables.begin(); -} - -ExecutablesList::iterator ExecutablesList::end() -{ - return m_Executables.end(); -} - -ExecutablesList::const_iterator ExecutablesList::end() const -{ - return m_Executables.end(); -} - -std::size_t ExecutablesList::size() const -{ - return m_Executables.size(); -} - -bool ExecutablesList::empty() const -{ - return m_Executables.empty(); -} - -void ExecutablesList::load(const MOBase::IPluginGame* game, const Settings& s) -{ - log::debug("loading executables"); - - m_Executables.clear(); - - // whether the executable list in the .ini is still using the old custom - // executables from 2.2.0, see upgradeFromCustom() - bool needsUpgrade = false; - - for (auto& map : s.executables()) { - Executable::Flags flags; - - if (map["toolbar"].toBool()) - flags |= Executable::ShowInToolbar; - - if (map["ownicon"].toBool()) - flags |= Executable::UseApplicationIcon; - - if (map["minimizeToSystemTray"].toBool()) - flags |= Executable::MinimizeToSystemTray; - - if (map["hide"].toBool()) - flags |= Executable::Hide; - - // Default to UseProton=true for backward compat (existing executables - // were always launched with Proton). Only set false if explicitly saved. - if (map.contains("useProton") ? map["useProton"].toBool() : true) - flags |= Executable::UseProton; - - if (map["useTerminal"].toBool()) - flags |= Executable::UseTerminal; - - if (map.contains("custom")) { - // the "custom" setting only exists in older versions - needsUpgrade = true; - } - - setExecutable(Executable() - .title(map["title"].toString()) - .binaryInfo(QFileInfo(map["binary"].toString())) - .arguments(map["arguments"].toString()) - .steamAppID(map["steamAppID"].toString()) - .workingDirectory(map["workingDirectory"].toString()) - .flags(flags)); - } - - addFromPlugin(game, IgnoreExisting); - - if (needsUpgrade) - upgradeFromCustom(game); - - dump(); -} - -void ExecutablesList::store(Settings& s) -{ - std::vector> v; - - for (const auto& item : *this) { - std::map map; - - map["title"] = item.title(); - map["toolbar"] = item.isShownOnToolbar(); - map["ownicon"] = item.usesOwnIcon(); - map["hide"] = item.hide(); - map["binary"] = item.binaryInfo().filePath(); - map["arguments"] = item.arguments(); - map["workingDirectory"] = item.workingDirectory(); - map["steamAppID"] = item.steamAppID(); - map["minimizeToSystemTray"] = item.minimizeToSystemTray(); - map["useProton"] = item.useProton(); - map["useTerminal"] = item.useTerminal(); - - v.push_back(std::move(map)); - } - - s.setExecutables(v); -} - -std::vector -ExecutablesList::getPluginExecutables(MOBase::IPluginGame const* game) -{ - Q_ASSERT(game != nullptr); - - std::vector v; - - // Native Linux installs (e.g. native Stardew Valley) launch directly without - // Proton. Their executables default to UseProton=false so the user doesn't - // have to flip every entry by hand after instance creation. - const bool nativeLinux = game->isNativeLinux(); - - for (const ExecutableInfo& info : game->executables()) { - if (!info.isValid()) { - continue; - } - - Executable::Flags flags = Executable::UseApplicationIcon; - if (!nativeLinux) { - flags |= Executable::UseProton; - } - v.push_back({info, flags}); - } - - const QFileInfo eppBin(QCoreApplication::applicationDirPath() + - "/explorer++/Explorer++.exe"); - - if (eppBin.exists()) { - const auto args = QString("\"%1\"").arg( - QDir::toNativeSeparators(game->dataDirectory().absolutePath())); - - const auto exe = Executable() - .title("Explore Virtual Folder") - .binaryInfo(eppBin) - .arguments(args) - .workingDirectory(eppBin.absolutePath()) - .flags(Executable::UseApplicationIcon); - - v.push_back(exe); - } - - return v; -} - -void ExecutablesList::resetFromPlugin(MOBase::IPluginGame const* game) -{ - log::debug("resetting plugin executables"); - - Q_ASSERT(game != nullptr); - - for (const auto& exe : getPluginExecutables(game)) { - setExecutable(exe, MoveExisting); - } -} - -void ExecutablesList::addFromPlugin(IPluginGame const* game, SetFlags flags) -{ - Q_ASSERT(game != nullptr); - - for (const auto& exe : getPluginExecutables(game)) { - setExecutable(exe, flags); - } -} - -const Executable& ExecutablesList::get(const QString& title) const -{ - for (const auto& exe : m_Executables) { - if (exe.title() == title) { - return exe; - } - } - - throw std::runtime_error( - QString("executable not found: %1").arg(title).toLocal8Bit().constData()); -} - -Executable& ExecutablesList::get(const QString& title) -{ - return const_cast(std::as_const(*this).get(title)); -} - -Executable& ExecutablesList::getByBinary(const QFileInfo& info) -{ - for (Executable& exe : m_Executables) { - if (exe.binaryInfo() == info) { - return exe; - } - } - throw std::runtime_error("invalid info"); -} - -ExecutablesList::iterator ExecutablesList::find(const QString& title, bool ci) -{ - const auto cif = ci ? Qt::CaseInsensitive : Qt::CaseSensitive; - - return std::find_if(begin(), end(), [&](auto&& e) { - return (e.title().compare(title, cif) == 0); - }); -} - -ExecutablesList::const_iterator ExecutablesList::find(const QString& title, - bool ci) const -{ - const auto cif = ci ? Qt::CaseInsensitive : Qt::CaseSensitive; - - return std::find_if(begin(), end(), [&](auto&& e) { - return (e.title().compare(title, cif) == 0); - }); -} - -bool ExecutablesList::titleExists(const QString& title) const -{ - auto test = [&](const Executable& exe) { - return exe.title() == title; - }; - return std::find_if(m_Executables.begin(), m_Executables.end(), test) != - m_Executables.end(); -} - -void ExecutablesList::setExecutable(const Executable& exe) -{ - setExecutable(exe, MergeExisting); -} - -void ExecutablesList::setExecutable(const Executable& exe, SetFlags flags) -{ - auto itor = find(exe.title()); - - if (itor != end()) { - if (flags == IgnoreExisting) { - return; - } - - if (flags == MoveExisting) { - const auto newTitle = makeNonConflictingTitle(exe.title()); - if (!newTitle) { - log::error("executable '{}' was in the way but could not be renamed", - exe.title()); - - return; - } - - log::warn("executable '{}' was in the way and was renamed to '{}'", itor->title(), - *newTitle); - - itor->title(*newTitle); - itor = end(); - } - } - - if (itor == m_Executables.end()) { - m_Executables.push_back(exe); - } else { - itor->mergeFrom(exe); - } -} - -void ExecutablesList::remove(const QString& title) -{ - auto itor = find(title); - if (itor != m_Executables.end()) { - m_Executables.erase(itor); - } -} - -std::optional ExecutablesList::makeNonConflictingTitle(const QString& prefix) const -{ - const int max = 100; - - QString title = prefix; - - for (int i = 1; i < max; ++i) { - if (!titleExists(title)) { - return title; - } - - title = prefix + QString(" (%1)").arg(i); - } - - log::error("ran out of executable titles for prefix '{}'", prefix); - return {}; -} - -void ExecutablesList::upgradeFromCustom(MOBase::IPluginGame const* game) -{ - log::debug("upgrading executables list"); - - Q_ASSERT(game != nullptr); - - // prior to 2.2.1, plugin executables were special in the sense that they - // did not store certain settings, like the binary info and working directory; - // those were filled in when MO started, but never saved - // - // this interferes with the new executables list, which is completely - // customizable, because plugin executables are only added to the list when - // they don't exist at all and are ignored otherwise, leaving some of the - // fields completely blank - // - // when the "custom" setting is found in the .ini file (see load()), it is - // assumed that the older scheme is still present; in that case, the plugin - // executables force their binary info and working directory into the existing - // executables one last time - // - // from that point on, plugin executables are ignored unless they're - // completely missing from the list, allowing users to customize them as they - // want - - for (const auto& exe : getPluginExecutables(game)) { - auto itor = find(exe.title()); - if (itor == end()) { - continue; - } - - if (!itor->binaryInfo().exists()) { - itor->binaryInfo(exe.binaryInfo()); - } - - if (itor->workingDirectory().isEmpty()) { - itor->workingDirectory(exe.workingDirectory()); - } - } -} - -void ExecutablesList::dump() const -{ - for (const auto& e : m_Executables) { - QStringList flags; - - if (e.flags() & Executable::ShowInToolbar) { - flags.push_back("toolbar"); - } - - if (e.flags() & Executable::UseApplicationIcon) { - flags.push_back("icon"); - } - - if (e.flags() & Executable::Hide) { - flags.push_back("hide"); - } - - if (e.flags() & Executable::MinimizeToSystemTray) { - flags.push_back("minimizeToSystemTray"); - } - - log::debug(" . executable '{}'\n" - " binary: {}\n" - " arguments: {}\n" - " steam ID: {}\n" - " directory: {}\n" - " flags: {} ({})", - e.title(), e.binaryInfo().filePath(), e.arguments(), e.steamAppID(), - e.workingDirectory(), flags.join("|"), e.flags()); - } -} - -Executable::Executable(QString title) : m_title(title) {} - -Executable::Executable(const MOBase::ExecutableInfo& info, Flags flags) - : m_title(info.title()), m_binaryInfo(info.binary()), - m_arguments(info.arguments().join(" ")), m_steamAppID(info.steamAppID()), - m_workingDirectory(info.workingDirectory().path()), m_flags(flags) -{} - -const QString& Executable::title() const -{ - return m_title; -} - -const QFileInfo& Executable::binaryInfo() const -{ - return m_binaryInfo; -} - -const QString& Executable::arguments() const -{ - return m_arguments; -} - -const QString& Executable::steamAppID() const -{ - return m_steamAppID; -} - -const QString& Executable::workingDirectory() const -{ - return m_workingDirectory; -} - -Executable::Flags Executable::flags() const -{ - return m_flags; -} - -Executable& Executable::title(const QString& s) -{ - m_title = s; - return *this; -} - -Executable& Executable::binaryInfo(const QFileInfo& fi) -{ - m_binaryInfo = fi; - return *this; -} - -Executable& Executable::arguments(const QString& s) -{ - m_arguments = s; - return *this; -} - -Executable& Executable::steamAppID(const QString& s) -{ - m_steamAppID = s; - return *this; -} - -Executable& Executable::workingDirectory(const QString& s) -{ - m_workingDirectory = s; - return *this; -} - -Executable& Executable::flags(Flags f) -{ - m_flags = f; - return *this; -} - -bool Executable::isShownOnToolbar() const -{ - return m_flags.testFlag(ShowInToolbar); -} - -void Executable::setShownOnToolbar(bool state) -{ - if (state) { - m_flags |= ShowInToolbar; - } else { - m_flags &= ~ShowInToolbar; - } -} - -bool Executable::usesOwnIcon() const -{ - return m_flags.testFlag(UseApplicationIcon); -} - -bool Executable::minimizeToSystemTray() const -{ - return m_flags.testFlag(MinimizeToSystemTray); -} - -bool Executable::hide() const -{ - return m_flags.testFlag(Hide); -} - -bool Executable::useProton() const -{ - return m_flags.testFlag(UseProton); -} - -bool Executable::useTerminal() const -{ - return m_flags.testFlag(UseTerminal); -} - -void Executable::mergeFrom(const Executable& other) -{ - // this happens after executables are loaded from settings and plugin - // executables are being added, or when users are modifying executables - - m_title = other.title(); - m_binaryInfo = other.binaryInfo(); - m_arguments = other.arguments(); - m_steamAppID = other.steamAppID(); - m_workingDirectory = other.workingDirectory(); - m_flags = other.flags(); -} +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "executableslist.h" + +#include "iplugingame.h" +#include "settings.h" +#include "utility.h" +#include + +#include +#include +#include +#include + +#include + +using namespace MOBase; + +ExecutablesList::iterator ExecutablesList::begin() +{ + return m_Executables.begin(); +} + +ExecutablesList::const_iterator ExecutablesList::begin() const +{ + return m_Executables.begin(); +} + +ExecutablesList::iterator ExecutablesList::end() +{ + return m_Executables.end(); +} + +ExecutablesList::const_iterator ExecutablesList::end() const +{ + return m_Executables.end(); +} + +std::size_t ExecutablesList::size() const +{ + return m_Executables.size(); +} + +bool ExecutablesList::empty() const +{ + return m_Executables.empty(); +} + +void ExecutablesList::load(const MOBase::IPluginGame* game, const Settings& s) +{ + log::debug("loading executables"); + + m_Executables.clear(); + + // whether the executable list in the .ini is still using the old custom + // executables from 2.2.0, see upgradeFromCustom() + bool needsUpgrade = false; + + for (auto& map : s.executables()) { + Executable::Flags flags; + + if (map["toolbar"].toBool()) + flags |= Executable::ShowInToolbar; + + if (map["ownicon"].toBool()) + flags |= Executable::UseApplicationIcon; + + if (map["minimizeToSystemTray"].toBool()) + flags |= Executable::MinimizeToSystemTray; + + if (map["hide"].toBool()) + flags |= Executable::Hide; + + // Default to UseProton=true for backward compat (existing executables + // were always launched with Proton). Only set false if explicitly saved. + if (map.contains("useProton") ? map["useProton"].toBool() : true) + flags |= Executable::UseProton; + + if (map["useTerminal"].toBool()) + flags |= Executable::UseTerminal; + + if (map.contains("custom")) { + // the "custom" setting only exists in older versions + needsUpgrade = true; + } + + setExecutable(Executable() + .title(map["title"].toString()) + .binaryInfo(QFileInfo(map["binary"].toString())) + .arguments(map["arguments"].toString()) + .steamAppID(map["steamAppID"].toString()) + .workingDirectory(map["workingDirectory"].toString()) + .flags(flags)); + } + + addFromPlugin(game, IgnoreExisting); + + if (needsUpgrade) + upgradeFromCustom(game); + + dump(); +} + +void ExecutablesList::store(Settings& s) +{ + std::vector> v; + + for (const auto& item : *this) { + std::map map; + + map["title"] = item.title(); + map["toolbar"] = item.isShownOnToolbar(); + map["ownicon"] = item.usesOwnIcon(); + map["hide"] = item.hide(); + map["binary"] = item.binaryInfo().filePath(); + map["arguments"] = item.arguments(); + map["workingDirectory"] = item.workingDirectory(); + map["steamAppID"] = item.steamAppID(); + map["minimizeToSystemTray"] = item.minimizeToSystemTray(); + map["useProton"] = item.useProton(); + map["useTerminal"] = item.useTerminal(); + + v.push_back(std::move(map)); + } + + s.setExecutables(v); +} + +std::vector +ExecutablesList::getPluginExecutables(MOBase::IPluginGame const* game) +{ + Q_ASSERT(game != nullptr); + + std::vector v; + + // Native Linux installs (e.g. native Stardew Valley) launch directly without + // Proton. Their executables default to UseProton=false so the user doesn't + // have to flip every entry by hand after instance creation. + const bool nativeLinux = game->isNativeLinux(); + + for (const ExecutableInfo& info : game->executables()) { + if (!info.isValid()) { + continue; + } + + Executable::Flags flags = Executable::UseApplicationIcon; + if (!nativeLinux) { + flags |= Executable::UseProton; + } + v.emplace_back(info, flags); + } + + const QFileInfo eppBin(QCoreApplication::applicationDirPath() + + "/explorer++/Explorer++.exe"); + + if (eppBin.exists()) { + const auto args = QString("\"%1\"").arg( + QDir::toNativeSeparators(game->dataDirectory().absolutePath())); + + const auto exe = Executable() + .title("Explore Virtual Folder") + .binaryInfo(eppBin) + .arguments(args) + .workingDirectory(eppBin.absolutePath()) + .flags(Executable::UseApplicationIcon); + + v.push_back(exe); + } + + return v; +} + +void ExecutablesList::resetFromPlugin(MOBase::IPluginGame const* game) +{ + log::debug("resetting plugin executables"); + + Q_ASSERT(game != nullptr); + + for (const auto& exe : getPluginExecutables(game)) { + setExecutable(exe, MoveExisting); + } +} + +void ExecutablesList::addFromPlugin(IPluginGame const* game, SetFlags flags) +{ + Q_ASSERT(game != nullptr); + + for (const auto& exe : getPluginExecutables(game)) { + setExecutable(exe, flags); + } +} + +const Executable& ExecutablesList::get(const QString& title) const +{ + for (const auto& exe : m_Executables) { + if (exe.title() == title) { + return exe; + } + } + + throw std::runtime_error( + QString("executable not found: %1").arg(title).toLocal8Bit().constData()); +} + +Executable& ExecutablesList::get(const QString& title) +{ + return const_cast(std::as_const(*this).get(title)); +} + +Executable& ExecutablesList::getByBinary(const QFileInfo& info) +{ + for (Executable& exe : m_Executables) { + if (exe.binaryInfo() == info) { + return exe; + } + } + throw std::runtime_error("invalid info"); +} + +ExecutablesList::iterator ExecutablesList::find(const QString& title, bool ci) +{ + const auto cif = ci ? Qt::CaseInsensitive : Qt::CaseSensitive; + + return std::find_if(begin(), end(), [&](auto&& e) { + return (e.title().compare(title, cif) == 0); + }); +} + +ExecutablesList::const_iterator ExecutablesList::find(const QString& title, + bool ci) const +{ + const auto cif = ci ? Qt::CaseInsensitive : Qt::CaseSensitive; + + return std::find_if(begin(), end(), [&](auto&& e) { + return (e.title().compare(title, cif) == 0); + }); +} + +bool ExecutablesList::titleExists(const QString& title) const +{ + auto test = [&](const Executable& exe) { + return exe.title() == title; + }; + return std::find_if(m_Executables.begin(), m_Executables.end(), test) != + m_Executables.end(); +} + +void ExecutablesList::setExecutable(const Executable& exe) +{ + setExecutable(exe, MergeExisting); +} + +void ExecutablesList::setExecutable(const Executable& exe, SetFlags flags) +{ + auto itor = find(exe.title()); + + if (itor != end()) { + if (flags == IgnoreExisting) { + return; + } + + if (flags == MoveExisting) { + const auto newTitle = makeNonConflictingTitle(exe.title()); + if (!newTitle) { + log::error("executable '{}' was in the way but could not be renamed", + exe.title()); + + return; + } + + log::warn("executable '{}' was in the way and was renamed to '{}'", itor->title(), + *newTitle); + + itor->title(*newTitle); + itor = end(); + } + } + + if (itor == m_Executables.end()) { + m_Executables.push_back(exe); + } else { + itor->mergeFrom(exe); + } +} + +void ExecutablesList::remove(const QString& title) +{ + auto itor = find(title); + if (itor != m_Executables.end()) { + m_Executables.erase(itor); + } +} + +std::optional ExecutablesList::makeNonConflictingTitle(const QString& prefix) const +{ + const int max = 100; + + QString title = prefix; + + for (int i = 1; i < max; ++i) { + if (!titleExists(title)) { + return title; + } + + title = prefix + QString(" (%1)").arg(i); + } + + log::error("ran out of executable titles for prefix '{}'", prefix); + return {}; +} + +void ExecutablesList::upgradeFromCustom(MOBase::IPluginGame const* game) +{ + log::debug("upgrading executables list"); + + Q_ASSERT(game != nullptr); + + // prior to 2.2.1, plugin executables were special in the sense that they + // did not store certain settings, like the binary info and working directory; + // those were filled in when MO started, but never saved + // + // this interferes with the new executables list, which is completely + // customizable, because plugin executables are only added to the list when + // they don't exist at all and are ignored otherwise, leaving some of the + // fields completely blank + // + // when the "custom" setting is found in the .ini file (see load()), it is + // assumed that the older scheme is still present; in that case, the plugin + // executables force their binary info and working directory into the existing + // executables one last time + // + // from that point on, plugin executables are ignored unless they're + // completely missing from the list, allowing users to customize them as they + // want + + for (const auto& exe : getPluginExecutables(game)) { + auto itor = find(exe.title()); + if (itor == end()) { + continue; + } + + if (!itor->binaryInfo().exists()) { + itor->binaryInfo(exe.binaryInfo()); + } + + if (itor->workingDirectory().isEmpty()) { + itor->workingDirectory(exe.workingDirectory()); + } + } +} + +void ExecutablesList::dump() const +{ + for (const auto& e : m_Executables) { + QStringList flags; + + if (e.flags() & Executable::ShowInToolbar) { + flags.push_back("toolbar"); + } + + if (e.flags() & Executable::UseApplicationIcon) { + flags.push_back("icon"); + } + + if (e.flags() & Executable::Hide) { + flags.push_back("hide"); + } + + if (e.flags() & Executable::MinimizeToSystemTray) { + flags.push_back("minimizeToSystemTray"); + } + + log::debug(" . executable '{}'\n" + " binary: {}\n" + " arguments: {}\n" + " steam ID: {}\n" + " directory: {}\n" + " flags: {} ({})", + e.title(), e.binaryInfo().filePath(), e.arguments(), e.steamAppID(), + e.workingDirectory(), flags.join("|"), e.flags()); + } +} + +Executable::Executable(QString title) : m_title(title) {} + +Executable::Executable(const MOBase::ExecutableInfo& info, Flags flags) + : m_title(info.title()), m_binaryInfo(info.binary()), + m_arguments(info.arguments().join(" ")), m_steamAppID(info.steamAppID()), + m_workingDirectory(info.workingDirectory().path()), m_flags(flags) +{} + +const QString& Executable::title() const +{ + return m_title; +} + +const QFileInfo& Executable::binaryInfo() const +{ + return m_binaryInfo; +} + +const QString& Executable::arguments() const +{ + return m_arguments; +} + +const QString& Executable::steamAppID() const +{ + return m_steamAppID; +} + +const QString& Executable::workingDirectory() const +{ + return m_workingDirectory; +} + +Executable::Flags Executable::flags() const +{ + return m_flags; +} + +Executable& Executable::title(const QString& s) +{ + m_title = s; + return *this; +} + +Executable& Executable::binaryInfo(const QFileInfo& fi) +{ + m_binaryInfo = fi; + return *this; +} + +Executable& Executable::arguments(const QString& s) +{ + m_arguments = s; + return *this; +} + +Executable& Executable::steamAppID(const QString& s) +{ + m_steamAppID = s; + return *this; +} + +Executable& Executable::workingDirectory(const QString& s) +{ + m_workingDirectory = s; + return *this; +} + +Executable& Executable::flags(Flags f) +{ + m_flags = f; + return *this; +} + +bool Executable::isShownOnToolbar() const +{ + return m_flags.testFlag(ShowInToolbar); +} + +void Executable::setShownOnToolbar(bool state) +{ + if (state) { + m_flags |= ShowInToolbar; + } else { + m_flags &= ~ShowInToolbar; + } +} + +bool Executable::usesOwnIcon() const +{ + return m_flags.testFlag(UseApplicationIcon); +} + +bool Executable::minimizeToSystemTray() const +{ + return m_flags.testFlag(MinimizeToSystemTray); +} + +bool Executable::hide() const +{ + return m_flags.testFlag(Hide); +} + +bool Executable::useProton() const +{ + return m_flags.testFlag(UseProton); +} + +bool Executable::useTerminal() const +{ + return m_flags.testFlag(UseTerminal); +} + +void Executable::mergeFrom(const Executable& other) +{ + // this happens after executables are loaded from settings and plugin + // executables are being added, or when users are modifying executables + + m_title = other.title(); + m_binaryInfo = other.binaryInfo(); + m_arguments = other.arguments(); + m_steamAppID = other.steamAppID(); + m_workingDirectory = other.workingDirectory(); + m_flags = other.flags(); +} diff --git a/src/src/filetree.cpp b/src/src/filetree.cpp index c9f2bb2..847385e 100644 --- a/src/src/filetree.cpp +++ b/src/src/filetree.cpp @@ -1,831 +1,832 @@ -#include "filetree.h" -#include "envshell.h" -#include "filetreeitem.h" -#include "filetreemodel.h" -#include "organizercore.h" -#include "shared/directoryentry.h" -#include "shared/fileentry.h" -#include "shared/filesorigin.h" -#include -#include - -using namespace MOShared; -using namespace MOBase; - -bool canPreviewFile(const PluginContainer& pc, const FileEntry& file) -{ - return canPreviewFile(pc, file.isFromArchive(), - QString::fromStdWString(file.getName())); -} - -bool canRunFile(const FileEntry& file) -{ - return canRunFile(file.isFromArchive(), QString::fromStdWString(file.getName())); -} - -bool canOpenFile(const FileEntry& file) -{ - return canOpenFile(file.isFromArchive(), QString::fromStdWString(file.getName())); -} - -bool isHidden(const FileEntry& file) -{ - return (QString::fromStdWString(file.getName()) - .endsWith(ModInfo::s_HiddenExt, Qt::CaseInsensitive)); -} - -bool canExploreFile(const FileEntry& file); -bool canHideFile(const FileEntry& file); -bool canUnhideFile(const FileEntry& file); - -class MenuItem -{ -public: - MenuItem(QString s = {}) : m_action(new QAction(std::move(s))) {} - - MenuItem& caption(const QString& s) - { - m_action->setText(s); - return *this; - } - - template - MenuItem& callback(F&& f) - { - QObject::connect(m_action, &QAction::triggered, std::forward(f)); - return *this; - } - - MenuItem& hint(const QString& s) - { - m_tooltip = s; - return *this; - } - - MenuItem& disabledHint(const QString& s) - { - m_disabledHint = s; - return *this; - } - - MenuItem& enabled(bool b) - { - m_action->setEnabled(b); - return *this; - } - - void addTo(QMenu& menu) - { - QString const s; - - setTips(); - - m_action->setParent(&menu); - menu.addAction(m_action); - } - -private: - QAction* m_action; - QString m_tooltip; - QString m_disabledHint; - - void setTips() - { - if (m_action->isEnabled() || m_disabledHint.isEmpty()) { - m_action->setStatusTip(m_tooltip); - return; - } - - QString s = m_tooltip.trimmed(); - - if (!s.isEmpty()) { - if (!s.endsWith(".")) { - s += "."; - } - - s += "\n"; - } - - s += QObject::tr("Disabled because") + ": " + m_disabledHint.trimmed(); - - if (!s.endsWith(".")) { - s += "."; - } - - m_action->setStatusTip(s); - } -}; - -FileTree::FileTree(OrganizerCore& core, PluginContainer& pc, QTreeView* tree) - : m_core(core), m_plugins(pc), m_tree(tree), m_model(new FileTreeModel(core)) -{ - m_tree->sortByColumn(0, Qt::AscendingOrder); - m_tree->setModel(m_model); - m_tree->header()->resizeSection(0, 200); - - MOBase::setCustomizableColumns(m_tree); - - connect(m_tree, &QTreeView::customContextMenuRequested, [&](auto pos) { - onContextMenu(pos); - }); - - connect(m_tree, &QTreeView::expanded, [&](auto&& index) { - onExpandedChanged(index, true); - }); - - connect(m_tree, &QTreeView::collapsed, [&](auto&& index) { - onExpandedChanged(index, false); - }); - - connect(m_tree, &QTreeView::activated, [&](auto&& index) { - onItemActivated(index); - }); -} - -FileTreeModel* FileTree::model() -{ - return m_model; -} - -void FileTree::refresh() -{ - m_model->refresh(); -} - -void FileTree::clear() -{ - m_model->clear(); -} - -bool FileTree::fullyLoaded() const -{ - return m_model->fullyLoaded(); -} - -void FileTree::ensureFullyLoaded() -{ - m_model->ensureFullyLoaded(); -} - -FileTreeItem* FileTree::singleSelection() -{ - const auto sel = m_tree->selectionModel()->selectedRows(); - if (sel.size() == 1) { - return m_model->itemFromIndex(proxiedIndex(sel[0])); - } - - return nullptr; -} - -void FileTree::open(FileTreeItem* item) -{ - if (!item) { - item = singleSelection(); - - if (!item) { - return; - } - } - - if (item->isFromArchive() || item->isDirectory()) { - return; - } - - const QString path = item->realPath(); - const QFileInfo targetInfo(path); - - m_core.processRunner() - .setFromFile(m_tree->window(), targetInfo) - .setHooked(false) - .setWaitForCompletion(ProcessRunner::TriggerRefresh) - .run(); -} - -void FileTree::openHooked(FileTreeItem* item) -{ - if (!item) { - item = singleSelection(); - - if (!item) { - return; - } - } - - if (item->isFromArchive() || item->isDirectory()) { - return; - } - - const QString path = item->realPath(); - const QFileInfo targetInfo(path); - - m_core.processRunner() - .setFromFile(m_tree->window(), targetInfo) - .setHooked(true) - .setWaitForCompletion(ProcessRunner::TriggerRefresh) - .run(); -} - -void FileTree::preview(FileTreeItem* item) -{ - if (!item) { - item = singleSelection(); - - if (!item) { - return; - } - } - - const QString path = item->dataRelativeFilePath(); - m_core.previewFileWithAlternatives(m_tree->window(), path); -} - -void FileTree::activate(FileTreeItem* item) -{ - if (item->isDirectory()) { - // activating a directory should just toggle expansion - return; - } - - const auto tryPreview = m_core.settings().interface().doubleClicksOpenPreviews(); - - if (tryPreview) { - const QFileInfo fi(item->realPath()); - if (m_plugins.previewGenerator().previewSupported(fi.suffix().toLower(), - item->isFromArchive())) { - preview(item); - return; - } - } - - open(item); -} - -void FileTree::addAsExecutable(FileTreeItem* item) -{ - if (!item) { - item = singleSelection(); - - if (!item) { - return; - } - } - - const QString path = item->realPath(); - const QFileInfo target(path); - const auto fec = spawn::getFileExecutionContext(m_tree->window(), target); - - switch (fec.type) { - case spawn::FileExecutionTypes::Executable: { - const QString name = QInputDialog::getText( - m_tree->window(), tr("Enter Name"), tr("Enter a name for the executable"), - QLineEdit::Normal, target.completeBaseName()); - - if (!name.isEmpty()) { - // Note: If this already exists, you'll lose custom settings - m_core.executablesList()->setExecutable( - Executable() - .title(name) - .binaryInfo(fec.binary) - .arguments(fec.arguments) - .workingDirectory(target.absolutePath()) - .flags(Executable::UseProton)); - - emit executablesChanged(); - } - - break; - } - - case spawn::FileExecutionTypes::Other: // fall-through - default: { - QMessageBox::information(m_tree->window(), tr("Not an executable"), - tr("This is not a recognized executable.")); - - break; - } - } -} - -void FileTree::exploreOrigin(FileTreeItem* item) -{ - if (!item) { - item = singleSelection(); - - if (!item) { - return; - } - } - - if (item->isFromArchive() || item->isDirectory()) { - return; - } - - const auto path = item->realPath(); - - log::debug("opening in explorer: {}", path); - shell::Explore(path); -} - -void FileTree::openModInfo(FileTreeItem* item) -{ - if (!item) { - item = singleSelection(); - - if (!item) { - return; - } - } - - const auto originID = item->originID(); - - if (originID == 0) { - // unmanaged - return; - } - - const auto& origin = m_core.directoryStructure()->getOriginByID(originID); - const auto& name = QString::fromStdWString(origin.getName()); - - unsigned int const index = ModInfo::getIndex(name); - if (index == UINT_MAX) { - log::error("can't open mod info, mod '{}' not found", name); - return; - } - - ModInfo::Ptr const modInfo = ModInfo::getByIndex(index); - if (modInfo) { - emit displayModInformation(modInfo, index, ModInfoTabIDs::None); - } -} - -void FileTree::toggleVisibility(bool visible, FileTreeItem* item) -{ - if (!item) { - item = singleSelection(); - - if (!item) { - return; - } - } - - const QString currentName = item->realPath(); - QString newName; - - if (visible) { - if (!currentName.endsWith(ModInfo::s_HiddenExt)) { - log::error("cannot unhide '{}', doesn't end with '{}'", currentName, - ModInfo::s_HiddenExt); - - return; - } - - newName = currentName.left(currentName.size() - ModInfo::s_HiddenExt.size()); - } else { - if (currentName.endsWith(ModInfo::s_HiddenExt)) { - log::error("cannot hide '{}', already ends with '{}'", currentName, - ModInfo::s_HiddenExt); - - return; - } - - newName = currentName + ModInfo::s_HiddenExt; - } - - log::debug("attempting to rename '{}' to '{}'", currentName, newName); - - FileRenamer renamer(m_tree->window(), - (visible ? FileRenamer::UNHIDE : FileRenamer::HIDE)); - - if (renamer.rename(currentName, newName) == FileRenamer::RESULT_OK) { - emit originModified(item->originID()); - refresh(); - } -} - -void FileTree::hide(FileTreeItem* item) -{ - toggleVisibility(false, item); -} - -void FileTree::unhide(FileTreeItem* item) -{ - toggleVisibility(true, item); -} - -class DumpFailed -{}; - -void FileTree::dumpToFile() const -{ - log::debug("dumping filetree to file"); - - QString const file = QFileDialog::getSaveFileName(m_tree->window()); - if (file.isEmpty()) { - log::debug("user canceled"); - return; - } - - m_core.directoryStructure()->dump(file.toStdWString()); -} - -void FileTree::onExpandedChanged(const QModelIndex& index, bool expanded) -{ - if (auto* item = m_model->itemFromIndex(proxiedIndex(index))) { - item->setExpanded(expanded); - } -} - -void FileTree::onItemActivated(const QModelIndex& index) -{ - auto* item = m_model->itemFromIndex(proxiedIndex(index)); - if (!item) { - return; - } - - activate(item); -} - -void FileTree::onContextMenu(const QPoint& pos) -{ - const auto m = QApplication::keyboardModifiers(); - - if (m & Qt::ShiftModifier) { - // shift+right-click won't select individual items because it interferes - // with regular shift selection; this makes it behave like explorer: - // - if the right-clicked item is currently part of the selection, show - // the menu for the selection - // - if not, select this item only and show the menu for it - const auto index = m_tree->indexAt(pos); - - if (!m_tree->selectionModel()->isSelected(index)) { - m_tree->selectionModel()->select(index, QItemSelectionModel::ClearAndSelect | - QItemSelectionModel::Rows | - QItemSelectionModel::Current); - } - - // if no shell menu was available, continue on and show the regular - // context menu - if (showShellMenu(pos)) { - return; - } - } - - QMenu menu; - - if (auto* item = singleSelection()) { - if (item->isDirectory()) { - addDirectoryMenus(menu, *item); - } else { - const auto file = m_core.directoryStructure()->searchFile( - item->dataRelativeFilePath().toStdWString(), nullptr); - - if (file) { - addFileMenus(menu, *file, item->originID()); - } - } - } - - addCommonMenus(menu); - - menu.exec(m_tree->viewport()->mapToGlobal(pos)); -} - -QMainWindow* getMainWindow(QWidget* w) -{ - QWidget* p = w; - - while (p) { - if (auto* mw = dynamic_cast(p)) { - return mw; - } - - p = p->parentWidget(); - } - - return nullptr; -} - -bool FileTree::showShellMenu(QPoint pos) -{ - auto* mw = getMainWindow(m_tree); - - // menus by origin - std::map menus; - int totalFiles = 0; - bool warnOnEmpty = true; - - for (auto&& index : m_tree->selectionModel()->selectedRows()) { - auto* item = m_model->itemFromIndex(proxiedIndex(index)); - if (!item) { - continue; - } - - if (item->isDirectory()) { - warnOnEmpty = false; - - log::warn("directories do not have shell menus; '{}' selected", item->filename()); - - continue; - } - - if (item->isFromArchive()) { - warnOnEmpty = false; - - log::warn("files from archives do not have shell menus; '{}' selected", - item->filename()); - - continue; - } - - auto itor = menus.find(item->originID()); - if (itor == menus.end()) { - itor = menus.emplace(item->originID(), mw).first; - } - - if (!QFile::exists(item->realPath())) { - log::error("{}", tr("File '%1' does not exist, you may need to refresh.") - .arg(item->realPath())); - } - - itor->second.addFile(QFileInfo(item->realPath())); - ++totalFiles; - - if (item->isConflicted()) { - const auto file = m_core.directoryStructure()->searchFile( - item->dataRelativeFilePath().toStdWString(), nullptr); - - if (!file) { - log::error("file '{}' not found, data path={}, real path={}", item->filename(), - item->dataRelativeFilePath(), item->realPath()); - - continue; - } - - const auto alts = file->getAlternatives(); - if (alts.empty()) { - log::warn("file '{}' has no alternative origins but is marked as conflicted", - item->dataRelativeFilePath()); - } - - for (auto&& alt : alts) { - auto itor = menus.find(alt.originID()); - if (itor == menus.end()) { - itor = menus.emplace(alt.originID(), mw).first; - } - - const auto fullPath = file->getFullPath(alt.originID()); - if (fullPath.empty()) { - log::error("file {} not found in origin {}", item->dataRelativeFilePath(), - alt.originID()); - - continue; - } - - if (!QFile::exists(QString::fromStdWString(fullPath))) { - log::error("{}", tr("File '%1' does not exist, you may need to refresh.") - .arg(QString::fromStdWString(fullPath))); - } - - itor->second.addFile(QFileInfo(QString::fromStdWString(fullPath))); - } - } - } - - if (menus.empty()) { - // don't warn if something that doesn't have a shell menu was selected, a - // warning has already been logged above - if (warnOnEmpty) { - log::warn("no menus to show"); - } - - return false; - } else if (menus.size() == 1) { - auto& menu = menus.begin()->second; - menu.exec(m_tree->viewport()->mapToGlobal(pos)); - } else { - env::ShellMenuCollection mc(mw); - bool hasDiscrepancies = false; - - for (auto&& m : menus) { - const auto* origin = m_core.directoryStructure()->findOriginByID(m.first); - if (!origin) { - log::error("origin {} not found for merged menus", m.first); - continue; - } - - QString caption = QString::fromStdWString(origin->getName()); - if (m.second.fileCount() < totalFiles) { - const auto d = m.second.fileCount(); - caption += " " + tr("(only has %1 file(s))").arg(d); - hasDiscrepancies = true; - } - - mc.add(caption, std::move(m.second)); - } - - if (hasDiscrepancies) { - mc.addDetails(tr("%1 file(s) selected").arg(totalFiles)); - } - - mc.exec(m_tree->viewport()->mapToGlobal(pos)); - } - - return true; -} - -void FileTree::addDirectoryMenus(QMenu&, FileTreeItem&) -{ - // noop -} - -void FileTree::addFileMenus(QMenu& menu, const FileEntry& file, int originID) -{ - using namespace spawn; - - addOpenMenus(menu, file); - - menu.addSeparator(); - menu.setToolTipsVisible(true); - - const QFileInfo target(QString::fromStdWString(file.getFullPath())); - - MenuItem(tr("&Add as Executable")) - .callback([&] { - addAsExecutable(); - }) - .hint(tr("Add this file to the executables list")) - .disabledHint(tr("This file is not executable")) - .enabled(getFileExecutionType(target) == FileExecutionTypes::Executable) - .addTo(menu); - - MenuItem(tr("Reveal in E&xplorer")) - .callback([&] { - exploreOrigin(); - }) - .hint(tr("Opens the file in Explorer")) - .disabledHint(tr("This file is in an archive")) - .enabled(!file.isFromArchive()) - .addTo(menu); - - MenuItem(tr("Open &Mod Info")) - .callback([&] { - openModInfo(); - }) - .hint(tr("Opens the Mod Info Window")) - .disabledHint(tr("This file is not in a managed mod")) - .enabled(originID != 0) - .addTo(menu); - - if (isHidden(file)) { - MenuItem(tr("&Un-Hide")) - .callback([&] { - unhide(); - }) - .hint(tr("Un-hides the file")) - .disabledHint(tr("This file is in an archive")) - .enabled(!file.isFromArchive()) - .addTo(menu); - } else { - MenuItem(tr("&Hide")) - .callback([&] { - hide(); - }) - .hint(tr("Hides the file")) - .disabledHint(tr("This file is in an archive")) - .enabled(!file.isFromArchive()) - .addTo(menu); - } -} - -void FileTree::addOpenMenus(QMenu& menu, const MOShared::FileEntry& file) -{ - using namespace spawn; - - MenuItem openMenu, openHookedMenu; - - const QFileInfo target(QString::fromStdWString(file.getFullPath())); - - if (getFileExecutionType(target) == FileExecutionTypes::Executable) { - openMenu.caption(tr("&Execute")) - .callback([&] { - open(); - }) - .hint(tr("Launches this program")) - .disabledHint(tr("This file is in an archive")) - .enabled(!file.isFromArchive()); - - openHookedMenu.caption(tr("Execute with &VFS")) - .callback([&] { - openHooked(); - }) - .hint(tr("Launches this program hooked to the VFS")) - .disabledHint(tr("This file is in an archive")) - .enabled(!file.isFromArchive()); - } else { - openMenu.caption(tr("&Open")) - .callback([&] { - open(); - }) - .hint(tr("Opens this file with its default handler")) - .disabledHint(tr("This file is in an archive")) - .enabled(!file.isFromArchive()); - - openHookedMenu.caption(tr("Open with &VFS")) - .callback([&] { - openHooked(); - }) - .hint(tr("Opens this file with its default handler hooked to the VFS")) - .disabledHint(tr("This file is in an archive")) - .enabled(!file.isFromArchive()); - } - - MenuItem previewMenu(tr("&Preview")); - previewMenu - .callback([&] { - preview(); - }) - .hint(tr("Previews this file within Mod Organizer")) - .disabledHint(tr("This file is in an archive or has no preview handler " - "associated with it")) - .enabled(canPreviewFile(m_plugins, file)); - - if (m_core.settings().interface().doubleClicksOpenPreviews()) { - previewMenu.addTo(menu); - openMenu.addTo(menu); - openHookedMenu.addTo(menu); - } else { - openMenu.addTo(menu); - previewMenu.addTo(menu); - openHookedMenu.addTo(menu); - } - - // bold the first enabled option, only first three are considered - for (int i = 0; i < 3; ++i) { - if (i >= menu.actions().size()) { - break; - } - - auto* a = menu.actions()[i]; - - if (menu.actions()[i]->isEnabled()) { - auto f = a->font(); - f.setBold(true); - a->setFont(f); - break; - } - } -} - -void FileTree::addCommonMenus(QMenu& menu) -{ - menu.addSeparator(); - - MenuItem(tr("&Save Tree to Text File...")) - .callback([&] { - dumpToFile(); - }) - .hint(tr("Writes the list of files to a text file")) - .addTo(menu); - - MenuItem(tr("&Refresh")) - .callback([&] { - refresh(); - }) - .hint(tr("Refreshes the list")) - .addTo(menu); - - MenuItem(tr("Ex&pand All")) - .callback([&] { - expandAll(); - }) - .addTo(menu); - - MenuItem(tr("&Collapse All")) - .callback([&] { - collapseAll(); - }) - .addTo(menu); -} - -QModelIndex FileTree::proxiedIndex(const QModelIndex& index) -{ - auto* model = m_tree->model(); - - if (auto* proxy = dynamic_cast(model)) { - return proxy->mapToSource(index); - } else { - return index; - } -} - -void FileTree::collapseAll() -{ - m_tree->collapseAll(); -} - -void FileTree::expandAll() -{ - m_model->aboutToExpandAll(); - m_tree->expandAll(); - m_model->expandedAll(); -} +#include "filetree.h" +#include "envshell.h" +#include "filetreeitem.h" +#include "filetreemodel.h" +#include "organizercore.h" +#include "shared/directoryentry.h" +#include "shared/fileentry.h" +#include "shared/filesorigin.h" +#include +#include + +using namespace MOShared; +using namespace MOBase; + +bool canPreviewFile(const PluginContainer& pc, const FileEntry& file) +{ + return canPreviewFile(pc, file.isFromArchive(), + QString::fromStdWString(file.getName())); +} + +bool canRunFile(const FileEntry& file) +{ + return canRunFile(file.isFromArchive(), QString::fromStdWString(file.getName())); +} + +bool canOpenFile(const FileEntry& file) +{ + return canOpenFile(file.isFromArchive(), QString::fromStdWString(file.getName())); +} + +bool isHidden(const FileEntry& file) +{ + return (QString::fromStdWString(file.getName()) + .endsWith(ModInfo::s_HiddenExt, Qt::CaseInsensitive)); +} + +bool canExploreFile(const FileEntry& file); +bool canHideFile(const FileEntry& file); +bool canUnhideFile(const FileEntry& file); + +class MenuItem +{ +public: + MenuItem(QString s = {}) : m_action(new QAction(std::move(s))) {} + + MenuItem& caption(const QString& s) + { + m_action->setText(s); + return *this; + } + + template + MenuItem& callback(F&& f) + { + QObject::connect(m_action, &QAction::triggered, std::forward(f)); + return *this; + } + + MenuItem& hint(const QString& s) + { + m_tooltip = s; + return *this; + } + + MenuItem& disabledHint(const QString& s) + { + m_disabledHint = s; + return *this; + } + + MenuItem& enabled(bool b) + { + m_action->setEnabled(b); + return *this; + } + + void addTo(QMenu& menu) + { + QString const s; + + setTips(); + + m_action->setParent(&menu); + menu.addAction(m_action); + } + +private: + QAction* m_action; + QString m_tooltip; + QString m_disabledHint; + + void setTips() + { + if (m_action->isEnabled() || m_disabledHint.isEmpty()) { + m_action->setStatusTip(m_tooltip); + return; + } + + QString s = m_tooltip.trimmed(); + + if (!s.isEmpty()) { + if (!s.endsWith(".")) { + s += "."; + } + + s += "\n"; + } + + s += QObject::tr("Disabled because") + ": " + m_disabledHint.trimmed(); + + if (!s.endsWith(".")) { + s += "."; + } + + m_action->setStatusTip(s); + } +}; + +FileTree::FileTree(OrganizerCore& core, PluginContainer& pc, QTreeView* tree) + : m_core(core), m_plugins(pc), m_tree(tree), m_model(new FileTreeModel(core)) +{ + m_tree->sortByColumn(0, Qt::AscendingOrder); + m_tree->setModel(m_model); + m_tree->header()->resizeSection(0, 200); + + MOBase::setCustomizableColumns(m_tree); + + connect(m_tree, &QTreeView::customContextMenuRequested, [&](auto pos) { + onContextMenu(pos); + }); + + connect(m_tree, &QTreeView::expanded, [&](auto&& index) { + onExpandedChanged(index, true); + }); + + connect(m_tree, &QTreeView::collapsed, [&](auto&& index) { + onExpandedChanged(index, false); + }); + + connect(m_tree, &QTreeView::activated, [&](auto&& index) { + onItemActivated(index); + }); +} + +FileTreeModel* FileTree::model() +{ + return m_model; +} + +void FileTree::refresh() +{ + m_model->refresh(); +} + +void FileTree::clear() +{ + m_model->clear(); +} + +bool FileTree::fullyLoaded() const +{ + return m_model->fullyLoaded(); +} + +void FileTree::ensureFullyLoaded() +{ + m_model->ensureFullyLoaded(); +} + +FileTreeItem* FileTree::singleSelection() +{ + const auto sel = m_tree->selectionModel()->selectedRows(); + if (sel.size() == 1) { + return m_model->itemFromIndex(proxiedIndex(sel[0])); + } + + return nullptr; +} + +void FileTree::open(FileTreeItem* item) +{ + if (!item) { + item = singleSelection(); + + if (!item) { + return; + } + } + + if (item->isFromArchive() || item->isDirectory()) { + return; + } + + const QString path = item->realPath(); + const QFileInfo targetInfo(path); + + m_core.processRunner() + .setFromFile(m_tree->window(), targetInfo) + .setHooked(false) + .setWaitForCompletion(ProcessRunner::TriggerRefresh) + .run(); +} + +void FileTree::openHooked(FileTreeItem* item) +{ + if (!item) { + item = singleSelection(); + + if (!item) { + return; + } + } + + if (item->isFromArchive() || item->isDirectory()) { + return; + } + + const QString path = item->realPath(); + const QFileInfo targetInfo(path); + + m_core.processRunner() + .setFromFile(m_tree->window(), targetInfo) + .setHooked(true) + .setWaitForCompletion(ProcessRunner::TriggerRefresh) + .run(); +} + +void FileTree::preview(FileTreeItem* item) +{ + if (!item) { + item = singleSelection(); + + if (!item) { + return; + } + } + + const QString path = item->dataRelativeFilePath(); + m_core.previewFileWithAlternatives(m_tree->window(), path); +} + +void FileTree::activate(FileTreeItem* item) +{ + if (item->isDirectory()) { + // activating a directory should just toggle expansion + return; + } + + const auto tryPreview = m_core.settings().interface().doubleClicksOpenPreviews(); + + if (tryPreview) { + const QFileInfo fi(item->realPath()); + if (m_plugins.previewGenerator().previewSupported(fi.suffix().toLower(), + item->isFromArchive())) { + preview(item); + return; + } + } + + open(item); +} + +void FileTree::addAsExecutable(FileTreeItem* item) +{ + if (!item) { + item = singleSelection(); + + if (!item) { + return; + } + } + + const QString path = item->realPath(); + const QFileInfo target(path); + const auto fec = spawn::getFileExecutionContext(m_tree->window(), target); + + switch (fec.type) { + case spawn::FileExecutionTypes::Executable: { + const QString name = QInputDialog::getText( + m_tree->window(), tr("Enter Name"), tr("Enter a name for the executable"), + QLineEdit::Normal, target.completeBaseName()); + + if (!name.isEmpty()) { + // Note: If this already exists, you'll lose custom settings + m_core.executablesList()->setExecutable( + Executable() + .title(name) + .binaryInfo(fec.binary) + .arguments(fec.arguments) + .workingDirectory(target.absolutePath()) + .flags(Executable::UseProton)); + + emit executablesChanged(); + } + + break; + } + + case spawn::FileExecutionTypes::Other: // fall-through + default: { + QMessageBox::information(m_tree->window(), tr("Not an executable"), + tr("This is not a recognized executable.")); + + break; + } + } +} + +void FileTree::exploreOrigin(FileTreeItem* item) +{ + if (!item) { + item = singleSelection(); + + if (!item) { + return; + } + } + + if (item->isFromArchive() || item->isDirectory()) { + return; + } + + const auto path = item->realPath(); + + log::debug("opening in explorer: {}", path); + shell::Explore(path); +} + +void FileTree::openModInfo(FileTreeItem* item) +{ + if (!item) { + item = singleSelection(); + + if (!item) { + return; + } + } + + const auto originID = item->originID(); + + if (originID == 0) { + // unmanaged + return; + } + + const auto& origin = m_core.directoryStructure()->getOriginByID(originID); + const auto& name = QString::fromStdWString(origin.getName()); + + unsigned int const index = ModInfo::getIndex(name); + if (index == UINT_MAX) { + log::error("can't open mod info, mod '{}' not found", name); + return; + } + + ModInfo::Ptr const modInfo = ModInfo::getByIndex(index); + if (modInfo) { + emit displayModInformation(modInfo, index, ModInfoTabIDs::None); + } +} + +void FileTree::toggleVisibility(bool visible, FileTreeItem* item) +{ + if (!item) { + item = singleSelection(); + + if (!item) { + return; + } + } + + const QString currentName = item->realPath(); + QString newName; + + if (visible) { + if (!currentName.endsWith(ModInfo::s_HiddenExt)) { + log::error("cannot unhide '{}', doesn't end with '{}'", currentName, + ModInfo::s_HiddenExt); + + return; + } + + newName = currentName.left(currentName.size() - ModInfo::s_HiddenExt.size()); + } else { + if (currentName.endsWith(ModInfo::s_HiddenExt)) { + log::error("cannot hide '{}', already ends with '{}'", currentName, + ModInfo::s_HiddenExt); + + return; + } + + newName = currentName + ModInfo::s_HiddenExt; + } + + log::debug("attempting to rename '{}' to '{}'", currentName, newName); + + FileRenamer renamer(m_tree->window(), + (visible ? FileRenamer::UNHIDE : FileRenamer::HIDE)); + + if (renamer.rename(currentName, newName) == FileRenamer::RESULT_OK) { + emit originModified(item->originID()); + refresh(); + } +} + +void FileTree::hide(FileTreeItem* item) +{ + toggleVisibility(false, item); +} + +void FileTree::unhide(FileTreeItem* item) +{ + toggleVisibility(true, item); +} + +class DumpFailed +{}; + +void FileTree::dumpToFile() const +{ + log::debug("dumping filetree to file"); + + QString const file = QFileDialog::getSaveFileName(m_tree->window()); + if (file.isEmpty()) { + log::debug("user canceled"); + return; + } + + m_core.directoryStructure()->dump(file.toStdWString()); +} + +void FileTree::onExpandedChanged(const QModelIndex& index, bool expanded) +{ + if (auto* item = m_model->itemFromIndex(proxiedIndex(index))) { + item->setExpanded(expanded); + } +} + +void FileTree::onItemActivated(const QModelIndex& index) +{ + auto* item = m_model->itemFromIndex(proxiedIndex(index)); + if (!item) { + return; + } + + activate(item); +} + +void FileTree::onContextMenu(const QPoint& pos) +{ + const auto m = QApplication::keyboardModifiers(); + + if (m & Qt::ShiftModifier) { + // shift+right-click won't select individual items because it interferes + // with regular shift selection; this makes it behave like explorer: + // - if the right-clicked item is currently part of the selection, show + // the menu for the selection + // - if not, select this item only and show the menu for it + const auto index = m_tree->indexAt(pos); + + if (!m_tree->selectionModel()->isSelected(index)) { + m_tree->selectionModel()->select(index, QItemSelectionModel::ClearAndSelect | + QItemSelectionModel::Rows | + QItemSelectionModel::Current); + } + + // if no shell menu was available, continue on and show the regular + // context menu + if (showShellMenu(pos)) { + return; + } + } + + QMenu menu; + + if (auto* item = singleSelection()) { + if (item->isDirectory()) { + addDirectoryMenus(menu, *item); + } else { + const auto file = m_core.directoryStructure()->searchFile( + item->dataRelativeFilePath().toStdWString(), nullptr); + + if (file) { + addFileMenus(menu, *file, item->originID()); + } + } + } + + addCommonMenus(menu); + + menu.exec(m_tree->viewport()->mapToGlobal(pos)); +} + +QMainWindow* getMainWindow(QWidget* w) +{ + QWidget* p = w; + + while (p) { + if (auto* mw = dynamic_cast(p)) { + return mw; + } + + p = p->parentWidget(); + } + + return nullptr; +} + +bool FileTree::showShellMenu(QPoint pos) +{ + auto* mw = getMainWindow(m_tree); + + // menus by origin + std::map menus; + int totalFiles = 0; + bool warnOnEmpty = true; + + for (auto&& index : m_tree->selectionModel()->selectedRows()) { + auto* item = m_model->itemFromIndex(proxiedIndex(index)); + if (!item) { + continue; + } + + if (item->isDirectory()) { + warnOnEmpty = false; + + log::warn("directories do not have shell menus; '{}' selected", item->filename()); + + continue; + } + + if (item->isFromArchive()) { + warnOnEmpty = false; + + log::warn("files from archives do not have shell menus; '{}' selected", + item->filename()); + + continue; + } + + auto itor = menus.find(item->originID()); + if (itor == menus.end()) { + itor = menus.emplace(item->originID(), mw).first; + } + + if (!QFile::exists(item->realPath())) { + log::error("{}", tr("File '%1' does not exist, you may need to refresh.") + .arg(item->realPath())); + } + + itor->second.addFile(QFileInfo(item->realPath())); + ++totalFiles; + + if (item->isConflicted()) { + const auto file = m_core.directoryStructure()->searchFile( + item->dataRelativeFilePath().toStdWString(), nullptr); + + if (!file) { + log::error("file '{}' not found, data path={}, real path={}", item->filename(), + item->dataRelativeFilePath(), item->realPath()); + + continue; + } + + const auto alts = file->getAlternatives(); + if (alts.empty()) { + log::warn("file '{}' has no alternative origins but is marked as conflicted", + item->dataRelativeFilePath()); + } + + for (auto&& alt : alts) { + auto itor = menus.find(alt.originID()); + if (itor == menus.end()) { + itor = menus.emplace(alt.originID(), mw).first; + } + + const auto fullPath = file->getFullPath(alt.originID()); + if (fullPath.empty()) { + log::error("file {} not found in origin {}", item->dataRelativeFilePath(), + alt.originID()); + + continue; + } + + if (!QFile::exists(QString::fromStdWString(fullPath))) { + log::error("{}", tr("File '%1' does not exist, you may need to refresh.") + .arg(QString::fromStdWString(fullPath))); + } + + itor->second.addFile(QFileInfo(QString::fromStdWString(fullPath))); + } + } + } + + if (menus.empty()) { + // don't warn if something that doesn't have a shell menu was selected, a + // warning has already been logged above + if (warnOnEmpty) { + log::warn("no menus to show"); + } + + return false; + } else if (menus.size() == 1) { + auto& menu = menus.begin()->second; + menu.exec(m_tree->viewport()->mapToGlobal(pos)); + } else { + env::ShellMenuCollection mc(mw); + bool hasDiscrepancies = false; + + for (auto&& m : menus) { + const auto* origin = m_core.directoryStructure()->findOriginByID(m.first); + if (!origin) { + log::error("origin {} not found for merged menus", m.first); + continue; + } + + QString caption = QString::fromStdWString(origin->getName()); + if (env::ShellMenu::fileCount() < totalFiles) { + const auto d = env::ShellMenu::fileCount(); + caption += " " + tr("(only has %1 file(s))").arg(d); + hasDiscrepancies = true; + } + + mc.add(caption, std::move(m.second)); + } + + if (hasDiscrepancies) { + mc.addDetails(tr("%1 file(s) selected").arg(totalFiles)); + } + + mc.exec(m_tree->viewport()->mapToGlobal(pos)); + } + + return true; +} + +void FileTree::addDirectoryMenus(QMenu&, FileTreeItem&) +{ + // noop +} + +void FileTree::addFileMenus(QMenu& menu, const FileEntry& file, int originID) +{ + using namespace spawn; + + addOpenMenus(menu, file); + + menu.addSeparator(); + menu.setToolTipsVisible(true); + + const QFileInfo target(QString::fromStdWString(file.getFullPath())); + + MenuItem(tr("&Add as Executable")) + .callback([&] { + addAsExecutable(); + }) + .hint(tr("Add this file to the executables list")) + .disabledHint(tr("This file is not executable")) + .enabled(getFileExecutionType(target) == FileExecutionTypes::Executable) + .addTo(menu); + + MenuItem(tr("Reveal in E&xplorer")) + .callback([&] { + exploreOrigin(); + }) + .hint(tr("Opens the file in Explorer")) + .disabledHint(tr("This file is in an archive")) + .enabled(!file.isFromArchive()) + .addTo(menu); + + MenuItem(tr("Open &Mod Info")) + .callback([&] { + openModInfo(); + }) + .hint(tr("Opens the Mod Info Window")) + .disabledHint(tr("This file is not in a managed mod")) + .enabled(originID != 0) + .addTo(menu); + + if (isHidden(file)) { + MenuItem(tr("&Un-Hide")) + .callback([&] { + unhide(); + }) + .hint(tr("Un-hides the file")) + .disabledHint(tr("This file is in an archive")) + .enabled(!file.isFromArchive()) + .addTo(menu); + } else { + MenuItem(tr("&Hide")) + .callback([&] { + hide(); + }) + .hint(tr("Hides the file")) + .disabledHint(tr("This file is in an archive")) + .enabled(!file.isFromArchive()) + .addTo(menu); + } +} + +void FileTree::addOpenMenus(QMenu& menu, const MOShared::FileEntry& file) +{ + using namespace spawn; + + MenuItem openMenu; + MenuItem openHookedMenu; + + const QFileInfo target(QString::fromStdWString(file.getFullPath())); + + if (getFileExecutionType(target) == FileExecutionTypes::Executable) { + openMenu.caption(tr("&Execute")) + .callback([&] { + open(); + }) + .hint(tr("Launches this program")) + .disabledHint(tr("This file is in an archive")) + .enabled(!file.isFromArchive()); + + openHookedMenu.caption(tr("Execute with &VFS")) + .callback([&] { + openHooked(); + }) + .hint(tr("Launches this program hooked to the VFS")) + .disabledHint(tr("This file is in an archive")) + .enabled(!file.isFromArchive()); + } else { + openMenu.caption(tr("&Open")) + .callback([&] { + open(); + }) + .hint(tr("Opens this file with its default handler")) + .disabledHint(tr("This file is in an archive")) + .enabled(!file.isFromArchive()); + + openHookedMenu.caption(tr("Open with &VFS")) + .callback([&] { + openHooked(); + }) + .hint(tr("Opens this file with its default handler hooked to the VFS")) + .disabledHint(tr("This file is in an archive")) + .enabled(!file.isFromArchive()); + } + + MenuItem previewMenu(tr("&Preview")); + previewMenu + .callback([&] { + preview(); + }) + .hint(tr("Previews this file within Mod Organizer")) + .disabledHint(tr("This file is in an archive or has no preview handler " + "associated with it")) + .enabled(canPreviewFile(m_plugins, file)); + + if (m_core.settings().interface().doubleClicksOpenPreviews()) { + previewMenu.addTo(menu); + openMenu.addTo(menu); + openHookedMenu.addTo(menu); + } else { + openMenu.addTo(menu); + previewMenu.addTo(menu); + openHookedMenu.addTo(menu); + } + + // bold the first enabled option, only first three are considered + for (int i = 0; i < 3; ++i) { + if (i >= menu.actions().size()) { + break; + } + + auto* a = menu.actions()[i]; + + if (menu.actions()[i]->isEnabled()) { + auto f = a->font(); + f.setBold(true); + a->setFont(f); + break; + } + } +} + +void FileTree::addCommonMenus(QMenu& menu) +{ + menu.addSeparator(); + + MenuItem(tr("&Save Tree to Text File...")) + .callback([&] { + dumpToFile(); + }) + .hint(tr("Writes the list of files to a text file")) + .addTo(menu); + + MenuItem(tr("&Refresh")) + .callback([&] { + refresh(); + }) + .hint(tr("Refreshes the list")) + .addTo(menu); + + MenuItem(tr("Ex&pand All")) + .callback([&] { + expandAll(); + }) + .addTo(menu); + + MenuItem(tr("&Collapse All")) + .callback([&] { + collapseAll(); + }) + .addTo(menu); +} + +QModelIndex FileTree::proxiedIndex(const QModelIndex& index) +{ + auto* model = m_tree->model(); + + if (auto* proxy = dynamic_cast(model)) { + return proxy->mapToSource(index); + } else { + return index; + } +} + +void FileTree::collapseAll() +{ + m_tree->collapseAll(); +} + +void FileTree::expandAll() +{ + m_model->aboutToExpandAll(); + m_tree->expandAll(); + m_model->expandedAll(); +} diff --git a/src/src/filetreemodel.cpp b/src/src/filetreemodel.cpp index b60610a..bc6545b 100644 --- a/src/src/filetreemodel.cpp +++ b/src/src/filetreemodel.cpp @@ -1,1239 +1,1241 @@ -#include "filetreemodel.h" -#include "organizercore.h" -#include "shared/directoryentry.h" -#include "shared/fileentry.h" -#include "shared/filesorigin.h" -#include "shared/util.h" -#include -#include - -using namespace MOBase; -using namespace MOShared; -namespace fs = std::filesystem; - -// in mainwindow.cpp -QString UnmanagedModName(); - -#define trace(f) - -// about queueRemoveItem(), queueSortItems() and the `forFetching` parameter -// -// update() can be called when refreshing the tree or expanding a node; -// there are certain operations that cannot be done during node expansion, -// namely removing or moving items -// -// 1) removing items -// beginRemoveRows()/endRemoveRows() clears the internal list of visible -// items in a QTreeView, which is repopulated during the next layout -// -// doing this _during_ layout (which happens during node expansion) crashes -// in Qt because it assumes the list of visible items doesn't change; that -// is, fetchMore() cannot _remove_ items from the tree, it can only _add_ -// items -// -// 2) moving items -// when a QSortFilterProxyModel is used between a tree and the FileTreeModel, -// it maintains a mapping of indices between the source and proxy models; -// calling layoutAboutToBeChanged()/layoutChanged() clears that mapping -// -// the only time this is called in FileTreeModel is when sorting items, -// which can happen during layout because since it calls fetchMore() -// -// doing this _during_ layout (which happens during node expansion) crashes -// in Qt because the QSortFilterProxyModel assumes the mapping doesn't -// change; that is, fetchMore() cannot _move_ items in the tree, it can -// only _add_ items -// -// -// therefore, these two operations are queued in a 1ms timer which is processed -// immediately after update() -// -// note that not all instances of removing items are currently getting queued -// (such as when removeDisappearingFiles()) because they cannot happen while -// fetching - -// tracks a contiguous range in the model to avoid calling begin*Rows(), etc. -// for every single item that's added/removed -// -class FileTreeModel::Range -{ -public: - // note that file ranges can start from an index higher than 0 if there are - // directories - // - Range(FileTreeModel* model, FileTreeItem& parentItem, int start = 0) - : m_model(model), m_parentItem(parentItem), m_current(start) - {} - - // includes the current index in the range - // - void includeCurrent() - { - // just remember the start of the range, m_current will be used in add() - // or remove() to figure out the actual range - if (m_first == -1) { - m_first = m_current; - } - } - - // moves to the next row - // - void next() { ++m_current; } - - // returns the current row - // - int current() const { return m_current; } - - // manually set this range - // - void set(int first, int last) - { - m_first = first; - m_current = last; - } - - // adds the given items to this range - // - void add(FileTreeItem::Children toAdd) - { - if (m_first == -1) { - // nothing to add - MO_ASSERT(toAdd.empty()); - return; - } - - const auto last = m_current - 1; - const auto parentIndex = m_model->indexFromItem(m_parentItem); - - // make sure the number of items is the same as the size of this range - MO_ASSERT(static_cast(toAdd.size()) == (last - m_first + 1)); - - trace(log::debug("Range::add() {} to {}", m_first, last)); - - m_model->beginInsertRows(parentIndex, m_first, last); - - m_parentItem.insert(std::make_move_iterator(toAdd.begin()), - std::make_move_iterator(toAdd.end()), - static_cast(m_first)); - - m_model->endInsertRows(); - - // reset - m_first = -1; - } - - // removes the item in this range, returns an iterator to first item passed - // this range once removed, which can be end() - // - FileTreeItem::Children::const_iterator remove() - { - if (m_first >= 0) { - const auto last = m_current - 1; - const auto parentIndex = m_model->indexFromItem(m_parentItem); - - trace(log::debug("Range::remove() {} to {}", m_first, last)); - - m_model->beginRemoveRows(parentIndex, m_first, last); - - m_parentItem.remove(static_cast(m_first), - static_cast(last - m_first + 1)); - - m_model->endRemoveRows(); - - m_model->removePendingIcons(parentIndex, m_first, last); - - // adjust current row to account for those that were just removed - m_current -= (m_current - m_first); - - // reset - m_first = -1; - } - - if (m_current >= m_parentItem.children().size()) { - return m_parentItem.children().end(); - } - - return m_parentItem.children().begin() + m_current + 1; - } - - static void removeChildren(FileTreeModel* model, FileTreeItem& parentItem) - { - Range r(model, parentItem); - r.set(0, static_cast(parentItem.children().size())); - r.remove(); - parentItem.clear(); - } - -private: - FileTreeModel* m_model; - FileTreeItem& m_parentItem; - int m_first{-1}; - int m_current; -}; - -FileTreeItem* getItem(const QModelIndex& index) -{ - return static_cast(index.internalPointer()); -} - -void* makeInternalPointer(FileTreeItem* item) -{ - return item; -} - -FileTreeModel::FileTreeModel(OrganizerCore& core, QObject* parent) - : QAbstractItemModel(parent), m_core(core), - m_root(FileTreeItem::createDirectory(this, nullptr, L"", L"")), - m_flags(HiddenFiles) -{ - m_root->setExpanded(true); - m_sortTimer.setSingleShot(true); - - connect(&m_removeTimer, &QTimer::timeout, [&] { - removeItems(); - }); - connect(&m_sortTimer, &QTimer::timeout, [&] { - sortItems(); - }); - connect(&m_iconPendingTimer, &QTimer::timeout, [&] { - updatePendingIcons(); - }); -} - -void FileTreeModel::refresh() -{ - TimeThis const tt("FileTreeModel::refresh()"); - - m_fullyLoaded = false; - update(*m_root, *m_core.directoryStructure(), L"", false); - sortItem(*m_root, false); -} - -void FileTreeModel::clear() -{ - m_fullyLoaded = false; - - beginResetModel(); - m_root->clear(); - endResetModel(); -} - -void FileTreeModel::recursiveFetchMore(const QModelIndex& m) -{ - if (canFetchMore(m)) { - doFetchMore(m, false, false); - } - - for (int i = 0; i < rowCount(m); ++i) { - recursiveFetchMore(index(i, 0, m)); - } -} - -void FileTreeModel::ensureFullyLoaded() -{ - if (!m_fullyLoaded) { - TimeThis const tt("FileTreeModel:: fully loading for search"); - recursiveFetchMore(QModelIndex()); - sortItem(*m_root, false); - m_fullyLoaded = true; - } -} - -bool FileTreeModel::enabled() const -{ - return m_enabled; -} - -void FileTreeModel::setEnabled(bool b) -{ - m_enabled = b; -} - -void FileTreeModel::aboutToExpandAll() -{ - m_sortingEnabled = false; -} - -void FileTreeModel::expandedAll() -{ - m_sortingEnabled = true; - sortItem(*m_root, false); -} - -const FileTreeModel::SortInfo& FileTreeModel::sortInfo() const -{ - return m_sort; -} - -bool FileTreeModel::showArchives() const -{ - return (m_flags.testFlag(Archives) && m_core.settings().archiveParsing()); -} - -bool FileTreeModel::showHiddenFiles() const -{ - return m_flags.testAnyFlag(HiddenFiles); -} - -QModelIndex FileTreeModel::index(int row, int col, const QModelIndex& parentIndex) const -{ - if (auto* parentItem = itemFromIndex(parentIndex)) { - if (row < 0 || row >= parentItem->children().size()) { - log::error("row {} out of range for {}", row, parentItem->debugName()); - return {}; - } - - return createIndex(row, col, makeInternalPointer(parentItem)); - } - - log::error("FileTreeModel::index(): parentIndex has no internal pointer"); - return {}; -} - -QModelIndex FileTreeModel::parent(const QModelIndex& index) const -{ - if (!index.isValid()) { - return {}; - } - - auto* parentItem = getItem(index); - if (!parentItem) { - log::error("FileTreeModel::parent(): no internal pointer"); - return {}; - } - - return indexFromItem(*parentItem); -} - -int FileTreeModel::rowCount(const QModelIndex& parent) const -{ - if (auto* item = itemFromIndex(parent)) { - return static_cast(item->children().size()); - } - - return 0; -} - -int FileTreeModel::columnCount(const QModelIndex&) const -{ - return ColumnCount; -} - -bool FileTreeModel::hasChildren(const QModelIndex& parent) const -{ - if (!m_enabled) { - return false; - } - - if (auto* item = itemFromIndex(parent)) { - if (parent.column() <= 0) { - return item->hasChildren(); - } - } - - return false; -} - -bool FileTreeModel::canFetchMore(const QModelIndex& parent) const -{ - if (!m_enabled) { - return false; - } - - if (auto* item = itemFromIndex(parent)) { - return !item->isLoaded(); - } - - return false; -} - -void FileTreeModel::fetchMore(const QModelIndex& parent) -{ - doFetchMore(parent, true, true); -} - -void FileTreeModel::doFetchMore(const QModelIndex& parent, bool forFetch, bool doSort) -{ - FileTreeItem* item = itemFromIndex(parent); - if (!item) { - return; - } - - const auto path = item->dataRelativeFilePath(); - - auto* parentEntry = - m_core.directoryStructure()->findSubDirectoryRecursive(path.toStdWString()); - - if (!parentEntry) { - log::error("FileTreeModel::fetchMore(): directory '{}' not found", path); - return; - } - - const auto parentPath = item->dataRelativeParentPath(); - update(*item, *parentEntry, parentPath.toStdWString(), forFetch); - - if (!forFetch && doSort) { - sortItem(*item, false); - } -} - -QVariant FileTreeModel::data(const QModelIndex& index, int role) const -{ - switch (role) { - case Qt::DisplayRole: { - if (auto* item = itemFromIndex(index)) { - return displayData(item, index.column()); - } - - break; - } - - case Qt::FontRole: { - if (auto* item = itemFromIndex(index)) { - return item->font(); - } - - break; - } - - case Qt::ToolTipRole: { - if (auto* item = itemFromIndex(index)) { - return makeTooltip(*item); - } - - return {}; - } - - case Qt::ForegroundRole: { - if (index.column() == 1) { - if (auto* item = itemFromIndex(index)) { - if (item->isConflicted()) { - return QBrush(Qt::red); - } - } - } - - break; - } - - case Qt::DecorationRole: { - if (index.column() == 0) { - if (auto* item = itemFromIndex(index)) { - return makeIcon(*item, index); - } - } - - break; - } - } - - return {}; -} - -QVariant FileTreeModel::headerData(int i, Qt::Orientation ori, int role) const -{ - static const std::array names = { - tr("Name"), tr("Mod"), tr("Type"), tr("Size"), tr("Date modified")}; - - if (role == Qt::DisplayRole) { - if (i >= 0 && i < static_cast(names.size())) { - return names[static_cast(i)]; - } - } - - return {}; -} - -Qt::ItemFlags FileTreeModel::flags(const QModelIndex& index) const -{ - auto f = QAbstractItemModel::flags(index); - - if (auto* item = itemFromIndex(index)) { - if (!item->hasChildren()) { - f |= Qt::ItemNeverHasChildren; - } - } - - return f; -} - -void FileTreeModel::sortItem(FileTreeItem& item, bool force) -{ - emit layoutAboutToBeChanged({}, QAbstractItemModel::VerticalSortHint); - - const auto oldList = persistentIndexList(); - std::vector> oldItems; - - const auto itemCount = oldList.size(); - oldItems.reserve(static_cast(itemCount)); - - for (int i = 0; i < itemCount; ++i) { - const QModelIndex& index = oldList[i]; - oldItems.push_back({itemFromIndex(index), index.column()}); - } - - item.sort(m_sort.column, m_sort.order, force); - - QModelIndexList newList; - newList.reserve(itemCount); - - for (int i = 0; i < itemCount; ++i) { - const auto& pair = oldItems[static_cast(i)]; - newList.append(indexFromItem(*pair.first, pair.second)); - } - - changePersistentIndexList(oldList, newList); - - emit layoutChanged({}, QAbstractItemModel::VerticalSortHint); -} - -void FileTreeModel::sort(int column, Qt::SortOrder order) -{ - m_sort.column = column; - m_sort.order = order; - - sortItem(*m_root, true); -} - -FileTreeItem* FileTreeModel::itemFromIndex(const QModelIndex& index) const -{ - if (!index.isValid()) { - return m_root.get(); - } - - auto* parentItem = getItem(index); - if (!parentItem) { - log::error("FileTreeModel::itemFromIndex(): no internal pointer"); - return nullptr; - } - - if (index.row() < 0 || index.row() >= parentItem->children().size()) { - log::error("FileTreeModel::itemFromIndex(): row {} is out of range for {}", - index.row(), parentItem->debugName()); - - return nullptr; - } - - return parentItem->children()[index.row()].get(); -} - -QModelIndex FileTreeModel::indexFromItem(FileTreeItem& item, int col) const -{ - auto* parent = item.parent(); - if (!parent) { - return {}; - } - - const int index = parent->childIndex(item); - if (index == -1) { - log::error("FileTreeMode::indexFromItem(): item {} not found in parent", - item.debugName()); - - return {}; - } - - return createIndex(index, col, makeInternalPointer(parent)); -} - -void FileTreeModel::update(FileTreeItem& parentItem, - const MOShared::DirectoryEntry& parentEntry, - const std::wstring& parentPath, bool forFetching) -{ - trace(log::debug("updating {}", parentItem.debugName())); - - auto path = parentPath; - if (!parentEntry.isTopLevel()) { - if (!path.empty()) { - path += MOShared::NativeWPathSep; - } - - path += parentEntry.getName(); - } - - parentItem.setLoaded(true); - - bool added = false; - - if (updateDirectories(parentItem, path, parentEntry, forFetching)) { - added = true; - } - - if (updateFiles(parentItem, path, parentEntry)) { - added = true; - } - - if (added) { - parentItem.makeSortingStale(); - - // see comment at the top of this file - if (forFetching) { - // don't pass a specific item, this will start a timer and re-sort the - // whole tree, which is faster than potentially queuing every single - // node if the whole tree is expanded - queueSortItem(nullptr); - } - } -} - -bool FileTreeModel::updateDirectories(FileTreeItem& parentItem, - const std::wstring& parentPath, - const MOShared::DirectoryEntry& parentEntry, - bool forFetching) -{ - // removeDisappearingDirectories() will add directories that are in the - // tree and still on the filesystem to this set; addNewDirectories() will - // use this to figure out if a directory is new or not - std::unordered_set seen; - - removeDisappearingDirectories(parentItem, parentEntry, parentPath, seen, forFetching); - return addNewDirectories(parentItem, parentEntry, parentPath, seen); -} - -void FileTreeModel::removeDisappearingDirectories( - FileTreeItem& parentItem, const MOShared::DirectoryEntry& parentEntry, - const std::wstring& parentPath, std::unordered_set& seen, - bool forFetching) -{ - auto& children = parentItem.children(); - auto itor = children.begin(); - - // keeps track of the contiguous directories that need to be removed to - // avoid calling beginRemoveRows(), etc. for each item - Range range(this, parentItem); - - // for each item in this tree item - while (itor != children.end()) { - const auto& item = *itor; - - if (!item->isDirectory()) { - // directories are always first, no point continuing once a file has - // been seen - break; - } - - auto d = parentEntry.findSubDirectory(item->filenameWsLowerCase(), true); - - if (d) { - trace(log::debug("dir {} still there", item->filename())); - - // directory is still there - seen.emplace(d->getName()); - - bool currentRemoved = false; - - if (item->areChildrenVisible()) { - // the item is currently expanded, update it - update(*item, *d, parentPath, forFetching); - } - - if (shouldShowFolder(*d, item.get())) { - // folder should be left in the list - if (!item->areChildrenVisible() && item->isLoaded()) { - if (!d->isEmpty()) { - // the item is loaded (previously expanded but now collapsed) and - // has children, mark it as unloaded so it updates when next - // expanded - item->setLoaded(false); - - if (!item->children().empty()) { - // if the item had children, remove them from the tree - - // see comment at the top of this file - if (forFetching) { - queueRemoveItem(item.get()); - } else { - Range::removeChildren(this, *item); - } - } - } - } - } else { - // item wouldn't have any children, prune it - trace(log::debug("dir {} is empty and pruned", item->filename())); - - range.includeCurrent(); - currentRemoved = true; - ++itor; - } - - if (!currentRemoved) { - // if there were directories before this row that need to be removed, - // do it now - itor = range.remove(); - } - } else { - // directory is gone from the parent entry - trace(log::debug("dir {} is gone", item->filename())); - - range.includeCurrent(); - ++itor; - } - - range.next(); - } - - // remove the last directory range, if any - range.remove(); -} - -bool FileTreeModel::addNewDirectories(FileTreeItem& parentItem, - const MOShared::DirectoryEntry& parentEntry, - const std::wstring& parentPath, - const std::unordered_set& seen) -{ - // keeps track of the contiguous directories that need to be added to - // avoid calling beginAddRows(), etc. for each item - Range range(this, parentItem); - std::vector toAdd; - bool added = false; - - // for each directory on the filesystem - for (auto&& d : parentEntry.getSubDirectories()) { - if (seen.contains(d->getName())) { - // already seen in the parent item - - // if there were directories before this row that need to be added, - // do it now - // - // todo: if the directory was actually removed in - // removeDisappearingDirectories(), the range doesn't need to be added - // now and could be extended further - range.add(std::move(toAdd)); - toAdd.clear(); - } else { - if (!shouldShowFolder(*d, nullptr)) { - // this is a new directory, but it doesn't contain anything interesting - trace(log::debug("new dir {}, empty and pruned", - QString::fromStdWString(d->getName()))); - - // act as if this directory doesn't exist at all - continue; - } - - // this is a new directory - trace(log::debug("new dir {}", QString::fromStdWString(d->getName()))); - - toAdd.push_back(createDirectoryItem(parentItem, parentPath, *d)); - added = true; - - range.includeCurrent(); - } - - range.next(); - } - - // add the last directory range, if any - range.add(std::move(toAdd)); - - return added; -} - -bool FileTreeModel::updateFiles(FileTreeItem& parentItem, - const std::wstring& parentPath, - const MOShared::DirectoryEntry& parentEntry) -{ - // removeDisappearingFiles() will add files that are in the tree and still on - // the filesystem to this set; addNewFiless() will use this to figure out if - // a file is new or not - std::unordered_set seen; - - int firstFileRow = 0; - - removeDisappearingFiles(parentItem, parentEntry, firstFileRow, seen); - return addNewFiles(parentItem, parentEntry, parentPath, firstFileRow, seen); -} - -void FileTreeModel::removeDisappearingFiles(FileTreeItem& parentItem, - const MOShared::DirectoryEntry& parentEntry, - int& firstFileRow, - std::unordered_set& seen) -{ - auto& children = parentItem.children(); - auto itor = children.begin(); - - firstFileRow = -1; - - // keeps track of the contiguous directories that need to be removed to - // avoid calling beginRemoveRows(), etc. for each item - Range range(this, parentItem); - - // for each item in this tree item - while (itor != children.end()) { - const auto& item = *itor; - - if (!item->isDirectory()) { - if (firstFileRow == -1) { - firstFileRow = range.current(); - } - - auto f = parentEntry.findFile(item->key()); - - if (f && shouldShowFile(*f)) { - trace(log::debug("file {} still there", item->filename())); - - // file is still there - seen.emplace(f->getIndex()); - - if (f->getOrigin() != item->originID()) { - // origin has changed - updateFileItem(*item, *f); - } - - // if there were files before this row that need to be removed, - // do it now - itor = range.remove(); - } else { - // file is gone from the parent entry - trace(log::debug("file {} is gone", item->filename())); - - range.includeCurrent(); - ++itor; - } - } else { - ++itor; - } - - range.next(); - } - - // remove the last file range, if any - range.remove(); - - if (firstFileRow == -1) { - firstFileRow = static_cast(children.size()); - } -} - -bool FileTreeModel::addNewFiles(FileTreeItem& parentItem, - const MOShared::DirectoryEntry& parentEntry, - const std::wstring& parentPath, const int firstFileRow, - const std::unordered_set& seen) -{ - // keeps track of the contiguous files that need to be added to - // avoid calling beginAddRows(), etc. for each item - std::vector toAdd; - Range range(this, parentItem, firstFileRow); - bool added = false; - - // for each directory on the filesystem - parentEntry.forEachFileIndex([&](auto&& fileIndex) { - if (seen.contains(fileIndex)) { - // already seen in the parent item - - // if there were directories before this row that need to be added, - // do it now - range.add(std::move(toAdd)); - toAdd.clear(); - } else { - const auto file = parentEntry.getFileByIndex(fileIndex); - - if (!file) { - log::error("FileTreeModel::addNewFiles(): file index {} in path {} not found", - fileIndex, parentPath); - - return true; - } - - if (shouldShowFile(*file)) { - // this is a new file - trace(log::debug("new file {}", QString::fromStdWString(file->getName()))); - - toAdd.push_back(createFileItem(parentItem, parentPath, *file)); - added = true; - - range.includeCurrent(); - } else { - // this is a new file, but it shouldn't be shown - trace(log::debug("new file {}, not shown", - QString::fromStdWString(file->getName()))); - return true; - } - } - - range.next(); - - return true; - }); - - // add the last file range, if any - range.add(std::move(toAdd)); - - return added; -} - -void FileTreeModel::queueRemoveItem(FileTreeItem* item) -{ - trace(log::debug("queuing {} for removal", item->debugName())); - - m_removeItems.push_back(item); - m_removeTimer.start(1); -} - -void FileTreeModel::removeItems() -{ - // see comment at the top of this file - trace(log::debug("remove item timer: removing {} items", m_removeItems.size())); - - auto copy = std::move(m_removeItems); - m_removeItems.clear(); - m_removeTimer.stop(); - - for (auto&& f : copy) { - Range::removeChildren(this, *f); - } -} - -void FileTreeModel::queueSortItem(FileTreeItem* item) -{ - if (!m_sortingEnabled) { - return; - } - - if (item) { - m_sortItems.push_back(item); - } - - m_sortTimer.start(1); -} - -void FileTreeModel::sortItems() -{ - // see comment at the top of this file - - if (m_sortItems.empty()) { - sortItem(*m_root, false); - } else { - log::debug("sort item timer: sorting {} items", m_sortItems.size()); - - auto items = std::move(m_sortItems); - m_sortItems.clear(); - - for (auto* item : items) { - sortItem(*item, false); - } - } -} - -FileTreeItem::Ptr FileTreeModel::createDirectoryItem(FileTreeItem& parentItem, - const std::wstring& parentPath, - const DirectoryEntry& d) -{ - auto item = FileTreeItem::createDirectory(this, &parentItem, parentPath, d.getName()); - - if (d.isEmpty()) { - // if this directory is empty, mark the item as loaded so the expand - // arrow doesn't show - item->setLoaded(true); - } - - return item; -} - -FileTreeItem::Ptr FileTreeModel::createFileItem(FileTreeItem& parentItem, - const std::wstring& parentPath, - const FileEntry& file) -{ - auto item = FileTreeItem::createFile(this, &parentItem, parentPath, file.getName()); - - updateFileItem(*item, file); - - item->setLoaded(true); - - return item; -} - -void FileTreeModel::updateFileItem(FileTreeItem& item, const MOShared::FileEntry& file) -{ - bool isArchive = false; - int const originID = file.getOrigin(isArchive); - - FileTreeItem::Flags flags = FileTreeItem::NoFlags; - - if (isArchive) { - flags |= FileTreeItem::FromArchive; - } - - if (!file.getAlternatives().empty()) { - flags |= FileTreeItem::Conflicted; - } - - item.setOrigin(originID, file.getFullPath(), flags, makeModName(file, originID)); - - if (file.getFileSize() != FileEntry::NoFileSize) { - item.setFileSize(file.getFileSize()); - } - - if (file.getCompressedFileSize() != FileEntry::NoFileSize) { - item.setCompressedFileSize(file.getCompressedFileSize()); - } -} - -bool FileTreeModel::shouldShowFile(const FileEntry& file) const -{ - if (showConflictsOnly() && - ((file.getAlternatives().empty()) || - QString::fromStdWString(file.getName()) - .endsWith(ModInfo::s_HiddenExt, Qt::CaseInsensitive))) { - // only conflicts should be shown, but this file is hidden or not conflicted - return false; - } - - if (!showArchives() && file.isFromArchive()) { - // files from archives shouldn't be shown, but this file is from an archive - return false; - } - - if (!showHiddenFiles() && QString::fromStdWString(file.getName()) - .endsWith(ModInfo::s_HiddenExt, Qt::CaseInsensitive)) { - // hidden files shouldn't be shown, but this file is hidden - return false; - } - - return true; -} - -bool FileTreeModel::shouldShowFolder(const DirectoryEntry& dir, - const FileTreeItem* item) const -{ - if ((!showHiddenFiles() || showConflictsOnly()) && - QString::fromStdWString(dir.getName()) - .endsWith(ModInfo::s_HiddenExt, Qt::CaseInsensitive)) { - return false; - } - - bool shouldPrune = m_flags.testFlag(PruneDirectories); - - if (m_core.settings().archiveParsing()) { - if (!m_flags.testFlag(Archives)) { - // archive parsing is enabled but the tree shouldn't show archives; this - // is a bit of a special case for folders because they have to be hidden - // regardless of the PruneDirectories flag if they only exist in archives - // - // note that this test is inaccurate: if a loose folder exists but is - // empty, and the same folder exists in an archive but is _not_ empty, - // then it's considered to exist _only_ in an archive and will be pruned - // - // if directories are ever made first-class so they can retain their - // origins, this test can be made more accurate - shouldPrune = true; - } - } - - if (!shouldPrune) { - // always show folders regardless of their content - return true; - } - - if (item) { - if (item->isLoaded() && item->children().empty()) { - // item is loaded and has no children; prune it - return false; - } - } - - bool foundFile = false; - - // check all files in this directory, return early if a file should be shown - dir.forEachFile([&](auto&& f) { - if (shouldShowFile(f)) { - foundFile = true; - - // stop - return false; - } - - // continue - return true; - }); - - if (foundFile) { - return true; - } - - // recurse into subdirectories - for (auto subdir : dir.getSubDirectories()) { - if (shouldShowFolder(*subdir, nullptr)) { - return true; - } - } - - return false; -} - -QVariant FileTreeModel::displayData(const FileTreeItem* item, int column) -{ - switch (column) { - case FileName: { - return item->filename(); - } - - case ModName: { - return item->mod(); - } - - case FileType: { - return item->fileType().value_or(QString()); - } - - case FileSize: { - if (item->isDirectory()) { - return {}; - } else { - QString fs; - - if (auto n = item->fileSize()) { - fs = localizedByteSize(*n); - } - - if (auto n = item->compressedFileSize()) { - return QString("%1 (%2)").arg(fs).arg(localizedByteSize(*n)); - } else { - return fs; - } - } - } - - case LastModified: { - if (auto d = item->lastModified()) { - if (d.has_value() && d.value().isValid()) { - return QLocale::system().toString(d.value(), QLocale::ShortFormat); - } - } - - return {}; - } - - default: { - return {}; - } - } -} - -std::wstring FileTreeModel::makeModName(const MOShared::FileEntry& file, - int originID) const -{ - static const std::wstring Unmanaged = UnmanagedModName().toStdWString(); - - const auto& origin = m_core.directoryStructure()->getOriginByID(originID); - - if (origin.getID() == 0) { - return Unmanaged; - } - - std::wstring name = origin.getName(); - - const auto& archive = file.getArchive(); - if (!archive.name().empty()) { - name += L" (" + archive.name() + L")"; - } - - return name; -} - -QString FileTreeModel::makeTooltip(const FileTreeItem& item) const -{ - auto nowrap = [&](auto&& s) { - return "

" + s + "

"; - }; - - auto line = [&](auto&& caption, auto&& value) { - if (value.isEmpty()) { - return nowrap("" + caption + ":\n"); - } else { - return nowrap("" + caption + ": " + value.toHtmlEscaped()) + "\n"; - } - }; - - if (item.isDirectory()) { - return line(tr("Directory"), item.filename()) + - line(tr("Virtual path"), item.virtualPath()); - } - - static const QString ListStart = "
    "; - - static const QString ListEnd = "
"; - - QString s = line(tr("Virtual path"), item.virtualPath()) + - line(tr("Real path"), item.realPath()) + line(tr("From"), item.mod()); - - const auto file = m_core.directoryStructure()->searchFile( - item.dataRelativeFilePath().toStdWString(), nullptr); - - if (file) { - const auto alternatives = file->getAlternatives(); - QStringList list; - - for (auto&& alt : alternatives) { - const auto& origin = m_core.directoryStructure()->getOriginByID(alt.originID()); - list.push_back(QString::fromStdWString(origin.getName())); - } - - if (list.size() == 1) { - s += line(tr("Also in"), list[0]); - } else if (list.size() >= 2) { - s += line(tr("Also in"), QString()) + ListStart; - - for (auto&& alt : list) { - s += "
  • " + alt + "
  • "; - } - - s += ListEnd; - } - } - - return s; -} - -QVariant FileTreeModel::makeIcon(const FileTreeItem& item, - const QModelIndex& index) const -{ - if (item.isDirectory()) { - return m_iconFetcher.genericDirectoryIcon(); - } - - auto v = m_iconFetcher.icon(item.realPath()); - if (!v.isNull()) { - return v; - } - - m_iconPending.push_back(index); - m_iconPendingTimer.start(std::chrono::milliseconds(1)); - - return m_iconFetcher.genericFileIcon(); -} - -void FileTreeModel::updatePendingIcons() -{ - std::vector const v(std::move(m_iconPending)); - m_iconPending.clear(); - - for (auto&& index : v) { - emit dataChanged(index, index, {Qt::DecorationRole}); - } - - if (m_iconPending.empty()) { - m_iconPendingTimer.stop(); - } -} - -void FileTreeModel::removePendingIcons(const QModelIndex& parent, int first, int last) -{ - auto itor = m_iconPending.begin(); - - while (itor != m_iconPending.end()) { - if (itor->parent() == parent) { - if (itor->row() >= first && itor->row() <= last) { - itor = m_iconPending.erase(itor); - continue; - } - } - - ++itor; - } -} +#include "filetreemodel.h" +#include "organizercore.h" +#include "shared/directoryentry.h" +#include "shared/fileentry.h" +#include "shared/filesorigin.h" +#include "shared/util.h" +#include +#include + +#include + +using namespace MOBase; +using namespace MOShared; +namespace fs = std::filesystem; + +// in mainwindow.cpp +QString UnmanagedModName(); + +#define trace(f) + +// about queueRemoveItem(), queueSortItems() and the `forFetching` parameter +// +// update() can be called when refreshing the tree or expanding a node; +// there are certain operations that cannot be done during node expansion, +// namely removing or moving items +// +// 1) removing items +// beginRemoveRows()/endRemoveRows() clears the internal list of visible +// items in a QTreeView, which is repopulated during the next layout +// +// doing this _during_ layout (which happens during node expansion) crashes +// in Qt because it assumes the list of visible items doesn't change; that +// is, fetchMore() cannot _remove_ items from the tree, it can only _add_ +// items +// +// 2) moving items +// when a QSortFilterProxyModel is used between a tree and the FileTreeModel, +// it maintains a mapping of indices between the source and proxy models; +// calling layoutAboutToBeChanged()/layoutChanged() clears that mapping +// +// the only time this is called in FileTreeModel is when sorting items, +// which can happen during layout because since it calls fetchMore() +// +// doing this _during_ layout (which happens during node expansion) crashes +// in Qt because the QSortFilterProxyModel assumes the mapping doesn't +// change; that is, fetchMore() cannot _move_ items in the tree, it can +// only _add_ items +// +// +// therefore, these two operations are queued in a 1ms timer which is processed +// immediately after update() +// +// note that not all instances of removing items are currently getting queued +// (such as when removeDisappearingFiles()) because they cannot happen while +// fetching + +// tracks a contiguous range in the model to avoid calling begin*Rows(), etc. +// for every single item that's added/removed +// +class FileTreeModel::Range +{ +public: + // note that file ranges can start from an index higher than 0 if there are + // directories + // + Range(FileTreeModel* model, FileTreeItem& parentItem, int start = 0) + : m_model(model), m_parentItem(parentItem), m_current(start) + {} + + // includes the current index in the range + // + void includeCurrent() + { + // just remember the start of the range, m_current will be used in add() + // or remove() to figure out the actual range + if (m_first == -1) { + m_first = m_current; + } + } + + // moves to the next row + // + void next() { ++m_current; } + + // returns the current row + // + int current() const { return m_current; } + + // manually set this range + // + void set(int first, int last) + { + m_first = first; + m_current = last; + } + + // adds the given items to this range + // + void add(FileTreeItem::Children toAdd) + { + if (m_first == -1) { + // nothing to add + MO_ASSERT(toAdd.empty()); + return; + } + + const auto last = m_current - 1; + const auto parentIndex = m_model->indexFromItem(m_parentItem); + + // make sure the number of items is the same as the size of this range + MO_ASSERT(static_cast(toAdd.size()) == (last - m_first + 1)); + + trace(log::debug("Range::add() {} to {}", m_first, last)); + + m_model->beginInsertRows(parentIndex, m_first, last); + + m_parentItem.insert(std::make_move_iterator(toAdd.begin()), + std::make_move_iterator(toAdd.end()), + static_cast(m_first)); + + m_model->endInsertRows(); + + // reset + m_first = -1; + } + + // removes the item in this range, returns an iterator to first item passed + // this range once removed, which can be end() + // + FileTreeItem::Children::const_iterator remove() + { + if (m_first >= 0) { + const auto last = m_current - 1; + const auto parentIndex = m_model->indexFromItem(m_parentItem); + + trace(log::debug("Range::remove() {} to {}", m_first, last)); + + m_model->beginRemoveRows(parentIndex, m_first, last); + + m_parentItem.remove(static_cast(m_first), + static_cast(last - m_first + 1)); + + m_model->endRemoveRows(); + + m_model->removePendingIcons(parentIndex, m_first, last); + + // adjust current row to account for those that were just removed + m_current -= (m_current - m_first); + + // reset + m_first = -1; + } + + if (m_current >= m_parentItem.children().size()) { + return m_parentItem.children().end(); + } + + return m_parentItem.children().begin() + m_current + 1; + } + + static void removeChildren(FileTreeModel* model, FileTreeItem& parentItem) + { + Range r(model, parentItem); + r.set(0, static_cast(parentItem.children().size())); + r.remove(); + parentItem.clear(); + } + +private: + FileTreeModel* m_model; + FileTreeItem& m_parentItem; + int m_first{-1}; + int m_current; +}; + +FileTreeItem* getItem(const QModelIndex& index) +{ + return static_cast(index.internalPointer()); +} + +void* makeInternalPointer(FileTreeItem* item) +{ + return item; +} + +FileTreeModel::FileTreeModel(OrganizerCore& core, QObject* parent) + : QAbstractItemModel(parent), m_core(core), + m_root(FileTreeItem::createDirectory(this, nullptr, L"", L"")), + m_flags(HiddenFiles) +{ + m_root->setExpanded(true); + m_sortTimer.setSingleShot(true); + + connect(&m_removeTimer, &QTimer::timeout, [&] { + removeItems(); + }); + connect(&m_sortTimer, &QTimer::timeout, [&] { + sortItems(); + }); + connect(&m_iconPendingTimer, &QTimer::timeout, [&] { + updatePendingIcons(); + }); +} + +void FileTreeModel::refresh() +{ + TimeThis const tt("FileTreeModel::refresh()"); + + m_fullyLoaded = false; + update(*m_root, *m_core.directoryStructure(), L"", false); + sortItem(*m_root, false); +} + +void FileTreeModel::clear() +{ + m_fullyLoaded = false; + + beginResetModel(); + m_root->clear(); + endResetModel(); +} + +void FileTreeModel::recursiveFetchMore(const QModelIndex& m) +{ + if (canFetchMore(m)) { + doFetchMore(m, false, false); + } + + for (int i = 0; i < rowCount(m); ++i) { + recursiveFetchMore(index(i, 0, m)); + } +} + +void FileTreeModel::ensureFullyLoaded() +{ + if (!m_fullyLoaded) { + TimeThis const tt("FileTreeModel:: fully loading for search"); + recursiveFetchMore(QModelIndex()); + sortItem(*m_root, false); + m_fullyLoaded = true; + } +} + +bool FileTreeModel::enabled() const +{ + return m_enabled; +} + +void FileTreeModel::setEnabled(bool b) +{ + m_enabled = b; +} + +void FileTreeModel::aboutToExpandAll() +{ + m_sortingEnabled = false; +} + +void FileTreeModel::expandedAll() +{ + m_sortingEnabled = true; + sortItem(*m_root, false); +} + +const FileTreeModel::SortInfo& FileTreeModel::sortInfo() const +{ + return m_sort; +} + +bool FileTreeModel::showArchives() const +{ + return (m_flags.testFlag(Archives) && m_core.settings().archiveParsing()); +} + +bool FileTreeModel::showHiddenFiles() const +{ + return m_flags.testAnyFlag(HiddenFiles); +} + +QModelIndex FileTreeModel::index(int row, int col, const QModelIndex& parentIndex) const +{ + if (auto* parentItem = itemFromIndex(parentIndex)) { + if (row < 0 || row >= parentItem->children().size()) { + log::error("row {} out of range for {}", row, parentItem->debugName()); + return {}; + } + + return createIndex(row, col, makeInternalPointer(parentItem)); + } + + log::error("FileTreeModel::index(): parentIndex has no internal pointer"); + return {}; +} + +QModelIndex FileTreeModel::parent(const QModelIndex& index) const +{ + if (!index.isValid()) { + return {}; + } + + auto* parentItem = getItem(index); + if (!parentItem) { + log::error("FileTreeModel::parent(): no internal pointer"); + return {}; + } + + return indexFromItem(*parentItem); +} + +int FileTreeModel::rowCount(const QModelIndex& parent) const +{ + if (auto* item = itemFromIndex(parent)) { + return static_cast(item->children().size()); + } + + return 0; +} + +int FileTreeModel::columnCount(const QModelIndex&) const +{ + return ColumnCount; +} + +bool FileTreeModel::hasChildren(const QModelIndex& parent) const +{ + if (!m_enabled) { + return false; + } + + if (auto* item = itemFromIndex(parent)) { + if (parent.column() <= 0) { + return item->hasChildren(); + } + } + + return false; +} + +bool FileTreeModel::canFetchMore(const QModelIndex& parent) const +{ + if (!m_enabled) { + return false; + } + + if (auto* item = itemFromIndex(parent)) { + return !item->isLoaded(); + } + + return false; +} + +void FileTreeModel::fetchMore(const QModelIndex& parent) +{ + doFetchMore(parent, true, true); +} + +void FileTreeModel::doFetchMore(const QModelIndex& parent, bool forFetch, bool doSort) +{ + FileTreeItem* item = itemFromIndex(parent); + if (!item) { + return; + } + + const auto path = item->dataRelativeFilePath(); + + auto* parentEntry = + m_core.directoryStructure()->findSubDirectoryRecursive(path.toStdWString()); + + if (!parentEntry) { + log::error("FileTreeModel::fetchMore(): directory '{}' not found", path); + return; + } + + const auto parentPath = item->dataRelativeParentPath(); + update(*item, *parentEntry, parentPath.toStdWString(), forFetch); + + if (!forFetch && doSort) { + sortItem(*item, false); + } +} + +QVariant FileTreeModel::data(const QModelIndex& index, int role) const +{ + switch (role) { + case Qt::DisplayRole: { + if (auto* item = itemFromIndex(index)) { + return displayData(item, index.column()); + } + + break; + } + + case Qt::FontRole: { + if (auto* item = itemFromIndex(index)) { + return item->font(); + } + + break; + } + + case Qt::ToolTipRole: { + if (auto* item = itemFromIndex(index)) { + return makeTooltip(*item); + } + + return {}; + } + + case Qt::ForegroundRole: { + if (index.column() == 1) { + if (auto* item = itemFromIndex(index)) { + if (item->isConflicted()) { + return QBrush(Qt::red); + } + } + } + + break; + } + + case Qt::DecorationRole: { + if (index.column() == 0) { + if (auto* item = itemFromIndex(index)) { + return makeIcon(*item, index); + } + } + + break; + } + } + + return {}; +} + +QVariant FileTreeModel::headerData(int i, Qt::Orientation ori, int role) const +{ + static const std::array names = { + tr("Name"), tr("Mod"), tr("Type"), tr("Size"), tr("Date modified")}; + + if (role == Qt::DisplayRole) { + if (i >= 0 && std::cmp_less(i, names.size())) { + return names[static_cast(i)]; + } + } + + return {}; +} + +Qt::ItemFlags FileTreeModel::flags(const QModelIndex& index) const +{ + auto f = QAbstractItemModel::flags(index); + + if (auto* item = itemFromIndex(index)) { + if (!item->hasChildren()) { + f |= Qt::ItemNeverHasChildren; + } + } + + return f; +} + +void FileTreeModel::sortItem(FileTreeItem& item, bool force) +{ + emit layoutAboutToBeChanged({}, QAbstractItemModel::VerticalSortHint); + + const auto oldList = persistentIndexList(); + std::vector> oldItems; + + const auto itemCount = oldList.size(); + oldItems.reserve(static_cast(itemCount)); + + for (int i = 0; i < itemCount; ++i) { + const QModelIndex& index = oldList[i]; + oldItems.emplace_back(itemFromIndex(index), index.column()); + } + + item.sort(m_sort.column, m_sort.order, force); + + QModelIndexList newList; + newList.reserve(itemCount); + + for (int i = 0; i < itemCount; ++i) { + const auto& pair = oldItems[static_cast(i)]; + newList.append(indexFromItem(*pair.first, pair.second)); + } + + changePersistentIndexList(oldList, newList); + + emit layoutChanged({}, QAbstractItemModel::VerticalSortHint); +} + +void FileTreeModel::sort(int column, Qt::SortOrder order) +{ + m_sort.column = column; + m_sort.order = order; + + sortItem(*m_root, true); +} + +FileTreeItem* FileTreeModel::itemFromIndex(const QModelIndex& index) const +{ + if (!index.isValid()) { + return m_root.get(); + } + + auto* parentItem = getItem(index); + if (!parentItem) { + log::error("FileTreeModel::itemFromIndex(): no internal pointer"); + return nullptr; + } + + if (index.row() < 0 || index.row() >= parentItem->children().size()) { + log::error("FileTreeModel::itemFromIndex(): row {} is out of range for {}", + index.row(), parentItem->debugName()); + + return nullptr; + } + + return parentItem->children()[index.row()].get(); +} + +QModelIndex FileTreeModel::indexFromItem(FileTreeItem& item, int col) const +{ + auto* parent = item.parent(); + if (!parent) { + return {}; + } + + const int index = parent->childIndex(item); + if (index == -1) { + log::error("FileTreeMode::indexFromItem(): item {} not found in parent", + item.debugName()); + + return {}; + } + + return createIndex(index, col, makeInternalPointer(parent)); +} + +void FileTreeModel::update(FileTreeItem& parentItem, + const MOShared::DirectoryEntry& parentEntry, + const std::wstring& parentPath, bool forFetching) +{ + trace(log::debug("updating {}", parentItem.debugName())); + + auto path = parentPath; + if (!parentEntry.isTopLevel()) { + if (!path.empty()) { + path += MOShared::NativeWPathSep; + } + + path += parentEntry.getName(); + } + + parentItem.setLoaded(true); + + bool added = false; + + if (updateDirectories(parentItem, path, parentEntry, forFetching)) { + added = true; + } + + if (updateFiles(parentItem, path, parentEntry)) { + added = true; + } + + if (added) { + parentItem.makeSortingStale(); + + // see comment at the top of this file + if (forFetching) { + // don't pass a specific item, this will start a timer and re-sort the + // whole tree, which is faster than potentially queuing every single + // node if the whole tree is expanded + queueSortItem(nullptr); + } + } +} + +bool FileTreeModel::updateDirectories(FileTreeItem& parentItem, + const std::wstring& parentPath, + const MOShared::DirectoryEntry& parentEntry, + bool forFetching) +{ + // removeDisappearingDirectories() will add directories that are in the + // tree and still on the filesystem to this set; addNewDirectories() will + // use this to figure out if a directory is new or not + std::unordered_set seen; + + removeDisappearingDirectories(parentItem, parentEntry, parentPath, seen, forFetching); + return addNewDirectories(parentItem, parentEntry, parentPath, seen); +} + +void FileTreeModel::removeDisappearingDirectories( + FileTreeItem& parentItem, const MOShared::DirectoryEntry& parentEntry, + const std::wstring& parentPath, std::unordered_set& seen, + bool forFetching) +{ + const auto& children = parentItem.children(); + auto itor = children.begin(); + + // keeps track of the contiguous directories that need to be removed to + // avoid calling beginRemoveRows(), etc. for each item + Range range(this, parentItem); + + // for each item in this tree item + while (itor != children.end()) { + const auto& item = *itor; + + if (!item->isDirectory()) { + // directories are always first, no point continuing once a file has + // been seen + break; + } + + auto *d = parentEntry.findSubDirectory(item->filenameWsLowerCase(), true); + + if (d) { + trace(log::debug("dir {} still there", item->filename())); + + // directory is still there + seen.emplace(d->getName()); + + bool currentRemoved = false; + + if (item->areChildrenVisible()) { + // the item is currently expanded, update it + update(*item, *d, parentPath, forFetching); + } + + if (shouldShowFolder(*d, item.get())) { + // folder should be left in the list + if (!item->areChildrenVisible() && item->isLoaded()) { + if (!d->isEmpty()) { + // the item is loaded (previously expanded but now collapsed) and + // has children, mark it as unloaded so it updates when next + // expanded + item->setLoaded(false); + + if (!item->children().empty()) { + // if the item had children, remove them from the tree + + // see comment at the top of this file + if (forFetching) { + queueRemoveItem(item.get()); + } else { + Range::removeChildren(this, *item); + } + } + } + } + } else { + // item wouldn't have any children, prune it + trace(log::debug("dir {} is empty and pruned", item->filename())); + + range.includeCurrent(); + currentRemoved = true; + ++itor; + } + + if (!currentRemoved) { + // if there were directories before this row that need to be removed, + // do it now + itor = range.remove(); + } + } else { + // directory is gone from the parent entry + trace(log::debug("dir {} is gone", item->filename())); + + range.includeCurrent(); + ++itor; + } + + range.next(); + } + + // remove the last directory range, if any + range.remove(); +} + +bool FileTreeModel::addNewDirectories(FileTreeItem& parentItem, + const MOShared::DirectoryEntry& parentEntry, + const std::wstring& parentPath, + const std::unordered_set& seen) +{ + // keeps track of the contiguous directories that need to be added to + // avoid calling beginAddRows(), etc. for each item + Range range(this, parentItem); + std::vector toAdd; + bool added = false; + + // for each directory on the filesystem + for (auto&& d : parentEntry.getSubDirectories()) { + if (seen.contains(d->getName())) { + // already seen in the parent item + + // if there were directories before this row that need to be added, + // do it now + // + // todo: if the directory was actually removed in + // removeDisappearingDirectories(), the range doesn't need to be added + // now and could be extended further + range.add(std::move(toAdd)); + toAdd.clear(); + } else { + if (!shouldShowFolder(*d, nullptr)) { + // this is a new directory, but it doesn't contain anything interesting + trace(log::debug("new dir {}, empty and pruned", + QString::fromStdWString(d->getName()))); + + // act as if this directory doesn't exist at all + continue; + } + + // this is a new directory + trace(log::debug("new dir {}", QString::fromStdWString(d->getName()))); + + toAdd.push_back(createDirectoryItem(parentItem, parentPath, *d)); + added = true; + + range.includeCurrent(); + } + + range.next(); + } + + // add the last directory range, if any + range.add(std::move(toAdd)); + + return added; +} + +bool FileTreeModel::updateFiles(FileTreeItem& parentItem, + const std::wstring& parentPath, + const MOShared::DirectoryEntry& parentEntry) +{ + // removeDisappearingFiles() will add files that are in the tree and still on + // the filesystem to this set; addNewFiless() will use this to figure out if + // a file is new or not + std::unordered_set seen; + + int firstFileRow = 0; + + removeDisappearingFiles(parentItem, parentEntry, firstFileRow, seen); + return addNewFiles(parentItem, parentEntry, parentPath, firstFileRow, seen); +} + +void FileTreeModel::removeDisappearingFiles(FileTreeItem& parentItem, + const MOShared::DirectoryEntry& parentEntry, + int& firstFileRow, + std::unordered_set& seen) +{ + const auto& children = parentItem.children(); + auto itor = children.begin(); + + firstFileRow = -1; + + // keeps track of the contiguous directories that need to be removed to + // avoid calling beginRemoveRows(), etc. for each item + Range range(this, parentItem); + + // for each item in this tree item + while (itor != children.end()) { + const auto& item = *itor; + + if (!item->isDirectory()) { + if (firstFileRow == -1) { + firstFileRow = range.current(); + } + + auto f = parentEntry.findFile(item->key()); + + if (f && shouldShowFile(*f)) { + trace(log::debug("file {} still there", item->filename())); + + // file is still there + seen.emplace(f->getIndex()); + + if (f->getOrigin() != item->originID()) { + // origin has changed + updateFileItem(*item, *f); + } + + // if there were files before this row that need to be removed, + // do it now + itor = range.remove(); + } else { + // file is gone from the parent entry + trace(log::debug("file {} is gone", item->filename())); + + range.includeCurrent(); + ++itor; + } + } else { + ++itor; + } + + range.next(); + } + + // remove the last file range, if any + range.remove(); + + if (firstFileRow == -1) { + firstFileRow = static_cast(children.size()); + } +} + +bool FileTreeModel::addNewFiles(FileTreeItem& parentItem, + const MOShared::DirectoryEntry& parentEntry, + const std::wstring& parentPath, const int firstFileRow, + const std::unordered_set& seen) +{ + // keeps track of the contiguous files that need to be added to + // avoid calling beginAddRows(), etc. for each item + std::vector toAdd; + Range range(this, parentItem, firstFileRow); + bool added = false; + + // for each directory on the filesystem + parentEntry.forEachFileIndex([&](auto&& fileIndex) { + if (seen.contains(fileIndex)) { + // already seen in the parent item + + // if there were directories before this row that need to be added, + // do it now + range.add(std::move(toAdd)); + toAdd.clear(); + } else { + const auto file = parentEntry.getFileByIndex(fileIndex); + + if (!file) { + log::error("FileTreeModel::addNewFiles(): file index {} in path {} not found", + fileIndex, parentPath); + + return true; + } + + if (shouldShowFile(*file)) { + // this is a new file + trace(log::debug("new file {}", QString::fromStdWString(file->getName()))); + + toAdd.push_back(createFileItem(parentItem, parentPath, *file)); + added = true; + + range.includeCurrent(); + } else { + // this is a new file, but it shouldn't be shown + trace(log::debug("new file {}, not shown", + QString::fromStdWString(file->getName()))); + return true; + } + } + + range.next(); + + return true; + }); + + // add the last file range, if any + range.add(std::move(toAdd)); + + return added; +} + +void FileTreeModel::queueRemoveItem(FileTreeItem* item) +{ + trace(log::debug("queuing {} for removal", item->debugName())); + + m_removeItems.push_back(item); + m_removeTimer.start(1); +} + +void FileTreeModel::removeItems() +{ + // see comment at the top of this file + trace(log::debug("remove item timer: removing {} items", m_removeItems.size())); + + auto copy = std::move(m_removeItems); + m_removeItems.clear(); + m_removeTimer.stop(); + + for (auto&& f : copy) { + Range::removeChildren(this, *f); + } +} + +void FileTreeModel::queueSortItem(FileTreeItem* item) +{ + if (!m_sortingEnabled) { + return; + } + + if (item) { + m_sortItems.push_back(item); + } + + m_sortTimer.start(1); +} + +void FileTreeModel::sortItems() +{ + // see comment at the top of this file + + if (m_sortItems.empty()) { + sortItem(*m_root, false); + } else { + log::debug("sort item timer: sorting {} items", m_sortItems.size()); + + auto items = std::move(m_sortItems); + m_sortItems.clear(); + + for (auto* item : items) { + sortItem(*item, false); + } + } +} + +FileTreeItem::Ptr FileTreeModel::createDirectoryItem(FileTreeItem& parentItem, + const std::wstring& parentPath, + const DirectoryEntry& d) +{ + auto item = FileTreeItem::createDirectory(this, &parentItem, parentPath, d.getName()); + + if (d.isEmpty()) { + // if this directory is empty, mark the item as loaded so the expand + // arrow doesn't show + item->setLoaded(true); + } + + return item; +} + +FileTreeItem::Ptr FileTreeModel::createFileItem(FileTreeItem& parentItem, + const std::wstring& parentPath, + const FileEntry& file) +{ + auto item = FileTreeItem::createFile(this, &parentItem, parentPath, file.getName()); + + updateFileItem(*item, file); + + item->setLoaded(true); + + return item; +} + +void FileTreeModel::updateFileItem(FileTreeItem& item, const MOShared::FileEntry& file) +{ + bool isArchive = false; + int const originID = file.getOrigin(isArchive); + + FileTreeItem::Flags flags = FileTreeItem::NoFlags; + + if (isArchive) { + flags |= FileTreeItem::FromArchive; + } + + if (!file.getAlternatives().empty()) { + flags |= FileTreeItem::Conflicted; + } + + item.setOrigin(originID, file.getFullPath(), flags, makeModName(file, originID)); + + if (file.getFileSize() != FileEntry::NoFileSize) { + item.setFileSize(file.getFileSize()); + } + + if (file.getCompressedFileSize() != FileEntry::NoFileSize) { + item.setCompressedFileSize(file.getCompressedFileSize()); + } +} + +bool FileTreeModel::shouldShowFile(const FileEntry& file) const +{ + if (showConflictsOnly() && + ((file.getAlternatives().empty()) || + QString::fromStdWString(file.getName()) + .endsWith(ModInfo::s_HiddenExt, Qt::CaseInsensitive))) { + // only conflicts should be shown, but this file is hidden or not conflicted + return false; + } + + if (!showArchives() && file.isFromArchive()) { + // files from archives shouldn't be shown, but this file is from an archive + return false; + } + + if (!showHiddenFiles() && QString::fromStdWString(file.getName()) + .endsWith(ModInfo::s_HiddenExt, Qt::CaseInsensitive)) { + // hidden files shouldn't be shown, but this file is hidden + return false; + } + + return true; +} + +bool FileTreeModel::shouldShowFolder(const DirectoryEntry& dir, + const FileTreeItem* item) const +{ + if ((!showHiddenFiles() || showConflictsOnly()) && + QString::fromStdWString(dir.getName()) + .endsWith(ModInfo::s_HiddenExt, Qt::CaseInsensitive)) { + return false; + } + + bool shouldPrune = m_flags.testFlag(PruneDirectories); + + if (m_core.settings().archiveParsing()) { + if (!m_flags.testFlag(Archives)) { + // archive parsing is enabled but the tree shouldn't show archives; this + // is a bit of a special case for folders because they have to be hidden + // regardless of the PruneDirectories flag if they only exist in archives + // + // note that this test is inaccurate: if a loose folder exists but is + // empty, and the same folder exists in an archive but is _not_ empty, + // then it's considered to exist _only_ in an archive and will be pruned + // + // if directories are ever made first-class so they can retain their + // origins, this test can be made more accurate + shouldPrune = true; + } + } + + if (!shouldPrune) { + // always show folders regardless of their content + return true; + } + + if (item) { + if (item->isLoaded() && item->children().empty()) { + // item is loaded and has no children; prune it + return false; + } + } + + bool foundFile = false; + + // check all files in this directory, return early if a file should be shown + dir.forEachFile([&](auto&& f) { + if (shouldShowFile(f)) { + foundFile = true; + + // stop + return false; + } + + // continue + return true; + }); + + if (foundFile) { + return true; + } + + // recurse into subdirectories + for (auto *subdir : dir.getSubDirectories()) { + if (shouldShowFolder(*subdir, nullptr)) { + return true; + } + } + + return false; +} + +QVariant FileTreeModel::displayData(const FileTreeItem* item, int column) +{ + switch (column) { + case FileName: { + return item->filename(); + } + + case ModName: { + return item->mod(); + } + + case FileType: { + return item->fileType().value_or(QString()); + } + + case FileSize: { + if (item->isDirectory()) { + return {}; + } else { + QString fs; + + if (auto n = item->fileSize()) { + fs = localizedByteSize(*n); + } + + if (auto n = item->compressedFileSize()) { + return QString("%1 (%2)").arg(fs).arg(localizedByteSize(*n)); + } else { + return fs; + } + } + } + + case LastModified: { + if (auto d = item->lastModified()) { + if (d.has_value() && d.value().isValid()) { + return QLocale::system().toString(d.value(), QLocale::ShortFormat); + } + } + + return {}; + } + + default: { + return {}; + } + } +} + +std::wstring FileTreeModel::makeModName(const MOShared::FileEntry& file, + int originID) const +{ + static const std::wstring Unmanaged = UnmanagedModName().toStdWString(); + + const auto& origin = m_core.directoryStructure()->getOriginByID(originID); + + if (origin.getID() == 0) { + return Unmanaged; + } + + std::wstring name = origin.getName(); + + const auto& archive = file.getArchive(); + if (!archive.name().empty()) { + name += L" (" + archive.name() + L")"; + } + + return name; +} + +QString FileTreeModel::makeTooltip(const FileTreeItem& item) const +{ + auto nowrap = [&](auto&& s) { + return "

    " + s + "

    "; + }; + + auto line = [&](auto&& caption, auto&& value) { + if (value.isEmpty()) { + return nowrap("" + caption + ":\n"); + } else { + return nowrap("" + caption + ": " + value.toHtmlEscaped()) + "\n"; + } + }; + + if (item.isDirectory()) { + return line(tr("Directory"), item.filename()) + + line(tr("Virtual path"), item.virtualPath()); + } + + static const QString ListStart = "
      "; + + static const QString ListEnd = "
    "; + + QString s = line(tr("Virtual path"), item.virtualPath()) + + line(tr("Real path"), item.realPath()) + line(tr("From"), item.mod()); + + const auto file = m_core.directoryStructure()->searchFile( + item.dataRelativeFilePath().toStdWString(), nullptr); + + if (file) { + const auto alternatives = file->getAlternatives(); + QStringList list; + + for (auto&& alt : alternatives) { + const auto& origin = m_core.directoryStructure()->getOriginByID(alt.originID()); + list.push_back(QString::fromStdWString(origin.getName())); + } + + if (list.size() == 1) { + s += line(tr("Also in"), list[0]); + } else if (list.size() >= 2) { + s += line(tr("Also in"), QString()) + ListStart; + + for (auto&& alt : list) { + s += "
  • " + alt + "
  • "; + } + + s += ListEnd; + } + } + + return s; +} + +QVariant FileTreeModel::makeIcon(const FileTreeItem& item, + const QModelIndex& index) const +{ + if (item.isDirectory()) { + return m_iconFetcher.genericDirectoryIcon(); + } + + auto v = m_iconFetcher.icon(item.realPath()); + if (!v.isNull()) { + return v; + } + + m_iconPending.push_back(index); + m_iconPendingTimer.start(std::chrono::milliseconds(1)); + + return m_iconFetcher.genericFileIcon(); +} + +void FileTreeModel::updatePendingIcons() +{ + std::vector const v(std::move(m_iconPending)); + m_iconPending.clear(); + + for (auto&& index : v) { + emit dataChanged(index, index, {Qt::DecorationRole}); + } + + if (m_iconPending.empty()) { + m_iconPendingTimer.stop(); + } +} + +void FileTreeModel::removePendingIcons(const QModelIndex& parent, int first, int last) +{ + auto itor = m_iconPending.begin(); + + while (itor != m_iconPending.end()) { + if (itor->parent() == parent) { + if (itor->row() >= first && itor->row() <= last) { + itor = m_iconPending.erase(itor); + continue; + } + } + + ++itor; + } +} diff --git a/src/src/filterlist.cpp b/src/src/filterlist.cpp index 1d89c10..e8f8f9e 100644 --- a/src/src/filterlist.cpp +++ b/src/src/filterlist.cpp @@ -1,445 +1,445 @@ -#include "filterlist.h" -#include "categories.h" -#include "categoriesdialog.h" -#include "organizercore.h" -#include "plugincontainer.h" -#include "settings.h" -#include "ui_mainwindow.h" -#include - -using namespace MOBase; -using CriteriaType = ModListSortProxy::CriteriaType; -using Criteria = ModListSortProxy::Criteria; - -class FilterList::CriteriaItem : public QTreeWidgetItem -{ - - static constexpr int IDRole = Qt::UserRole; - static constexpr int TypeRole = Qt::UserRole + 1; - -public: - static constexpr int StateRole = Qt::UserRole + 2; - - enum States - { - FirstState = 0, - - Inactive = FirstState, - Active = 1, - Inverted = 2, - - LastState = Inverted - }; - - CriteriaItem(FilterList* list, QString name, CriteriaType type, int id) - : QTreeWidgetItem({"", name}), m_list(list) - { - setData(0, Qt::ToolTipRole, name); - setData(0, TypeRole, type); - setData(0, IDRole, id); - setData(0, Qt::DecorationRole, QIcon(":/MO/gui/unchecked-checkbox")); - } - - CriteriaType type() const - { - return static_cast(data(0, TypeRole).toInt()); - } - - int id() const { return data(0, IDRole).toInt(); } - - States state() const { return m_state; } - - void setState(States s) - { - if (m_state != s) { - m_state = s; - updateState(); - } - } - - void nextState() - { - auto s = static_cast(m_state + 1); - if (s > LastState) { - s = FirstState; - } - setState(s); - } - - void previousState() - { - auto s = static_cast(m_state - 1); - if (s < FirstState) { - s = LastState; - } - setState(s); - } - - QVariant data(int column, int role) const override - { - if (role == StateRole) { - return m_state; - } - return QTreeWidgetItem::data(column, role); - } - - void setData(int column, int role, const QVariant& value) override - { - if (role == StateRole) { - setState(static_cast(value.toInt())); - } else { - QTreeWidgetItem::setData(column, role, value); - } - } - -private: - FilterList* m_list; - States m_state{Inactive}; - - void updateState() - { - QIcon i; - - switch (m_state) { - case Inactive: { - i = QIcon(":/MO/gui/unchecked-checkbox"); - break; - } - - case Active: { - i = QIcon(":/MO/gui/checked-checkbox"); - break; - } - - case Inverted: { - i = QIcon(":/MO/gui/indeterminate-checkbox"); - break; - } - } - setData(0, Qt::DecorationRole, i); - } -}; - -class CriteriaItemFilter : public QObject -{ -public: - using Callback = std::function; - - CriteriaItemFilter(QTreeWidget* tree, Callback f) - : QObject(tree), m_tree(tree), m_f(std::move(f)) - {} - - bool eventFilter(QObject* o, QEvent* e) override - { - // careful: this filter is installed on both the tree and the viewport - // - // no check is currently necessary because mouse events originate from the - // viewport only and keyboard events from the tree only - - if (m_f) { - if (e->type() == QEvent::MouseButtonPress || - e->type() == QEvent::MouseButtonDblClick) { - if (handleMouse(static_cast(e))) { - return true; - } - } else if (e->type() == QEvent::KeyPress) { - if (handleKeyboard(static_cast(e))) { - return true; - } - } - } - - return QObject::eventFilter(o, e); - } - -private: - QTreeWidget* m_tree; - Callback m_f; - - bool handleMouse(QMouseEvent* e) - { - auto* item = m_tree->itemAt(e->pos()); - if (!item) { - return false; - } - - m_tree->setCurrentItem(item); - - const auto dir = (e->button() == Qt::LeftButton ? 1 : -1); - - return m_f(item, dir); - } - - bool handleKeyboard(QKeyEvent* e) - { - if (e->key() == Qt::Key_Space) { - auto* item = m_tree->currentItem(); - if (!item) { - return false; - } - - const auto shiftPressed = (e->modifiers() & Qt::ShiftModifier); - const auto dir = (shiftPressed ? -1 : 1); - - return m_f(item, dir); - } - - return false; - } -}; - -FilterList::FilterList(Ui::MainWindow* ui, OrganizerCore& core, - CategoryFactory& factory) - : ui(ui), m_core(core), m_factory(factory) -{ - auto* eventFilter = new CriteriaItemFilter(ui->filters, [&](auto* item, int dir) { - return cycleItem(item, dir); - }); - - ui->filters->installEventFilter(eventFilter); - ui->filters->viewport()->installEventFilter(eventFilter); - - connect(ui->filtersClear, &QPushButton::clicked, [&] { - clearSelection(); - }); - connect(ui->filtersEdit, &QPushButton::clicked, [&] { - editCategories(); - }); - connect(ui->filtersAnd, &QCheckBox::toggled, [&] { - onOptionsChanged(); - }); - connect(ui->filtersOr, &QCheckBox::toggled, [&] { - onOptionsChanged(); - }); - - connect(ui->filtersSeparators, qOverload(&QComboBox::currentIndexChanged), [&] { - onOptionsChanged(); - }); - - ui->filters->header()->setMinimumSectionSize(0); - ui->filters->header()->resizeSection(0, 23); - ui->categoriesSplitter->setCollapsible(0, false); - ui->categoriesSplitter->setCollapsible(1, false); - - ui->filtersSeparators->addItem(tr("Filter separators"), - ModListSortProxy::SeparatorFilter); - ui->filtersSeparators->addItem(tr("Show separators"), - ModListSortProxy::SeparatorShow); - ui->filtersSeparators->addItem(tr("Hide separators"), - ModListSortProxy::SeparatorHide); -} - -void FilterList::restoreState(const Settings& s) -{ - s.widgets().restoreIndex(ui->filtersSeparators); - s.widgets().restoreChecked(ui->filtersAnd); - s.widgets().restoreChecked(ui->filtersOr); - - if (m_core.settings().interface().saveFilters()) { - s.widgets().restoreTreeCheckState(ui->filters, CriteriaItem::StateRole); - } - checkCriteria(); -} - -void FilterList::saveState(Settings& s) const -{ - s.widgets().saveTreeCheckState(ui->filters, CriteriaItem::StateRole); - s.widgets().saveChecked(ui->filtersAnd); - s.widgets().saveChecked(ui->filtersOr); - s.widgets().saveIndex(ui->filtersSeparators); -} - -QTreeWidgetItem* FilterList::addCriteriaItem(QTreeWidgetItem* root, const QString& name, - int categoryID, CriteriaType type) -{ - auto* item = new CriteriaItem(this, name, type, categoryID); - - // For now list all categories flatly without nestling them as there is - // no way to espand nodes in the filter view since clicking changes state. - ui->filters->addTopLevelItem(item); - - return item; -} - -void FilterList::addContentCriteria() -{ - m_core.modDataContents().forEachContent( - [this](auto const& content) { - addCriteriaItem(nullptr, - QString("<%1>").arg(tr("Contains %1").arg(content.name())), - content.id(), ModListSortProxy::TypeContent); - }, - true); -} - -void FilterList::addCategoryCriteria(QTreeWidgetItem* root, - const std::set& categoriesUsed, int targetID) -{ - const auto count = static_cast(m_factory.numCategories()); - for (unsigned int i = 1; i < count; ++i) { - if (m_factory.getParentID(i) == targetID) { - int const categoryID = m_factory.getCategoryID(i); - if (categoriesUsed.contains(categoryID)) { - QTreeWidgetItem* item = - addCriteriaItem(root, m_factory.getCategoryName(i), categoryID, - ModListSortProxy::TypeCategory); - if (m_factory.hasChildren(i)) { - addCategoryCriteria(item, categoriesUsed, categoryID); - } - } - } - } -} - -void FilterList::addSpecialCriteria(int type) -{ - const auto sc = static_cast(type); - - addCriteriaItem(nullptr, m_factory.getSpecialCategoryName(sc), type, - ModListSortProxy::TypeSpecial); -} - -void FilterList::refresh() -{ - const auto oldSelection = selectedCriteria(); - - ui->filters->clear(); - - using F = CategoryFactory; - addSpecialCriteria(F::Checked); - addSpecialCriteria(F::UpdateAvailable); - addSpecialCriteria(F::Backup); - addSpecialCriteria(F::Managed); - addSpecialCriteria(F::HasCategory); - addSpecialCriteria(F::Conflict); - addSpecialCriteria(F::HasHiddenFiles); - addSpecialCriteria(F::Endorsed); - addSpecialCriteria(F::Tracked); - addSpecialCriteria(F::HasNexusID); - addSpecialCriteria(F::HasGameData); - - addContentCriteria(); - - std::set categoriesUsed; - for (unsigned int modIdx = 0; modIdx < ModInfo::getNumMods(); ++modIdx) { - ModInfo::Ptr const modInfo = ModInfo::getByIndex(modIdx); - for (int const categoryID : modInfo->getCategories()) { - int currentID = categoryID; - std::set cycleTest; - // also add parents so they show up in the tree - while (currentID != 0) { - categoriesUsed.insert(currentID); - if (!cycleTest.insert(currentID).second) { - log::warn("cycle in categories: {}", SetJoin(cycleTest, ", ")); - break; - } - currentID = m_factory.getParentID(m_factory.getCategoryIndex(currentID)); - } - } - } - - addCategoryCriteria(nullptr, categoriesUsed, 0); - setSelection(oldSelection); -} - -void FilterList::setSelection(const std::vector& criteria) -{ - for (int i = 0; i < ui->filters->topLevelItemCount(); ++i) { - auto* item = dynamic_cast(ui->filters->topLevelItem(i)); - if (!item) { - continue; - } - - bool found = false; - - for (auto&& c : criteria) { - if (item->type() == c.type && item->id() == c.id) { - item->setState(c.inverse ? CriteriaItem::Inverted : CriteriaItem::Active); - found = true; - break; - } - } - - if (!found) { - item->setState(CriteriaItem::Inactive); - } - } -} - -void FilterList::clearSelection() -{ - for (int i = 0; i < ui->filters->topLevelItemCount(); ++i) { - auto* ci = dynamic_cast(ui->filters->topLevelItem(i)); - if (!ci) { - continue; - } - - ci->setState(CriteriaItem::Inactive); - } - - checkCriteria(); -} - -bool FilterList::cycleItem(QTreeWidgetItem* item, int direction) -{ - auto* ci = dynamic_cast(item); - if (!ci) { - return false; - } - - if (direction > 0) { - ci->nextState(); - } else if (direction < 0) { - ci->previousState(); - } else { - return false; - } - - checkCriteria(); - return true; -} - -std::vector FilterList::selectedCriteria() const -{ - std::vector criteria; - - for (int i = 0; i < ui->filters->topLevelItemCount(); ++i) { - const auto* ci = dynamic_cast(ui->filters->topLevelItem(i)); - if (!ci) { - continue; - } - - if (ci->state() != CriteriaItem::Inactive) { - criteria.push_back( - {ci->type(), ci->id(), (ci->state() == CriteriaItem::Inverted)}); - } - } - - return criteria; -} - -void FilterList::checkCriteria() -{ - emit criteriaChanged(selectedCriteria()); -} - -void FilterList::editCategories() -{ - CategoriesDialog dialog(qApp->activeWindow()); - - if (dialog.exec() == QDialog::Accepted) { - dialog.commitChanges(); - refresh(); - } -} - -void FilterList::onOptionsChanged() -{ - const auto mode = ui->filtersAnd->isChecked() ? ModListSortProxy::FilterAnd - : ModListSortProxy::FilterOr; - - const auto separators = static_cast( - ui->filtersSeparators->currentData().toInt()); - - emit optionsChanged(mode, separators); -} +#include "filterlist.h" +#include "categories.h" +#include "categoriesdialog.h" +#include "organizercore.h" +#include "plugincontainer.h" +#include "settings.h" +#include "ui_mainwindow.h" +#include + +using namespace MOBase; +using CriteriaType = ModListSortProxy::CriteriaType; +using Criteria = ModListSortProxy::Criteria; + +class FilterList::CriteriaItem : public QTreeWidgetItem +{ + + static constexpr int IDRole = Qt::UserRole; + static constexpr int TypeRole = Qt::UserRole + 1; + +public: + static constexpr int StateRole = Qt::UserRole + 2; + + enum States + { + FirstState = 0, + + Inactive = FirstState, + Active = 1, + Inverted = 2, + + LastState = Inverted + }; + + CriteriaItem(FilterList* list, QString name, CriteriaType type, int id) + : QTreeWidgetItem({"", name}), m_list(list) + { + setData(0, Qt::ToolTipRole, name); + setData(0, TypeRole, type); + setData(0, IDRole, id); + setData(0, Qt::DecorationRole, QIcon(":/MO/gui/unchecked-checkbox")); + } + + CriteriaType type() const + { + return static_cast(data(0, TypeRole).toInt()); + } + + int id() const { return data(0, IDRole).toInt(); } + + States state() const { return m_state; } + + void setState(States s) + { + if (m_state != s) { + m_state = s; + updateState(); + } + } + + void nextState() + { + auto s = static_cast(m_state + 1); + if (s > LastState) { + s = FirstState; + } + setState(s); + } + + void previousState() + { + auto s = static_cast(m_state - 1); + if (s < FirstState) { + s = LastState; + } + setState(s); + } + + QVariant data(int column, int role) const override + { + if (role == StateRole) { + return m_state; + } + return QTreeWidgetItem::data(column, role); + } + + void setData(int column, int role, const QVariant& value) override + { + if (role == StateRole) { + setState(static_cast(value.toInt())); + } else { + QTreeWidgetItem::setData(column, role, value); + } + } + +private: + FilterList* m_list; + States m_state{Inactive}; + + void updateState() + { + QIcon i; + + switch (m_state) { + case Inactive: { + i = QIcon(":/MO/gui/unchecked-checkbox"); + break; + } + + case Active: { + i = QIcon(":/MO/gui/checked-checkbox"); + break; + } + + case Inverted: { + i = QIcon(":/MO/gui/indeterminate-checkbox"); + break; + } + } + setData(0, Qt::DecorationRole, i); + } +}; + +class CriteriaItemFilter : public QObject +{ +public: + using Callback = std::function; + + CriteriaItemFilter(QTreeWidget* tree, Callback f) + : QObject(tree), m_tree(tree), m_f(std::move(f)) + {} + + bool eventFilter(QObject* o, QEvent* e) override + { + // careful: this filter is installed on both the tree and the viewport + // + // no check is currently necessary because mouse events originate from the + // viewport only and keyboard events from the tree only + + if (m_f) { + if (e->type() == QEvent::MouseButtonPress || + e->type() == QEvent::MouseButtonDblClick) { + if (handleMouse(static_cast(e))) { + return true; + } + } else if (e->type() == QEvent::KeyPress) { + if (handleKeyboard(static_cast(e))) { + return true; + } + } + } + + return QObject::eventFilter(o, e); + } + +private: + QTreeWidget* m_tree; + Callback m_f; + + bool handleMouse(QMouseEvent* e) + { + auto* item = m_tree->itemAt(e->pos()); + if (!item) { + return false; + } + + m_tree->setCurrentItem(item); + + const auto dir = (e->button() == Qt::LeftButton ? 1 : -1); + + return m_f(item, dir); + } + + bool handleKeyboard(QKeyEvent* e) + { + if (e->key() == Qt::Key_Space) { + auto* item = m_tree->currentItem(); + if (!item) { + return false; + } + + const auto shiftPressed = (e->modifiers() & Qt::ShiftModifier); + const auto dir = (shiftPressed ? -1 : 1); + + return m_f(item, dir); + } + + return false; + } +}; + +FilterList::FilterList(Ui::MainWindow* ui, OrganizerCore& core, + CategoryFactory& factory) + : ui(ui), m_core(core), m_factory(factory) +{ + auto* eventFilter = new CriteriaItemFilter(ui->filters, [&](auto* item, int dir) { + return cycleItem(item, dir); + }); + + ui->filters->installEventFilter(eventFilter); + ui->filters->viewport()->installEventFilter(eventFilter); + + connect(ui->filtersClear, &QPushButton::clicked, [&] { + clearSelection(); + }); + connect(ui->filtersEdit, &QPushButton::clicked, [&] { + editCategories(); + }); + connect(ui->filtersAnd, &QCheckBox::toggled, [&] { + onOptionsChanged(); + }); + connect(ui->filtersOr, &QCheckBox::toggled, [&] { + onOptionsChanged(); + }); + + connect(ui->filtersSeparators, qOverload(&QComboBox::currentIndexChanged), [&] { + onOptionsChanged(); + }); + + ui->filters->header()->setMinimumSectionSize(0); + ui->filters->header()->resizeSection(0, 23); + ui->categoriesSplitter->setCollapsible(0, false); + ui->categoriesSplitter->setCollapsible(1, false); + + ui->filtersSeparators->addItem(tr("Filter separators"), + ModListSortProxy::SeparatorFilter); + ui->filtersSeparators->addItem(tr("Show separators"), + ModListSortProxy::SeparatorShow); + ui->filtersSeparators->addItem(tr("Hide separators"), + ModListSortProxy::SeparatorHide); +} + +void FilterList::restoreState(const Settings& s) +{ + s.widgets().restoreIndex(ui->filtersSeparators); + s.widgets().restoreChecked(ui->filtersAnd); + s.widgets().restoreChecked(ui->filtersOr); + + if (m_core.settings().interface().saveFilters()) { + s.widgets().restoreTreeCheckState(ui->filters, CriteriaItem::StateRole); + } + checkCriteria(); +} + +void FilterList::saveState(Settings& s) const +{ + s.widgets().saveTreeCheckState(ui->filters, CriteriaItem::StateRole); + s.widgets().saveChecked(ui->filtersAnd); + s.widgets().saveChecked(ui->filtersOr); + s.widgets().saveIndex(ui->filtersSeparators); +} + +QTreeWidgetItem* FilterList::addCriteriaItem(QTreeWidgetItem* root, const QString& name, + int categoryID, CriteriaType type) +{ + auto* item = new CriteriaItem(this, name, type, categoryID); + + // For now list all categories flatly without nestling them as there is + // no way to espand nodes in the filter view since clicking changes state. + ui->filters->addTopLevelItem(item); + + return item; +} + +void FilterList::addContentCriteria() +{ + m_core.modDataContents().forEachContent( + [this](auto const& content) { + addCriteriaItem(nullptr, + QString("<%1>").arg(tr("Contains %1").arg(content.name())), + content.id(), ModListSortProxy::TypeContent); + }, + true); +} + +void FilterList::addCategoryCriteria(QTreeWidgetItem* root, + const std::set& categoriesUsed, int targetID) +{ + const auto count = static_cast(m_factory.numCategories()); + for (unsigned int i = 1; i < count; ++i) { + if (m_factory.getParentID(i) == targetID) { + int const categoryID = m_factory.getCategoryID(i); + if (categoriesUsed.contains(categoryID)) { + QTreeWidgetItem* item = + addCriteriaItem(root, m_factory.getCategoryName(i), categoryID, + ModListSortProxy::TypeCategory); + if (m_factory.hasChildren(i)) { + addCategoryCriteria(item, categoriesUsed, categoryID); + } + } + } + } +} + +void FilterList::addSpecialCriteria(int type) +{ + const auto sc = static_cast(type); + + addCriteriaItem(nullptr, CategoryFactory::getSpecialCategoryName(sc), type, + ModListSortProxy::TypeSpecial); +} + +void FilterList::refresh() +{ + const auto oldSelection = selectedCriteria(); + + ui->filters->clear(); + + using F = CategoryFactory; + addSpecialCriteria(F::Checked); + addSpecialCriteria(F::UpdateAvailable); + addSpecialCriteria(F::Backup); + addSpecialCriteria(F::Managed); + addSpecialCriteria(F::HasCategory); + addSpecialCriteria(F::Conflict); + addSpecialCriteria(F::HasHiddenFiles); + addSpecialCriteria(F::Endorsed); + addSpecialCriteria(F::Tracked); + addSpecialCriteria(F::HasNexusID); + addSpecialCriteria(F::HasGameData); + + addContentCriteria(); + + std::set categoriesUsed; + for (unsigned int modIdx = 0; modIdx < ModInfo::getNumMods(); ++modIdx) { + ModInfo::Ptr const modInfo = ModInfo::getByIndex(modIdx); + for (int const categoryID : modInfo->getCategories()) { + int currentID = categoryID; + std::set cycleTest; + // also add parents so they show up in the tree + while (currentID != 0) { + categoriesUsed.insert(currentID); + if (!cycleTest.insert(currentID).second) { + log::warn("cycle in categories: {}", SetJoin(cycleTest, ", ")); + break; + } + currentID = m_factory.getParentID(m_factory.getCategoryIndex(currentID)); + } + } + } + + addCategoryCriteria(nullptr, categoriesUsed, 0); + setSelection(oldSelection); +} + +void FilterList::setSelection(const std::vector& criteria) +{ + for (int i = 0; i < ui->filters->topLevelItemCount(); ++i) { + auto* item = dynamic_cast(ui->filters->topLevelItem(i)); + if (!item) { + continue; + } + + bool found = false; + + for (auto&& c : criteria) { + if (item->type() == c.type && item->id() == c.id) { + item->setState(c.inverse ? CriteriaItem::Inverted : CriteriaItem::Active); + found = true; + break; + } + } + + if (!found) { + item->setState(CriteriaItem::Inactive); + } + } +} + +void FilterList::clearSelection() +{ + for (int i = 0; i < ui->filters->topLevelItemCount(); ++i) { + auto* ci = dynamic_cast(ui->filters->topLevelItem(i)); + if (!ci) { + continue; + } + + ci->setState(CriteriaItem::Inactive); + } + + checkCriteria(); +} + +bool FilterList::cycleItem(QTreeWidgetItem* item, int direction) +{ + auto* ci = dynamic_cast(item); + if (!ci) { + return false; + } + + if (direction > 0) { + ci->nextState(); + } else if (direction < 0) { + ci->previousState(); + } else { + return false; + } + + checkCriteria(); + return true; +} + +std::vector FilterList::selectedCriteria() const +{ + std::vector criteria; + + for (int i = 0; i < ui->filters->topLevelItemCount(); ++i) { + const auto* ci = dynamic_cast(ui->filters->topLevelItem(i)); + if (!ci) { + continue; + } + + if (ci->state() != CriteriaItem::Inactive) { + criteria.push_back( + {ci->type(), ci->id(), (ci->state() == CriteriaItem::Inverted)}); + } + } + + return criteria; +} + +void FilterList::checkCriteria() +{ + emit criteriaChanged(selectedCriteria()); +} + +void FilterList::editCategories() +{ + CategoriesDialog dialog(qApp->activeWindow()); + + if (dialog.exec() == QDialog::Accepted) { + dialog.commitChanges(); + refresh(); + } +} + +void FilterList::onOptionsChanged() +{ + const auto mode = ui->filtersAnd->isChecked() ? ModListSortProxy::FilterAnd + : ModListSortProxy::FilterOr; + + const auto separators = static_cast( + ui->filtersSeparators->currentData().toInt()); + + emit optionsChanged(mode, separators); +} diff --git a/src/src/forcedloaddialog.cpp b/src/src/forcedloaddialog.cpp index 5606b9f..f0267f1 100644 --- a/src/src/forcedloaddialog.cpp +++ b/src/src/forcedloaddialog.cpp @@ -1,71 +1,71 @@ -#include "forcedloaddialog.h" -#include "ui_forcedloaddialog.h" - -#include "forcedloaddialogwidget.h" - -#include -#include - -using namespace MOBase; - -ForcedLoadDialog::ForcedLoadDialog(const IPluginGame* game, QWidget* parent) - : QDialog(parent), ui(new Ui::ForcedLoadDialog), m_GamePlugin(game) -{ - ui->setupUi(this); -} - -ForcedLoadDialog::~ForcedLoadDialog() -{ - delete ui; -} - -void ForcedLoadDialog::setValues(QList& values) -{ - ui->tableWidget->clearContents(); - - for (int i = 0; i < values.count(); i++) { - ForcedLoadDialogWidget* item = new ForcedLoadDialogWidget(m_GamePlugin, this); - item->setEnabled(values[i].enabled()); - item->setProcess(values[i].process()); - item->setLibraryPath(values[i].library()); - item->setForced(values[i].forced()); - - ui->tableWidget->insertRow(i); - ui->tableWidget->setCellWidget(i, 0, item); - } - - ui->tableWidget->resizeRowsToContents(); -} - -QList ForcedLoadDialog::values() -{ - QList results; - for (int row = 0; row < ui->tableWidget->rowCount(); row++) { - auto widget = (ForcedLoadDialogWidget*)ui->tableWidget->cellWidget(row, 0); - results.append( - ExecutableForcedLoadSetting(widget->getProcess(), widget->getLibraryPath()) - .withEnabled(widget->getEnabled()) - .withForced(widget->getForced())); - } - return results; -} - -void ForcedLoadDialog::on_addRowButton_clicked() -{ - int const row = ui->tableWidget->rowCount(); - ui->tableWidget->insertRow(row); - ForcedLoadDialogWidget* item = new ForcedLoadDialogWidget(m_GamePlugin, this); - ui->tableWidget->setCellWidget(row, 0, item); - ui->tableWidget->resizeRowsToContents(); -} - -void ForcedLoadDialog::on_deleteRowButton_clicked() -{ - for (auto rowIndex : ui->tableWidget->selectionModel()->selectedRows()) { - int const row = rowIndex.row(); - auto widget = (ForcedLoadDialogWidget*)ui->tableWidget->cellWidget(row, 0); - if (!widget->getForced()) { - ui->tableWidget->removeRow(row); - } - } -} +#include "forcedloaddialog.h" +#include "ui_forcedloaddialog.h" + +#include "forcedloaddialogwidget.h" + +#include +#include + +using namespace MOBase; + +ForcedLoadDialog::ForcedLoadDialog(const IPluginGame* game, QWidget* parent) + : QDialog(parent), ui(new Ui::ForcedLoadDialog), m_GamePlugin(game) +{ + ui->setupUi(this); +} + +ForcedLoadDialog::~ForcedLoadDialog() +{ + delete ui; +} + +void ForcedLoadDialog::setValues(QList& values) +{ + ui->tableWidget->clearContents(); + + for (int i = 0; i < values.count(); i++) { + ForcedLoadDialogWidget* item = new ForcedLoadDialogWidget(m_GamePlugin, this); + item->setEnabled(values[i].enabled()); + item->setProcess(values[i].process()); + item->setLibraryPath(values[i].library()); + item->setForced(values[i].forced()); + + ui->tableWidget->insertRow(i); + ui->tableWidget->setCellWidget(i, 0, item); + } + + ui->tableWidget->resizeRowsToContents(); +} + +QList ForcedLoadDialog::values() +{ + QList results; + for (int row = 0; row < ui->tableWidget->rowCount(); row++) { + auto *widget = (ForcedLoadDialogWidget*)ui->tableWidget->cellWidget(row, 0); + results.append( + ExecutableForcedLoadSetting(widget->getProcess(), widget->getLibraryPath()) + .withEnabled(widget->getEnabled()) + .withForced(widget->getForced())); + } + return results; +} + +void ForcedLoadDialog::on_addRowButton_clicked() +{ + int const row = ui->tableWidget->rowCount(); + ui->tableWidget->insertRow(row); + ForcedLoadDialogWidget* item = new ForcedLoadDialogWidget(m_GamePlugin, this); + ui->tableWidget->setCellWidget(row, 0, item); + ui->tableWidget->resizeRowsToContents(); +} + +void ForcedLoadDialog::on_deleteRowButton_clicked() +{ + for (auto rowIndex : ui->tableWidget->selectionModel()->selectedRows()) { + int const row = rowIndex.row(); + auto *widget = (ForcedLoadDialogWidget*)ui->tableWidget->cellWidget(row, 0); + if (!widget->getForced()) { + ui->tableWidget->removeRow(row); + } + } +} diff --git a/src/src/fuseconnector.cpp b/src/src/fuseconnector.cpp index faa5e57..ac2b29a 100644 --- a/src/src/fuseconnector.cpp +++ b/src/src/fuseconnector.cpp @@ -993,11 +993,7 @@ static bool isStaleOrMounted(const QString& path) // Probe the path directly — ENOTCONN means dead FUSE mount even if // /proc/mounts lists it under a different (canonical) path. struct stat st; - if (::stat(path.toLocal8Bit().constData(), &st) != 0 && errno == ENOTCONN) { - return true; - } - - return false; + return ::stat(path.toLocal8Bit().constData(), &st) != 0 && errno == ENOTCONN; } static void doUnmount(const QString& path) diff --git a/src/src/github.cpp b/src/src/github.cpp index 640a169..d6dbacd 100644 --- a/src/src/github.cpp +++ b/src/src/github.cpp @@ -1,204 +1,204 @@ -#include "github.h" -#include -#include -#include - -#include -#include - -static const QString GITHUB_URL("https://api.github.com"); -static const QString USER_AGENT("GitHubPP"); - -GitHub::GitHub(const char* clientId) : m_AccessManager(new QNetworkAccessManager(this)) -{} - -GitHub::~GitHub() -{ - // delete all the replies since they depend on the access manager, which is - // about to be deleted - for (auto* reply : m_replies) { - reply->disconnect(); - delete reply; - } -} - -QJsonArray GitHub::releases(const Repository& repo) -{ - QJsonDocument const result = request( - Method::GET, QString("repos/%1/%2/releases").arg(repo.owner, repo.project), - QByteArray(), true); - return result.array(); -} - -void GitHub::releases(const Repository& repo, - const std::function& callback) -{ - request( - Method::GET, QString("repos/%1/%2/releases").arg(repo.owner, repo.project), - QByteArray(), - [callback](const QJsonDocument& result) { - callback(result.array()); - }, - true); -} - -QJsonDocument GitHub::handleReply(QNetworkReply* reply) -{ - int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - if (statusCode != 200) { - return QJsonDocument(QJsonObject( - {{"http_status", statusCode}, - {"redirection", - reply->attribute(QNetworkRequest::RedirectionTargetAttribute).toString()}, - {"reason", - reply->attribute(QNetworkRequest::HttpReasonPhraseAttribute).toString()}})); - } - - QByteArray const data = reply->readAll(); - if (data.isNull() || data.isEmpty() || (strcmp(data.constData(), "null") == 0)) { - return {}; - } - - QJsonParseError parseError; - QJsonDocument result = QJsonDocument::fromJson(data, &parseError); - - if (parseError.error != QJsonParseError::NoError) { - return QJsonDocument(QJsonObject({{"parse_error", parseError.errorString()}})); - } - - return result; -} - -QNetworkReply* GitHub::genReply(Method method, const QString& path, - const QByteArray& data, bool relative) -{ - QNetworkRequest request(relative ? GITHUB_URL + "/" + path : path); - - request.setHeader(QNetworkRequest::UserAgentHeader, USER_AGENT); - request.setRawHeader("Accept", "application/vnd.github.v3+json"); - - switch (method) { - case Method::GET: - return m_AccessManager->get(request); - case Method::POST: - return m_AccessManager->post(request, data); - default: - // this shouldn't be possible as all enum options are handled - throw std::runtime_error("invalid method"); - } -} - -QJsonDocument GitHub::request(Method method, const QString& path, - const QByteArray& data, bool relative) -{ - QEventLoop wait; - QNetworkReply* reply = genReply(method, path, data, relative); - - connect(reply, SIGNAL(finished), &wait, SLOT(quit())); - wait.exec(); - QJsonDocument result = handleReply(reply); - reply->deleteLater(); - - QJsonObject const object = result.object(); - if (object.value("http_status").toDouble() == 301.0) { - return request(method, object.value("redirection").toString(), data, false); - } else { - return result; - } -} - -void GitHub::request(Method method, const QString& path, const QByteArray& data, - const std::function& callback, - bool relative) -{ - // make sure the timer is owned by this so it's deleted correctly and - // doesn't fire after the GitHub object is destroyed; this happens when - // restarting MO by switching instances, for example - QTimer* timer = new QTimer(this); - timer->setSingleShot(true); - timer->setInterval(10000); - - QNetworkReply* reply = genReply(method, path, data, relative); - - // remember this reply so it can be deleted in the destructor if necessary - m_replies.push_back(reply); - - Request req = {method, data, callback, timer, reply}; - - // finished - connect(reply, &QNetworkReply::finished, [this, req] { - onFinished(req); - }); - - // error - connect(reply, qOverload(&QNetworkReply::errorOccurred), - [this, req](auto&& error) { - onError(req, error); - }); - - // timeout - connect(timer, &QTimer::timeout, [this, req] { - onTimeout(req); - }); - - timer->start(); -} - -void GitHub::onFinished(const Request& req) -{ - QJsonDocument const result = handleReply(req.reply); - QJsonObject const object = result.object(); - - req.timer->stop(); - - if (object.value("http_status").toInt() == 301) { - request(req.method, object.value("redirection").toString(), req.data, req.callback, - false); - } else { - req.callback(result); - } - - deleteReply(req.reply); -} - -void GitHub::onError(const Request& req, QNetworkReply::NetworkError error) -{ - // the only way the request can be aborted is when there's a timeout, which - // already logs a message - if (error != QNetworkReply::OperationCanceledError) { - qCritical().noquote().nospace() - << "Github: request for " << req.reply->url().toString() << " failed, " - << req.reply->errorString() << " (" << error << ")"; - } - - req.timer->stop(); - req.reply->disconnect(); - - QJsonObject const root({{"network_error", req.reply->errorString()}}); - QJsonDocument const doc(root); - - req.callback(doc); - - deleteReply(req.reply); -} - -void GitHub::onTimeout(const Request& req) -{ - qCritical().noquote().nospace() - << "Github: request for " << req.reply->url().toString() << " timed out"; - - // don't delete the reply, abort will fire the error() handler above - req.reply->abort(); -} - -void GitHub::deleteReply(QNetworkReply* reply) -{ - // remove from the list - auto itor = std::find(m_replies.begin(), m_replies.end(), reply); - if (itor != m_replies.end()) { - m_replies.erase(itor); - } - - // delete - reply->deleteLater(); -} +#include "github.h" +#include +#include +#include + +#include +#include + +static const QString GITHUB_URL("https://api.github.com"); +static const QString USER_AGENT("GitHubPP"); + +GitHub::GitHub(const char* clientId) : m_AccessManager(new QNetworkAccessManager(this)) +{} + +GitHub::~GitHub() +{ + // delete all the replies since they depend on the access manager, which is + // about to be deleted + for (auto* reply : m_replies) { + reply->disconnect(); + delete reply; + } +} + +QJsonArray GitHub::releases(const Repository& repo) +{ + QJsonDocument const result = request( + Method::GET, QString("repos/%1/%2/releases").arg(repo.owner, repo.project), + QByteArray(), true); + return result.array(); +} + +void GitHub::releases(const Repository& repo, + const std::function& callback) +{ + request( + Method::GET, QString("repos/%1/%2/releases").arg(repo.owner, repo.project), + QByteArray(), + [callback](const QJsonDocument& result) { + callback(result.array()); + }, + true); +} + +QJsonDocument GitHub::handleReply(QNetworkReply* reply) +{ + int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (statusCode != 200) { + return QJsonDocument(QJsonObject( + {{"http_status", statusCode}, + {"redirection", + reply->attribute(QNetworkRequest::RedirectionTargetAttribute).toString()}, + {"reason", + reply->attribute(QNetworkRequest::HttpReasonPhraseAttribute).toString()}})); + } + + QByteArray const data = reply->readAll(); + if (data.isNull() || data.isEmpty() || (strcmp(data.constData(), "null") == 0)) { + return {}; + } + + QJsonParseError parseError; + QJsonDocument result = QJsonDocument::fromJson(data, &parseError); + + if (parseError.error != QJsonParseError::NoError) { + return QJsonDocument(QJsonObject({{"parse_error", parseError.errorString()}})); + } + + return result; +} + +QNetworkReply* GitHub::genReply(Method method, const QString& path, + const QByteArray& data, bool relative) +{ + QNetworkRequest request(relative ? GITHUB_URL + "/" + path : path); + + request.setHeader(QNetworkRequest::UserAgentHeader, USER_AGENT); + request.setRawHeader("Accept", "application/vnd.github.v3+json"); + + switch (method) { + case Method::GET: + return m_AccessManager->get(request); + case Method::POST: + return m_AccessManager->post(request, data); + default: + // this shouldn't be possible as all enum options are handled + throw std::runtime_error("invalid method"); + } +} + +QJsonDocument GitHub::request(Method method, const QString& path, + const QByteArray& data, bool relative) +{ + QEventLoop wait; + QNetworkReply* reply = genReply(method, path, data, relative); + + connect(reply, SIGNAL(finished), &wait, SLOT(quit())); + wait.exec(); + QJsonDocument result = handleReply(reply); + reply->deleteLater(); + + QJsonObject const object = result.object(); + if (object.value("http_status").toDouble() == 301.0) { + return request(method, object.value("redirection").toString(), data, false); + } else { + return result; + } +} + +void GitHub::request(Method method, const QString& path, const QByteArray& data, + const std::function& callback, + bool relative) +{ + // make sure the timer is owned by this so it's deleted correctly and + // doesn't fire after the GitHub object is destroyed; this happens when + // restarting MO by switching instances, for example + QTimer* timer = new QTimer(this); + timer->setSingleShot(true); + timer->setInterval(10000); + + QNetworkReply* reply = genReply(method, path, data, relative); + + // remember this reply so it can be deleted in the destructor if necessary + m_replies.push_back(reply); + + Request req = {.method=method, .data=data, .callback=callback, .timer=timer, .reply=reply}; + + // finished + connect(reply, &QNetworkReply::finished, [this, req] { + onFinished(req); + }); + + // error + connect(reply, qOverload(&QNetworkReply::errorOccurred), + [this, req](auto&& error) { + onError(req, error); + }); + + // timeout + connect(timer, &QTimer::timeout, [this, req] { + onTimeout(req); + }); + + timer->start(); +} + +void GitHub::onFinished(const Request& req) +{ + QJsonDocument const result = handleReply(req.reply); + QJsonObject const object = result.object(); + + req.timer->stop(); + + if (object.value("http_status").toInt() == 301) { + request(req.method, object.value("redirection").toString(), req.data, req.callback, + false); + } else { + req.callback(result); + } + + deleteReply(req.reply); +} + +void GitHub::onError(const Request& req, QNetworkReply::NetworkError error) +{ + // the only way the request can be aborted is when there's a timeout, which + // already logs a message + if (error != QNetworkReply::OperationCanceledError) { + qCritical().noquote().nospace() + << "Github: request for " << req.reply->url().toString() << " failed, " + << req.reply->errorString() << " (" << error << ")"; + } + + req.timer->stop(); + req.reply->disconnect(); + + QJsonObject const root({{"network_error", req.reply->errorString()}}); + QJsonDocument const doc(root); + + req.callback(doc); + + deleteReply(req.reply); +} + +void GitHub::onTimeout(const Request& req) +{ + qCritical().noquote().nospace() + << "Github: request for " << req.reply->url().toString() << " timed out"; + + // don't delete the reply, abort will fire the error() handler above + req.reply->abort(); +} + +void GitHub::deleteReply(QNetworkReply* reply) +{ + // remove from the list + auto itor = std::find(m_replies.begin(), m_replies.end(), reply); + if (itor != m_replies.end()) { + m_replies.erase(itor); + } + + // delete + reply->deleteLater(); +} diff --git a/src/src/iconextractor.cpp b/src/src/iconextractor.cpp index 3956870..3f91b8d 100644 --- a/src/src/iconextractor.cpp +++ b/src/src/iconextractor.cpp @@ -114,7 +114,7 @@ QByteArray buildIco(const QByteArray& grpData, continue; QByteArray const hdr(grpData.constData() + grpOff, 8); - entries.append({hdr, imgData}); + entries.append({.header=hdr, .data=imgData}); break; } } @@ -129,7 +129,8 @@ QByteArray buildIco(const QByteArray& grpData, ico.reserve(headerSize + count * 4096); // ICONDIR header. - uint16_t zero = 0, one = 1; + uint16_t zero = 0; + uint16_t one = 1; ico.append(reinterpret_cast(&zero), 2); ico.append(reinterpret_cast(&one), 2); ico.append(reinterpret_cast(&entryCount), 2); @@ -175,7 +176,8 @@ QByteArray tryExtractIcons(const QByteArray& fileData) uint16_t const magic = r16(optHdr); // Determine resource directory RVA. - uint32_t resRva = 0, resSize = 0; + uint32_t resRva = 0; + uint32_t resSize = 0; if (magic == 0x10b) { // PE32: data directories start at offset 96 in optional header. // Resource table is the 3rd entry (index 2), each entry is 8 bytes. diff --git a/src/src/installationmanager.cpp b/src/src/installationmanager.cpp index 1d2a89a..83e78bb 100644 --- a/src/src/installationmanager.cpp +++ b/src/src/installationmanager.cpp @@ -1,960 +1,959 @@ -/* -Copyright (C) 2012 Sebastian Herbord. All rights reserved. - -This file is part of Mod Organizer. - -Mod Organizer is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -Mod Organizer is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with Mod Organizer. If not, see . -*/ - -#include - -#include "installationmanager.h" - -#include "categories.h" -#include "filesystemutilities.h" -#include "iplugininstallercustom.h" -#include "iplugininstallersimple.h" -#include "messagedialog.h" -#include "modinfo.h" -#include "nexusinterface.h" -#include "queryoverwritedialog.h" -#include "questionboxmemory.h" -#include "report.h" -#include "selectiondialog.h" -#include "settings.h" -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include - -#include "archivefiletree.h" - -using namespace MOBase; -using namespace MOShared; - -namespace -{ -QString storeMetaPath(const QString& value) -{ - if (value.isEmpty()) { - return value; - } - - if (MOBase::isWindowsDrivePath(value) || QDir::isAbsolutePath(value)) { - return MOBase::normalizePathForWine(value); - } - - return QDir::fromNativeSeparators(value); -} -} // namespace - -InstallationResult::InstallationResult(IPluginInstaller::EInstallResult result) - : m_result(result) - -{} - -template -static T resolveFunction(QLibrary& lib, const char* name) -{ - T temp = reinterpret_cast(lib.resolve(name)); - if (temp == nullptr) { - throw std::runtime_error(QObject::tr("invalid 7-zip32.dll: %1") - .arg(lib.errorString()) - .toLatin1() - .constData()); - } - return temp; -} - -InstallationManager::InstallationManager() -{ - m_ArchiveHandler = CreateArchive(); - if (!m_ArchiveHandler->isValid()) { - throw MyException(getErrorString(m_ArchiveHandler->getLastError())); - } - m_ArchiveHandler->setLogCallback([](auto level, auto const& message) { - using LogLevel = Archive::LogLevel; - switch (level) { - case LogLevel::Debug: - log::debug("{}", message); - break; - case LogLevel::Info: - log::info("{}", message); - break; - case LogLevel::Warning: - log::warn("{}", message); - break; - case LogLevel::Error: - log::error("{}", message); - break; - } - }); - - // Connect the query password slot - This is the only way I found to be able to query - // user from a separate thread. We use a BlockingQueuedConnection so that calling - // passwordRequested() will block until the end of the slot. - connect(this, &InstallationManager::passwordRequested, this, - &InstallationManager::queryPassword, Qt::BlockingQueuedConnection); -} - -InstallationManager::~InstallationManager() = default; - -void InstallationManager::setParentWidget(QWidget* widget) -{ - m_ParentWidget = widget; -} - -void InstallationManager::setPluginContainer(const PluginContainer* pluginContainer) -{ - m_PluginContainer = pluginContainer; -} - -void InstallationManager::queryPassword() -{ - m_Password = QInputDialog::getText(m_ParentWidget, tr("Password required"), - tr("Password"), QLineEdit::Password); -} - -bool InstallationManager::extractFiles(QString extractPath, QString title, - bool showFilenames, bool silent) -{ - TimeThis const tt("InstallationManager::extractFiles"); - - // Callback for errors: - QString errorMessage; - auto errorCallback = [&errorMessage, this](std::wstring const& message) { - m_ArchiveHandler->cancel(); - errorMessage = QString::fromStdWString(message); - }; - - // The future that will hold the result: - QFuture future; - - if (silent) { - future = QtConcurrent::run([&]() -> bool { - return m_ArchiveHandler->extract(extractPath.toStdWString(), nullptr, nullptr, - errorCallback); - }); - future.waitForFinished(); - } else { - QProgressDialog* installationProgress = new QProgressDialog(m_ParentWidget); - ON_BLOCK_EXIT([=]() { - installationProgress->cancel(); - installationProgress->hide(); - installationProgress->deleteLater(); - }); - installationProgress->setWindowFlags(installationProgress->windowFlags() & - (~Qt::WindowContextHelpButtonHint)); - if (!title.isEmpty()) { - installationProgress->setWindowTitle(title); - } - installationProgress->setWindowModality(Qt::WindowModal); - installationProgress->setFixedSize(600, 100); - - // Turn off auto-reset otherwize the progress dialog is reset before the end. This - // is kind of annoying because updateProgress consider percentage of progression - // through the archive (pack), while we are waiting for extracting archive entries, - // so the percentage of in updateProgress is not really related to the percentage of - // files extracted... - installationProgress->setAutoReset(false); - - // Note: Using a loop with a progressUpdate() that only wake-up the loop. The - // event-loop will be used in a loop and not via exec() because connecting to - // QProgressDialog::setValue and using .exec() creates huge recursion that leads to - // stack-overflow. See https://bugreports.qt.io/browse/QTBUG-10561 - QEventLoop loop; - connect(this, &InstallationManager::progressUpdate, &loop, &QEventLoop::wakeUp, - Qt::QueuedConnection); - - // Cancelling progress only cancel the extraction, we do not force exiting the - // event-loop: - connect(installationProgress, &QProgressDialog::canceled, [this]() { - m_ArchiveHandler->cancel(); - }); - - std::mutex mutex; - int currentProgress = 0; - QString currentFileName; - - // The callbacks: - auto progressCallback = [this, ¤tProgress, &mutex]( - auto progressType, uint64_t current, uint64_t total) { - if (progressType == Archive::ProgressType::EXTRACTION) { - { - std::scoped_lock const guard(mutex); - currentProgress = static_cast(100 * current / total); - } - emit progressUpdate(); - } - }; - Archive::FileChangeCallback fileChangeCallback = - [this, ¤tFileName, &mutex](auto changeType, std::wstring const& file) { - if (changeType == Archive::FileChangeType::EXTRACTION_START) { - { - std::scoped_lock const guard(mutex); - currentFileName = QString::fromStdWString(file); - } - emit progressUpdate(); - } - }; - - // unpack only the files we need for the installer - QFutureWatcher futureWatcher; - connect(&futureWatcher, &QFutureWatcher::finished, &loop, &QEventLoop::wakeUp, - Qt::QueuedConnection); - futureWatcher.setFuture(QtConcurrent::run([&]() -> bool { - return m_ArchiveHandler->extract(extractPath.toStdWString(), progressCallback, - showFilenames ? fileChangeCallback : nullptr, - errorCallback); - })); - - installationProgress->setModal(true); - installationProgress->show(); - - while (!futureWatcher.isFinished()) { - loop.processEvents(QEventLoop::AllEvents | QEventLoop::WaitForMoreEvents); - std::scoped_lock const guard(mutex); - if (currentProgress != installationProgress->value()) { - installationProgress->setValue(currentProgress); - } - if (currentFileName != installationProgress->labelText()) { - installationProgress->setLabelText(currentFileName); - } - } - - installationProgress->hide(); - - future = futureWatcher.future(); - } - - // Check the result: - if (!future.result()) { - if (m_ArchiveHandler->getLastError() == Archive::Error::ERROR_EXTRACT_CANCELLED) { - if (!errorMessage.isEmpty()) { - throw MyException(tr("Extraction failed: %1").arg(errorMessage)); - } else { - return false; - } - } else { - throw MyException(tr("Extraction failed: %1") - .arg(static_cast(m_ArchiveHandler->getLastError()))); - } - } - - return true; -} - -QString InstallationManager::extractFile(std::shared_ptr entry, - bool silent) -{ - QStringList result = this->extractFiles({entry}, silent); - return result.isEmpty() ? QString() : result[0]; -} - -QStringList InstallationManager::extractFiles( - std::vector> const& entries, bool silent) -{ - // Remove the directory since mapToArchive would add them: - std::vector> files; - std::copy_if(entries.begin(), entries.end(), std::back_inserter(files), - [](auto const& entry) { - return entry->isFile(); - }); - - // Update the archive: - ArchiveFileTree::mapToArchive(*m_ArchiveHandler, files); - - // Retrieve the file path: - QStringList result; - - for (auto& entry : files) { - auto path = entry->path(); - result.append(QDir::tempPath().append("/").append(path)); - m_TempFilesToDelete.insert(path); - } - - if (!extractFiles(QDir::tempPath(), tr("Extracting files"), false, silent)) { - return {}; - } - - return result; -} - -QString -InstallationManager::createFile(std::shared_ptr entry) -{ - // Use QTemporaryFile to create the temporary file with the given template: - QTemporaryFile tempFile( - QDir::cleanPath(QDir::tempPath() + QDir::separator() + "mo2-install")); - - // Turn-off autoRemove otherwise the file is deleted when destructor is called: - tempFile.setAutoRemove(false); - - // Open/Close the file so that installer can use it properly: - if (!tempFile.open()) { - return {}; - } - tempFile.close(); - - // fileName() returns the full path since we provide a full path in the constructor: - const QString absPath = tempFile.fileName(); - - m_CreatedFiles[entry] = absPath; - m_TempFilesToDelete.insert(QDir::temp().relativeFilePath(absPath)); - - // Returns the path with native separators: - return QDir::toNativeSeparators(absPath); -} - -void InstallationManager::cleanCreatedFiles( - std::shared_ptr fileTree) -{ - // We simply have to check if all the entries have fileTree as a parent: - for (auto it = std::begin(m_CreatedFiles); it != std::end(m_CreatedFiles);) { - - // Find the parent - Could this be in FileTreeEntry? - bool found = false; - { - auto parent = it->first->parent(); - while (parent && !found) { - if (parent == fileTree) { - found = true; - } else { - parent = parent->parent(); - } - } - } - - // If the parent was not found, we remove the entry, otherwize we move to the next - // one: - if (!found) { - it = m_CreatedFiles.erase(it); - } else { - ++it; - } - } -} - -IPluginInstaller::EInstallResult -InstallationManager::installArchive(GuessedValue& modName, - const QString& archiveName, int modId) -{ - // in earlier versions the modName was copied here and the copy passed to install. I - // don't know why I did this and it causes a problem if this is called by the bundle - // installer and the bundled installer adds additional names that then end up being - // used, because the caller will then not have the right name. - return install(archiveName, modName, modId).result(); -} - -QString InstallationManager::generateBackupName(const QString& directoryName) -{ - QString backupName = directoryName + "_backup"; - if (QDir(backupName).exists()) { - int idx = 2; - QString temp = backupName + QString::number(idx); - while (QDir(temp).exists()) { - ++idx; - temp = backupName + QString::number(idx); - } - backupName = temp; - } - return backupName; -} - -InstallationResult InstallationManager::testOverwrite(GuessedValue& modName) -{ - QString targetDirectory = - QDir::fromNativeSeparators(m_ModsDirectory + "/" + modName); - - // this is only returned on success - InstallationResult result{IPluginInstaller::RESULT_SUCCESS}; - - while (QDir(targetDirectory).exists()) { - Settings& settings(Settings::instance()); - - const bool backup = settings.keepBackupOnInstall(); - QueryOverwriteDialog overwriteDialog(m_ParentWidget, - backup ? QueryOverwriteDialog::BACKUP_YES - : QueryOverwriteDialog::BACKUP_NO); - - if (overwriteDialog.exec()) { - settings.setKeepBackupOnInstall(overwriteDialog.backup()); - - if (overwriteDialog.backup()) { - QString const backupDirectory = generateBackupName(targetDirectory); - if (!copyDir(targetDirectory, backupDirectory, false)) { - reportError(tr("Failed to create backup")); - return {IPluginInstaller::RESULT_FAILED}; - } - } - - result.m_merged = overwriteDialog.action() == QueryOverwriteDialog::ACT_MERGE; - result.m_replaced = overwriteDialog.action() == QueryOverwriteDialog::ACT_REPLACE; - result.m_backup = overwriteDialog.backup(); - - if (overwriteDialog.action() == QueryOverwriteDialog::ACT_RENAME) { - bool ok = false; - QString const name = QInputDialog::getText(m_ParentWidget, tr("Mod Name"), tr("Name"), - QLineEdit::Normal, modName, &ok); - if (ok && !name.isEmpty()) { - modName.update(name, GUESS_USER); - if (!ensureValidModName(modName)) { - return {IPluginInstaller::RESULT_FAILED}; - } - targetDirectory = QDir::fromNativeSeparators(m_ModsDirectory) + "/" + modName; - } - } else if (overwriteDialog.action() == QueryOverwriteDialog::ACT_REPLACE) { - unsigned int const idx = ModInfo::getIndex(modName); - if (idx != UINT_MAX) { - auto modInfo = ModInfo::getByIndex(idx); - // mark the old install file as uninstalled - emit modReplaced(modInfo->installationFile()); - } - // save original settings like categories. Because it makes sense - QString metaFilename = targetDirectory + "/meta.ini"; - QFile settingsFile(metaFilename); - QByteArray originalSettings; - if (settingsFile.open(QIODevice::ReadOnly)) { - originalSettings = settingsFile.readAll(); - settingsFile.close(); - } - - // remove the directory with all content, then recreate it empty - shellDelete(QStringList(targetDirectory)); - if (!QDir().mkdir(targetDirectory)) { - // The retry exists because Windows can keep a directory around for a - // moment after delete. Linux doesn't have that problem; the sleep is - // kept for safety in case the underlying filesystem is slow. - QThread::msleep(100); - QDir().mkdir(targetDirectory); - } - // restore the saved settings - if (settingsFile.open(QIODevice::WriteOnly)) { - settingsFile.write(originalSettings); - settingsFile.close(); - } else { - log::error("failed to restore original settings: {}", metaFilename); - } - return result; - } else if (overwriteDialog.action() == QueryOverwriteDialog::ACT_MERGE) { - return result; - } else /* if (overwriteDialog.action() == QueryOverwriteDialog::ACT_NONE) */ - { - return {IPluginInstaller::RESULT_CANCELED}; - } - } else { - return {IPluginInstaller::RESULT_CANCELED}; - } - } - - QDir().mkdir(targetDirectory); - - return result; -} - -bool InstallationManager::ensureValidModName(GuessedValue& name) const -{ - while (name->isEmpty()) { - bool ok; - name.update( - QInputDialog::getText( - m_ParentWidget, tr("Invalid name"), - tr("The name you entered is invalid, please enter a different one."), - QLineEdit::Normal, "", &ok), - GUESS_USER); - if (!ok) { - return false; - } - } - return true; -} - -InstallationResult InstallationManager::doInstall(GuessedValue& modName, - QString gameName, int modID, - const QString& version, - const QString& newestVersion, - int categoryID, int fileCategoryID, - const QString& repository) -{ - if (!ensureValidModName(modName)) { - return {IPluginInstaller::RESULT_FAILED}; - } - - bool const merge = false; - // determine target directory - InstallationResult result = testOverwrite(modName); - if (!result) { - return result; - } - - result.m_name = modName; - - QString const targetDirectory = QDir(m_ModsDirectory + "/" + modName).canonicalPath(); - QString targetDirectoryNative = QDir::toNativeSeparators(targetDirectory); - - log::debug("installing to \"{}\"", targetDirectoryNative); - if (!extractFiles(targetDirectory, "", true, false)) { - return {IPluginInstaller::RESULT_CANCELED}; - } - - // Copy the created files: - for (auto& p : m_CreatedFiles) { - QString destPath = - QDir::cleanPath(targetDirectory + QDir::separator() + p.first->path()); - log::debug("Moving {} to {}.", p.second, destPath); - - // We need to remove the path if it exists: - if (QFile::exists(destPath)) { - QFile::remove(destPath); - } - - QDir const dir = QFileInfo(destPath).absoluteDir(); - if (!dir.exists()) { - dir.mkpath("."); - } - - QFile::copy(p.second, destPath); - } - - QSettings settingsFile(targetDirectory + "/meta.ini", QSettings::IniFormat); - - // overwrite settings only if they are actually are available or haven't been set - // before - if ((gameName != "") || !settingsFile.contains("gameName")) { - settingsFile.setValue("gameName", gameName); - } - if ((modID != 0) || !settingsFile.contains("modid")) { - settingsFile.setValue("modid", modID); - } - if (!settingsFile.contains("version") || - (!version.isEmpty() && - (!merge || (VersionInfo(version) >= - VersionInfo(settingsFile.value("version").toString()))))) { - settingsFile.setValue("version", version); - } - if (!newestVersion.isEmpty() || !settingsFile.contains("newestVersion")) { - settingsFile.setValue("newestVersion", newestVersion); - } - // issue #51 used to overwrite the manually set categories - if (!settingsFile.contains("category")) { - settingsFile.setValue("category", QString::number(categoryID)); - } - settingsFile.setValue("nexusFileStatus", fileCategoryID); - settingsFile.setValue("installationFile", storeMetaPath(m_CurrentFile)); - settingsFile.setValue("repository", repository); - - if (!merge) { - // this does not clear the list we have in memory but the mod is going to have to be - // re-read anyway btw.: installedFiles were written with beginWriteArray but we can - // still clear it with beginGroup. nice - settingsFile.beginGroup("installedFiles"); - settingsFile.remove(""); - settingsFile.endGroup(); - } - - return result; -} - -bool InstallationManager::wasCancelled() const -{ - return m_ArchiveHandler->getLastError() == Archive::Error::ERROR_EXTRACT_CANCELLED; -} - -bool InstallationManager::isRunning() const -{ - return m_IsRunning; -} - -void InstallationManager::postInstallCleanup() -{ - // Clear the list of created files: - m_CreatedFiles.clear(); - - // Close the archive: - m_ArchiveHandler->close(); - - // directories we may want to remove. sorted from longest to shortest to ensure we - // remove subdirectories first. - auto longestFirst = [](const QString& LHS, const QString& RHS) -> bool { - if (LHS.size() != RHS.size()) - return LHS.size() > RHS.size(); - else - return LHS < RHS; - }; - - std::set> - directoriesToRemove(longestFirst); - - // clean up temp files - // TODO: this doesn't yet remove directories. Also, the files may be left there if - // this point isn't reached - for (const QString& tempFile : m_TempFilesToDelete) { - QFileInfo const fileInfo(QDir::tempPath() + "/" + tempFile); - if (fileInfo.exists()) { - if (!fileInfo.isReadable() || !fileInfo.isWritable()) { - QFile::setPermissions(fileInfo.absoluteFilePath(), - QFile::ReadOther | QFile::WriteOther); - } - if (!QFile::remove(fileInfo.absoluteFilePath())) { - log::warn("Unable to delete {}", fileInfo.absoluteFilePath()); - } - } - directoriesToRemove.insert(fileInfo.absolutePath()); - } - - m_TempFilesToDelete.clear(); - - // try to delete each directory we had temporary files in. the call fails for - // non-empty directories which is ok - for (const QString& dir : directoriesToRemove) { - QDir().rmdir(dir); - } -} - -InstallationResult InstallationManager::install(const QString& fileName, - GuessedValue& modName, - int modID) -{ - m_IsRunning = true; - ON_BLOCK_EXIT([this]() { - m_IsRunning = false; - }); - - QFileInfo const fileInfo(fileName); - if (!getSupportedExtensions().contains(fileInfo.suffix(), Qt::CaseInsensitive)) { - reportError(tr("File format \"%1\" not supported").arg(fileInfo.suffix())); - return {IPluginInstaller::RESULT_FAILED}; - } - - modName.setFilter(&fixDirectoryName); - - modName.update(QFileInfo(fileName).completeBaseName(), GUESS_FALLBACK); - - // read out meta information from the download if available - QString gameName = ""; - QString version = ""; - QString newestVersion = ""; - int category = 0; - int categoryID = 0; - int fileCategoryID = 1; - QString repository = "Nexus"; - - QString const metaName = fileName + ".meta"; - if (QFile(metaName).exists()) { - QSettings const metaFile(metaName, QSettings::IniFormat); - gameName = metaFile.value("gameName", "").toString(); - modID = metaFile.value("modID", 0).toInt(); - QTextDocument doc; - doc.setHtml(metaFile.value("name", "").toString()); - modName.update(doc.toPlainText(), GUESS_FALLBACK); - modName.update(metaFile.value("modName", "").toString(), GUESS_META); - - version = metaFile.value("version", "").toString(); - newestVersion = metaFile.value("newestVersion", "").toString(); - category = metaFile.value("category", 0).toInt(); - unsigned int const categoryIndex = CategoryFactory::instance().resolveNexusID(category); - if (category != 0 && categoryIndex == 0U && - Settings::instance().nexus().categoryMappings()) { - QMessageBox nexusQuery; - nexusQuery.setWindowTitle(tr("No category found")); - nexusQuery.setText(tr( - "This Nexus category has not yet been mapped. Do you wish to proceed without " - "setting a category, proceed and disable automatic Nexus mappings, or stop " - "and configure your category mappings?")); - QPushButton* proceedButton = - nexusQuery.addButton(tr("&Proceed"), QMessageBox::YesRole); - QPushButton* disableButton = - nexusQuery.addButton(tr("&Disable"), QMessageBox::AcceptRole); - QPushButton* stopButton = - nexusQuery.addButton(tr("&Stop && Configure"), QMessageBox::DestructiveRole); - nexusQuery.exec(); - if (nexusQuery.clickedButton() == disableButton) { - Settings::instance().nexus().setCategoryMappings(false); - } else if (nexusQuery.clickedButton() == stopButton) { - return MOBase::IPluginInstaller::RESULT_CATEGORYREQUESTED; - } - } else { - categoryID = CategoryFactory::instance().getCategoryID(categoryIndex); - } - repository = metaFile.value("repository", "").toString(); - fileCategoryID = metaFile.value("fileCategory", 1).toInt(); - } - - if (version.isEmpty()) { - QDateTime const lastMod = fileInfo.lastModified(); - version = "d" + lastMod.toString("yyyy.M.d"); - } - - { // guess the mod name and mod if from the file name if there was no meta - // information - QString guessedModName; - int guessedModID = modID; - NexusInterface::interpretNexusFileName(QFileInfo(fileName).fileName(), - guessedModName, guessedModID, false); - if ((modID == 0) && (guessedModID != -1)) { - modID = guessedModID; - } else if (modID != guessedModID) { - log::debug("passed mod id: {}, guessed id: {}", modID, guessedModID); - } - - modName.update(guessedModName, GUESS_GOOD); - } - - m_CurrentFile = fileInfo.absoluteFilePath(); - if (fileInfo.dir() == QDir(m_DownloadsDirectory)) { - m_CurrentFile = fileInfo.fileName(); - } - log::debug("using mod name \"{}\" (id {}) -> {}", QString(modName), modID, - m_CurrentFile); - - // If there's an archive already open, close it. This happens with the bundle - // installer when it uncompresses a split archive, then finds it has a real archive - // to deal with. - m_ArchiveHandler->close(); - - // open the archive and construct the directory tree the installers work on - - bool const archiveOpen = - m_ArchiveHandler->open(fileName.toStdWString(), [this]() -> std::wstring { - m_Password = QString(); - - // Note: If we are not in the Qt event thread, we cannot use queryPassword() - // directly, so we emit passwordRequested() that is connected to - // queryPassword(). The connection is made using Qt::BlockingQueuedConnection, - // so the emit "call" is actually blocking. We cannot use emit if we are in the - // even thread, otherwize we have a deadlock. - if (QThread::currentThread() != QApplication::instance()->thread()) { - emit passwordRequested(); - } else { - queryPassword(); - } - return m_Password.toStdWString(); - }); - if (!archiveOpen) { - log::debug("integrated archiver can't open {}: {} ({})", fileName, - getErrorString(m_ArchiveHandler->getLastError()), - m_ArchiveHandler->getLastError()); - } - ON_BLOCK_EXIT(std::bind(&InstallationManager::postInstallCleanup, this)); - - std::shared_ptr filesTree = - archiveOpen ? ArchiveFileTree::makeTree(*m_ArchiveHandler) : nullptr; - - auto installers = m_PluginContainer->plugins(); - - std::sort(installers.begin(), installers.end(), - [](IPluginInstaller* lhs, IPluginInstaller* rhs) { - return lhs->priority() > rhs->priority(); - }); - - InstallationResult installResult(IPluginInstaller::RESULT_NOTATTEMPTED); - - for (IPluginInstaller* installer : installers) { - // don't use inactive installers (installer can't be null here but vc static code - // analysis thinks it could) - if ((installer == nullptr) || !m_PluginContainer->isEnabled(installer)) { - continue; - } - - // try only manual installers if that was requested - if (installResult.result() == IPluginInstaller::RESULT_MANUALREQUESTED) { - if (!installer->isManualInstaller()) { - continue; - } - } else if (installResult.result() != IPluginInstaller::RESULT_NOTATTEMPTED) { - break; - } - - try { - { // simple case - IPluginInstallerSimple* installerSimple = - dynamic_cast(installer); - if ((installerSimple != nullptr) && (filesTree != nullptr) && - (installer->isArchiveSupported(filesTree))) { - installResult.m_result = - installerSimple->install(modName, filesTree, version, modID); - if (installResult) { - - // Downcast to an actual ArchiveFileTree and map to the archive. Test if - // the tree is still an ArchiveFileTree, otherwize it means the installer - // did some bad stuff. - ArchiveFileTree* p = dynamic_cast(filesTree.get()); - if (p == nullptr) { - throw IncompatibilityException( - tr("Invalid file tree returned by plugin.")); - } - - // Detach the file tree (this ensure the parent is null and call to path() - // stops at this root): - p->detach(); - - p->mapToArchive(*m_ArchiveHandler); - - // Clean the created files: - cleanCreatedFiles(filesTree); - - // the simple installer only prepares the installation, the rest - // works the same for all installers - installResult = doInstall(modName, gameName, modID, version, newestVersion, - categoryID, fileCategoryID, repository); - } - } - } - - if (installResult.result() != IPluginInstaller::RESULT_CANCELED) { // custom case - IPluginInstallerCustom* installerCustom = - dynamic_cast(installer); - if ((installerCustom != nullptr) && - (((filesTree != nullptr) && installer->isArchiveSupported(filesTree)) || - ((filesTree == nullptr) && - installerCustom->isArchiveSupported(fileName)))) { - std::set const installerExt = installerCustom->supportedExtensions(); - if (installerExt.contains(fileInfo.suffix())) { - installResult.m_result = - installerCustom->install(modName, gameName, fileName, version, modID); - unsigned int const idx = ModInfo::getIndex(modName); - if (idx != UINT_MAX) { - ModInfo::Ptr const info = ModInfo::getByIndex(idx); - info->setRepository(repository); - } - } - } - } - } catch (const IncompatibilityException& e) { - log::error("plugin \"{}\" incompatible: {}", installer->name(), e.what()); - } - - // act upon the installation result. at this point the files have already been - // extracted to the correct location - switch (installResult.result()) { - case IPluginInstaller::RESULT_FAILED: { - QMessageBox::information(qApp->activeWindow(), tr("Installation failed"), - tr("Something went wrong while installing this mod."), - QMessageBox::Ok); - return installResult; - } break; - case IPluginInstaller::RESULT_SUCCESS: - case IPluginInstaller::RESULT_SUCCESSCANCEL: { - if (filesTree != nullptr) { - auto iniTweakEntry = filesTree->find("INI Tweaks", FileTreeEntry::DIRECTORY); - installResult.m_iniTweaks = - iniTweakEntry != nullptr && !iniTweakEntry->astree()->empty(); - } - installResult.m_result = IPluginInstaller::RESULT_SUCCESS; - return installResult; - } break; - case IPluginInstaller::RESULT_NOTATTEMPTED: - case IPluginInstaller::RESULT_MANUALREQUESTED: { - continue; - } - default: - return installResult; - } - } - if (installResult.result() == IPluginInstaller::RESULT_NOTATTEMPTED) { - reportError( - tr("None of the available installer plugins were able to handle that archive.\n" - "This is likely due to a corrupted or incompatible download or unrecognized " - "archive format.")); - } - - return installResult; -} - -QString InstallationManager::getErrorString(Archive::Error errorCode) -{ - switch (errorCode) { - case Archive::Error::ERROR_NONE: { - return tr("no error"); - } break; - case Archive::Error::ERROR_LIBRARY_NOT_FOUND: { - return tr("7z.dll not found"); - } break; - case Archive::Error::ERROR_LIBRARY_INVALID: { - return tr("7z.dll isn't valid"); - } break; - case Archive::Error::ERROR_ARCHIVE_NOT_FOUND: { - return tr("archive not found"); - } break; - case Archive::Error::ERROR_FAILED_TO_OPEN_ARCHIVE: { - return tr("failed to open archive"); - } break; - case Archive::Error::ERROR_INVALID_ARCHIVE_FORMAT: { - return tr("unsupported archive type"); - } break; - case Archive::Error::ERROR_LIBRARY_ERROR: { - return tr("internal library error"); - } break; - case Archive::Error::ERROR_ARCHIVE_INVALID: { - return tr("archive invalid"); - } break; - default: { - // this probably means the archiver.dll is newer than this - return tr("unknown archive error"); - } break; - } -} - -QStringList InstallationManager::getSupportedExtensions() const -{ - std::set supportedExtensions( - {"zip", "rar", "7z", "fomod", "001"}); - for (auto* installer : m_PluginContainer->plugins()) { - if (m_PluginContainer->isEnabled(installer)) { - if (auto* installerCustom = dynamic_cast(installer)) { - std::set const extensions = installerCustom->supportedExtensions(); - supportedExtensions.insert(extensions.begin(), extensions.end()); - } - } - } - return {supportedExtensions.begin(), supportedExtensions.end()}; -} - -void InstallationManager::notifyInstallationStart(QString const& archive, - bool reinstallation, - ModInfo::Ptr currentMod) -{ - auto& installers = m_PluginContainer->plugins(); - for (auto* installer : installers) { - if (m_PluginContainer->isEnabled(installer)) { - installer->onInstallationStart(archive, reinstallation, currentMod.get()); - } - } -} - -void InstallationManager::notifyInstallationEnd(const InstallationResult& result, - ModInfo::Ptr newMod) -{ - auto& installers = m_PluginContainer->plugins(); - for (auto* installer : installers) { - if (m_PluginContainer->isEnabled(installer)) { - installer->onInstallationEnd(result.result(), newMod.get()); - } - } -} +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include + +#include "installationmanager.h" + +#include "categories.h" +#include "filesystemutilities.h" +#include "iplugininstallercustom.h" +#include "iplugininstallersimple.h" +#include "messagedialog.h" +#include "modinfo.h" +#include "nexusinterface.h" +#include "queryoverwritedialog.h" +#include "questionboxmemory.h" +#include "report.h" +#include "selectiondialog.h" +#include "settings.h" +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "archivefiletree.h" + +using namespace MOBase; +using namespace MOShared; + +namespace +{ +QString storeMetaPath(const QString& value) +{ + if (value.isEmpty()) { + return value; + } + + if (MOBase::isWindowsDrivePath(value) || QDir::isAbsolutePath(value)) { + return MOBase::normalizePathForWine(value); + } + + return QDir::fromNativeSeparators(value); +} +} // namespace + +InstallationResult::InstallationResult(IPluginInstaller::EInstallResult result) + : m_result(result) + +{} + +template +static T resolveFunction(QLibrary& lib, const char* name) +{ + T temp = reinterpret_cast(lib.resolve(name)); + if (temp == nullptr) { + throw std::runtime_error(QObject::tr("invalid 7z.so: %1") + .arg(lib.errorString()) + .toLatin1() + .constData()); + } + return temp; +} + +InstallationManager::InstallationManager() +{ + m_ArchiveHandler = CreateArchive(); + if (!m_ArchiveHandler->isValid()) { + throw MyException(getErrorString(m_ArchiveHandler->getLastError())); + } + m_ArchiveHandler->setLogCallback([](auto level, auto const& message) { + using LogLevel = Archive::LogLevel; + switch (level) { + case LogLevel::Debug: + log::debug("{}", message); + break; + case LogLevel::Info: + log::info("{}", message); + break; + case LogLevel::Warning: + log::warn("{}", message); + break; + case LogLevel::Error: + log::error("{}", message); + break; + } + }); + + // Connect the query password slot - This is the only way I found to be able to query + // user from a separate thread. We use a BlockingQueuedConnection so that calling + // passwordRequested() will block until the end of the slot. + connect(this, &InstallationManager::passwordRequested, this, + &InstallationManager::queryPassword, Qt::BlockingQueuedConnection); +} + +InstallationManager::~InstallationManager() = default; + +void InstallationManager::setParentWidget(QWidget* widget) +{ + m_ParentWidget = widget; +} + +void InstallationManager::setPluginContainer(const PluginContainer* pluginContainer) +{ + m_PluginContainer = pluginContainer; +} + +void InstallationManager::queryPassword() +{ + m_Password = QInputDialog::getText(m_ParentWidget, tr("Password required"), + tr("Password"), QLineEdit::Password); +} + +bool InstallationManager::extractFiles(QString extractPath, QString title, + bool showFilenames, bool silent) +{ + TimeThis const tt("InstallationManager::extractFiles"); + + // Callback for errors: + QString errorMessage; + auto errorCallback = [&errorMessage, this](std::wstring const& message) { + m_ArchiveHandler->cancel(); + errorMessage = QString::fromStdWString(message); + }; + + // The future that will hold the result: + QFuture future; + + if (silent) { + future = QtConcurrent::run([&]() -> bool { + return m_ArchiveHandler->extract(extractPath.toStdWString(), nullptr, nullptr, + errorCallback); + }); + future.waitForFinished(); + } else { + QProgressDialog* installationProgress = new QProgressDialog(m_ParentWidget); + ON_BLOCK_EXIT([=]() { + installationProgress->cancel(); + installationProgress->hide(); + installationProgress->deleteLater(); + }); + installationProgress->setWindowFlags(installationProgress->windowFlags() & + (~Qt::WindowContextHelpButtonHint)); + if (!title.isEmpty()) { + installationProgress->setWindowTitle(title); + } + installationProgress->setWindowModality(Qt::WindowModal); + installationProgress->setFixedSize(600, 100); + + // Turn off auto-reset otherwize the progress dialog is reset before the end. This + // is kind of annoying because updateProgress consider percentage of progression + // through the archive (pack), while we are waiting for extracting archive entries, + // so the percentage of in updateProgress is not really related to the percentage of + // files extracted... + installationProgress->setAutoReset(false); + + // Note: Using a loop with a progressUpdate() that only wake-up the loop. The + // event-loop will be used in a loop and not via exec() because connecting to + // QProgressDialog::setValue and using .exec() creates huge recursion that leads to + // stack-overflow. See https://bugreports.qt.io/browse/QTBUG-10561 + QEventLoop loop; + connect(this, &InstallationManager::progressUpdate, &loop, &QEventLoop::wakeUp, + Qt::QueuedConnection); + + // Cancelling progress only cancel the extraction, we do not force exiting the + // event-loop: + connect(installationProgress, &QProgressDialog::canceled, [this]() { + m_ArchiveHandler->cancel(); + }); + + std::mutex mutex; + int currentProgress = 0; + QString currentFileName; + + // The callbacks: + auto progressCallback = [this, ¤tProgress, &mutex]( + auto progressType, uint64_t current, uint64_t total) { + if (progressType == Archive::ProgressType::EXTRACTION) { + { + std::scoped_lock const guard(mutex); + currentProgress = static_cast(100 * current / total); + } + emit progressUpdate(); + } + }; + Archive::FileChangeCallback fileChangeCallback = + [this, ¤tFileName, &mutex](auto changeType, std::wstring const& file) { + if (changeType == Archive::FileChangeType::EXTRACTION_START) { + { + std::scoped_lock const guard(mutex); + currentFileName = QString::fromStdWString(file); + } + emit progressUpdate(); + } + }; + + // unpack only the files we need for the installer + QFutureWatcher futureWatcher; + connect(&futureWatcher, &QFutureWatcher::finished, &loop, &QEventLoop::wakeUp, + Qt::QueuedConnection); + futureWatcher.setFuture(QtConcurrent::run([&]() -> bool { + return m_ArchiveHandler->extract(extractPath.toStdWString(), progressCallback, + showFilenames ? fileChangeCallback : nullptr, + errorCallback); + })); + + installationProgress->setModal(true); + installationProgress->show(); + + while (!futureWatcher.isFinished()) { + loop.processEvents(QEventLoop::AllEvents | QEventLoop::WaitForMoreEvents); + std::scoped_lock const guard(mutex); + if (currentProgress != installationProgress->value()) { + installationProgress->setValue(currentProgress); + } + if (currentFileName != installationProgress->labelText()) { + installationProgress->setLabelText(currentFileName); + } + } + + installationProgress->hide(); + + future = futureWatcher.future(); + } + + // Check the result: + if (!future.result()) { + if (m_ArchiveHandler->getLastError() == Archive::Error::ERROR_EXTRACT_CANCELLED) { + if (!errorMessage.isEmpty()) { + throw MyException(tr("Extraction failed: %1").arg(errorMessage)); + } else { + return false; + } + } else { + throw MyException(tr("Extraction failed: %1") + .arg(static_cast(m_ArchiveHandler->getLastError()))); + } + } + + return true; +} + +QString InstallationManager::extractFile(std::shared_ptr entry, + bool silent) +{ + QStringList result = this->extractFiles({entry}, silent); + return result.isEmpty() ? QString() : result[0]; +} + +QStringList InstallationManager::extractFiles( + std::vector> const& entries, bool silent) +{ + // Remove the directory since mapToArchive would add them: + std::vector> files; + std::copy_if(entries.begin(), entries.end(), std::back_inserter(files), + [](auto const& entry) { + return entry->isFile(); + }); + + // Update the archive: + ArchiveFileTree::mapToArchive(*m_ArchiveHandler, files); + + // Retrieve the file path: + QStringList result; + + for (auto& entry : files) { + auto path = entry->path(); + result.append(QDir::tempPath().append("/").append(path)); + m_TempFilesToDelete.insert(path); + } + + if (!extractFiles(QDir::tempPath(), tr("Extracting files"), false, silent)) { + return {}; + } + + return result; +} + +QString +InstallationManager::createFile(std::shared_ptr entry) +{ + // Use QTemporaryFile to create the temporary file with the given template: + QTemporaryFile tempFile( + QDir::cleanPath(QDir::tempPath() + QDir::separator() + "mo2-install")); + + // Turn-off autoRemove otherwise the file is deleted when destructor is called: + tempFile.setAutoRemove(false); + + // Open/Close the file so that installer can use it properly: + if (!tempFile.open()) { + return {}; + } + tempFile.close(); + + // fileName() returns the full path since we provide a full path in the constructor: + const QString absPath = tempFile.fileName(); + + m_CreatedFiles[entry] = absPath; + m_TempFilesToDelete.insert(QDir::temp().relativeFilePath(absPath)); + + // Returns the path with native separators: + return QDir::toNativeSeparators(absPath); +} + +void InstallationManager::cleanCreatedFiles( + std::shared_ptr fileTree) +{ + // We simply have to check if all the entries have fileTree as a parent: + for (auto it = std::begin(m_CreatedFiles); it != std::end(m_CreatedFiles);) { + + // Find the parent - Could this be in FileTreeEntry? + bool found = false; + { + auto parent = it->first->parent(); + while (parent && !found) { + if (parent == fileTree) { + found = true; + } else { + parent = parent->parent(); + } + } + } + + // If the parent was not found, we remove the entry, otherwize we move to the next + // one: + if (!found) { + it = m_CreatedFiles.erase(it); + } else { + ++it; + } + } +} + +IPluginInstaller::EInstallResult +InstallationManager::installArchive(GuessedValue& modName, + const QString& archiveName, int modId) +{ + // in earlier versions the modName was copied here and the copy passed to install. I + // don't know why I did this and it causes a problem if this is called by the bundle + // installer and the bundled installer adds additional names that then end up being + // used, because the caller will then not have the right name. + return install(archiveName, modName, modId).result(); +} + +QString InstallationManager::generateBackupName(const QString& directoryName) +{ + QString backupName = directoryName + "_backup"; + if (QDir(backupName).exists()) { + int idx = 2; + QString temp = backupName + QString::number(idx); + while (QDir(temp).exists()) { + ++idx; + temp = backupName + QString::number(idx); + } + backupName = temp; + } + return backupName; +} + +InstallationResult InstallationManager::testOverwrite(GuessedValue& modName) +{ + QString targetDirectory = + QDir::fromNativeSeparators(m_ModsDirectory + "/" + modName); + + // this is only returned on success + InstallationResult result{IPluginInstaller::RESULT_SUCCESS}; + + while (QDir(targetDirectory).exists()) { + Settings& settings(Settings::instance()); + + const bool backup = settings.keepBackupOnInstall(); + QueryOverwriteDialog overwriteDialog(m_ParentWidget, + backup ? QueryOverwriteDialog::BACKUP_YES + : QueryOverwriteDialog::BACKUP_NO); + + if (overwriteDialog.exec()) { + settings.setKeepBackupOnInstall(overwriteDialog.backup()); + + if (overwriteDialog.backup()) { + QString const backupDirectory = generateBackupName(targetDirectory); + if (!copyDir(targetDirectory, backupDirectory, false)) { + reportError(tr("Failed to create backup")); + return {IPluginInstaller::RESULT_FAILED}; + } + } + + result.m_merged = overwriteDialog.action() == QueryOverwriteDialog::ACT_MERGE; + result.m_replaced = overwriteDialog.action() == QueryOverwriteDialog::ACT_REPLACE; + result.m_backup = overwriteDialog.backup(); + + if (overwriteDialog.action() == QueryOverwriteDialog::ACT_RENAME) { + bool ok = false; + QString const name = QInputDialog::getText(m_ParentWidget, tr("Mod Name"), tr("Name"), + QLineEdit::Normal, modName, &ok); + if (ok && !name.isEmpty()) { + modName.update(name, GUESS_USER); + if (!ensureValidModName(modName)) { + return {IPluginInstaller::RESULT_FAILED}; + } + targetDirectory = QDir::fromNativeSeparators(m_ModsDirectory) + "/" + modName; + } + } else if (overwriteDialog.action() == QueryOverwriteDialog::ACT_REPLACE) { + unsigned int const idx = ModInfo::getIndex(modName); + if (idx != UINT_MAX) { + auto modInfo = ModInfo::getByIndex(idx); + // mark the old install file as uninstalled + emit modReplaced(modInfo->installationFile()); + } + // save original settings like categories. Because it makes sense + QString metaFilename = targetDirectory + "/meta.ini"; + QFile settingsFile(metaFilename); + QByteArray originalSettings; + if (settingsFile.open(QIODevice::ReadOnly)) { + originalSettings = settingsFile.readAll(); + settingsFile.close(); + } + + // remove the directory with all content, then recreate it empty + shellDelete(QStringList(targetDirectory)); + if (!QDir().mkdir(targetDirectory)) { + // The retry exists because Windows can keep a directory around for a + // moment after delete. Linux doesn't have that problem; the sleep is + // kept for safety in case the underlying filesystem is slow. + QThread::msleep(100); + QDir().mkdir(targetDirectory); + } + // restore the saved settings + if (settingsFile.open(QIODevice::WriteOnly)) { + settingsFile.write(originalSettings); + settingsFile.close(); + } else { + log::error("failed to restore original settings: {}", metaFilename); + } + return result; + } else if (overwriteDialog.action() == QueryOverwriteDialog::ACT_MERGE) { + return result; + } else /* if (overwriteDialog.action() == QueryOverwriteDialog::ACT_NONE) */ + { + return {IPluginInstaller::RESULT_CANCELED}; + } + } else { + return {IPluginInstaller::RESULT_CANCELED}; + } + } + + QDir().mkdir(targetDirectory); + + return result; +} + +bool InstallationManager::ensureValidModName(GuessedValue& name) const +{ + while (name->isEmpty()) { + bool ok; + name.update( + QInputDialog::getText( + m_ParentWidget, tr("Invalid name"), + tr("The name you entered is invalid, please enter a different one."), + QLineEdit::Normal, "", &ok), + GUESS_USER); + if (!ok) { + return false; + } + } + return true; +} + +InstallationResult InstallationManager::doInstall(GuessedValue& modName, + QString gameName, int modID, + const QString& version, + const QString& newestVersion, + int categoryID, int fileCategoryID, + const QString& repository) +{ + if (!ensureValidModName(modName)) { + return {IPluginInstaller::RESULT_FAILED}; + } + + bool const merge = false; + // determine target directory + InstallationResult result = testOverwrite(modName); + if (!result) { + return result; + } + + result.m_name = modName; + + QString const targetDirectory = QDir(m_ModsDirectory + "/" + modName).canonicalPath(); + QString targetDirectoryNative = QDir::toNativeSeparators(targetDirectory); + + log::debug("installing to \"{}\"", targetDirectoryNative); + if (!extractFiles(targetDirectory, "", true, false)) { + return {IPluginInstaller::RESULT_CANCELED}; + } + + // Copy the created files: + for (auto& p : m_CreatedFiles) { + QString destPath = + QDir::cleanPath(targetDirectory + QDir::separator() + p.first->path()); + log::debug("Moving {} to {}.", p.second, destPath); + + // We need to remove the path if it exists: + if (QFile::exists(destPath)) { + QFile::remove(destPath); + } + + QDir const dir = QFileInfo(destPath).absoluteDir(); + if (!dir.exists()) { + dir.mkpath("."); + } + + QFile::copy(p.second, destPath); + } + + QSettings settingsFile(targetDirectory + "/meta.ini", QSettings::IniFormat); + + // overwrite settings only if they are actually are available or haven't been set + // before + if ((gameName != "") || !settingsFile.contains("gameName")) { + settingsFile.setValue("gameName", gameName); + } + if ((modID != 0) || !settingsFile.contains("modid")) { + settingsFile.setValue("modid", modID); + } + if (!settingsFile.contains("version") || + (!version.isEmpty() && + (!merge || (VersionInfo(version) >= + VersionInfo(settingsFile.value("version").toString()))))) { + settingsFile.setValue("version", version); + } + if (!newestVersion.isEmpty() || !settingsFile.contains("newestVersion")) { + settingsFile.setValue("newestVersion", newestVersion); + } + // issue #51 used to overwrite the manually set categories + if (!settingsFile.contains("category")) { + settingsFile.setValue("category", QString::number(categoryID)); + } + settingsFile.setValue("nexusFileStatus", fileCategoryID); + settingsFile.setValue("installationFile", storeMetaPath(m_CurrentFile)); + settingsFile.setValue("repository", repository); + + if (!merge) { + // this does not clear the list we have in memory but the mod is going to have to be + // re-read anyway btw.: installedFiles were written with beginWriteArray but we can + // still clear it with beginGroup. nice + settingsFile.beginGroup("installedFiles"); + settingsFile.remove(""); + settingsFile.endGroup(); + } + + return result; +} + +bool InstallationManager::wasCancelled() const +{ + return m_ArchiveHandler->getLastError() == Archive::Error::ERROR_EXTRACT_CANCELLED; +} + +bool InstallationManager::isRunning() const +{ + return m_IsRunning; +} + +void InstallationManager::postInstallCleanup() +{ + // Clear the list of created files: + m_CreatedFiles.clear(); + + // Close the archive: + m_ArchiveHandler->close(); + + // directories we may want to remove. sorted from longest to shortest to ensure we + // remove subdirectories first. + auto longestFirst = [](const QString& LHS, const QString& RHS) -> bool { + if (LHS.size() != RHS.size()) + return LHS.size() > RHS.size(); + else + return LHS < RHS; + }; + + std::set> + directoriesToRemove(longestFirst); + + // clean up temp files + // TODO: this doesn't yet remove directories. Also, the files may be left there if + // this point isn't reached + for (const QString& tempFile : m_TempFilesToDelete) { + QFileInfo const fileInfo(QDir::tempPath() + "/" + tempFile); + if (fileInfo.exists()) { + if (!fileInfo.isReadable() || !fileInfo.isWritable()) { + QFile::setPermissions(fileInfo.absoluteFilePath(), + QFile::ReadOther | QFile::WriteOther); + } + if (!QFile::remove(fileInfo.absoluteFilePath())) { + log::warn("Unable to delete {}", fileInfo.absoluteFilePath()); + } + } + directoriesToRemove.insert(fileInfo.absolutePath()); + } + + m_TempFilesToDelete.clear(); + + // try to delete each directory we had temporary files in. the call fails for + // non-empty directories which is ok + for (const QString& dir : directoriesToRemove) { + QDir().rmdir(dir); + } +} + +InstallationResult InstallationManager::install(const QString& fileName, + GuessedValue& modName, + int modID) +{ + m_IsRunning = true; + ON_BLOCK_EXIT([this]() { + m_IsRunning = false; + }); + + QFileInfo const fileInfo(fileName); + if (!getSupportedExtensions().contains(fileInfo.suffix(), Qt::CaseInsensitive)) { + reportError(tr("File format \"%1\" not supported").arg(fileInfo.suffix())); + return {IPluginInstaller::RESULT_FAILED}; + } + + modName.setFilter(&fixDirectoryName); + + modName.update(QFileInfo(fileName).completeBaseName(), GUESS_FALLBACK); + + // read out meta information from the download if available + QString gameName = ""; + QString version = ""; + QString newestVersion = ""; + int category = 0; + int categoryID = 0; + int fileCategoryID = 1; + QString repository = "Nexus"; + + QString const metaName = fileName + ".meta"; + if (QFile(metaName).exists()) { + QSettings const metaFile(metaName, QSettings::IniFormat); + gameName = metaFile.value("gameName", "").toString(); + modID = metaFile.value("modID", 0).toInt(); + QTextDocument doc; + doc.setHtml(metaFile.value("name", "").toString()); + modName.update(doc.toPlainText(), GUESS_FALLBACK); + modName.update(metaFile.value("modName", "").toString(), GUESS_META); + + version = metaFile.value("version", "").toString(); + newestVersion = metaFile.value("newestVersion", "").toString(); + category = metaFile.value("category", 0).toInt(); + unsigned int const categoryIndex = CategoryFactory::instance().resolveNexusID(category); + if (category != 0 && categoryIndex == 0U && + Settings::instance().nexus().categoryMappings()) { + QMessageBox nexusQuery; + nexusQuery.setWindowTitle(tr("No category found")); + nexusQuery.setText(tr( + "This Nexus category has not yet been mapped. Do you wish to proceed without " + "setting a category, proceed and disable automatic Nexus mappings, or stop " + "and configure your category mappings?")); + nexusQuery.addButton(tr("&Proceed"), QMessageBox::YesRole); + QPushButton* disableButton = + nexusQuery.addButton(tr("&Disable"), QMessageBox::AcceptRole); + QPushButton* stopButton = + nexusQuery.addButton(tr("&Stop && Configure"), QMessageBox::DestructiveRole); + nexusQuery.exec(); + if (nexusQuery.clickedButton() == disableButton) { + Settings::instance().nexus().setCategoryMappings(false); + } else if (nexusQuery.clickedButton() == stopButton) { + return MOBase::IPluginInstaller::RESULT_CATEGORYREQUESTED; + } + } else { + categoryID = CategoryFactory::instance().getCategoryID(categoryIndex); + } + repository = metaFile.value("repository", "").toString(); + fileCategoryID = metaFile.value("fileCategory", 1).toInt(); + } + + if (version.isEmpty()) { + QDateTime const lastMod = fileInfo.lastModified(); + version = "d" + lastMod.toString("yyyy.M.d"); + } + + { // guess the mod name and mod if from the file name if there was no meta + // information + QString guessedModName; + int guessedModID = modID; + NexusInterface::interpretNexusFileName(QFileInfo(fileName).fileName(), + guessedModName, guessedModID, false); + if ((modID == 0) && (guessedModID != -1)) { + modID = guessedModID; + } else if (modID != guessedModID) { + log::debug("passed mod id: {}, guessed id: {}", modID, guessedModID); + } + + modName.update(guessedModName, GUESS_GOOD); + } + + m_CurrentFile = fileInfo.absoluteFilePath(); + if (fileInfo.dir() == QDir(m_DownloadsDirectory)) { + m_CurrentFile = fileInfo.fileName(); + } + log::debug("using mod name \"{}\" (id {}) -> {}", QString(modName), modID, + m_CurrentFile); + + // If there's an archive already open, close it. This happens with the bundle + // installer when it uncompresses a split archive, then finds it has a real archive + // to deal with. + m_ArchiveHandler->close(); + + // open the archive and construct the directory tree the installers work on + + bool const archiveOpen = + m_ArchiveHandler->open(fileName.toStdWString(), [this]() -> std::wstring { + m_Password = QString(); + + // Note: If we are not in the Qt event thread, we cannot use queryPassword() + // directly, so we emit passwordRequested() that is connected to + // queryPassword(). The connection is made using Qt::BlockingQueuedConnection, + // so the emit "call" is actually blocking. We cannot use emit if we are in the + // even thread, otherwize we have a deadlock. + if (QThread::currentThread() != QApplication::instance()->thread()) { + emit passwordRequested(); + } else { + queryPassword(); + } + return m_Password.toStdWString(); + }); + if (!archiveOpen) { + log::debug("integrated archiver can't open {}: {} ({})", fileName, + getErrorString(m_ArchiveHandler->getLastError()), + m_ArchiveHandler->getLastError()); + } + ON_BLOCK_EXIT(std::bind(&InstallationManager::postInstallCleanup, this)); + + std::shared_ptr filesTree = + archiveOpen ? ArchiveFileTree::makeTree(*m_ArchiveHandler) : nullptr; + + auto installers = m_PluginContainer->plugins(); + + std::sort(installers.begin(), installers.end(), + [](IPluginInstaller* lhs, IPluginInstaller* rhs) { + return lhs->priority() > rhs->priority(); + }); + + InstallationResult installResult(IPluginInstaller::RESULT_NOTATTEMPTED); + + for (IPluginInstaller* installer : installers) { + // don't use inactive installers (installer can't be null here but vc static code + // analysis thinks it could) + if ((installer == nullptr) || !m_PluginContainer->isEnabled(installer)) { + continue; + } + + // try only manual installers if that was requested + if (installResult.result() == IPluginInstaller::RESULT_MANUALREQUESTED) { + if (!installer->isManualInstaller()) { + continue; + } + } else if (installResult.result() != IPluginInstaller::RESULT_NOTATTEMPTED) { + break; + } + + try { + { // simple case + IPluginInstallerSimple* installerSimple = + dynamic_cast(installer); + if ((installerSimple != nullptr) && (filesTree != nullptr) && + (installer->isArchiveSupported(filesTree))) { + installResult.m_result = + installerSimple->install(modName, filesTree, version, modID); + if (installResult) { + + // Downcast to an actual ArchiveFileTree and map to the archive. Test if + // the tree is still an ArchiveFileTree, otherwize it means the installer + // did some bad stuff. + ArchiveFileTree* p = dynamic_cast(filesTree.get()); + if (p == nullptr) { + throw IncompatibilityException( + tr("Invalid file tree returned by plugin.")); + } + + // Detach the file tree (this ensure the parent is null and call to path() + // stops at this root): + p->detach(); + + p->mapToArchive(*m_ArchiveHandler); + + // Clean the created files: + cleanCreatedFiles(filesTree); + + // the simple installer only prepares the installation, the rest + // works the same for all installers + installResult = doInstall(modName, gameName, modID, version, newestVersion, + categoryID, fileCategoryID, repository); + } + } + } + + if (installResult.result() != IPluginInstaller::RESULT_CANCELED) { // custom case + IPluginInstallerCustom* installerCustom = + dynamic_cast(installer); + if ((installerCustom != nullptr) && + (((filesTree != nullptr) && installer->isArchiveSupported(filesTree)) || + ((filesTree == nullptr) && + installerCustom->isArchiveSupported(fileName)))) { + std::set const installerExt = installerCustom->supportedExtensions(); + if (installerExt.contains(fileInfo.suffix())) { + installResult.m_result = + installerCustom->install(modName, gameName, fileName, version, modID); + unsigned int const idx = ModInfo::getIndex(modName); + if (idx != UINT_MAX) { + ModInfo::Ptr const info = ModInfo::getByIndex(idx); + info->setRepository(repository); + } + } + } + } + } catch (const IncompatibilityException& e) { + log::error("plugin \"{}\" incompatible: {}", installer->name(), e.what()); + } + + // act upon the installation result. at this point the files have already been + // extracted to the correct location + switch (installResult.result()) { + case IPluginInstaller::RESULT_FAILED: { + QMessageBox::information(qApp->activeWindow(), tr("Installation failed"), + tr("Something went wrong while installing this mod."), + QMessageBox::Ok); + return installResult; + } break; + case IPluginInstaller::RESULT_SUCCESS: + case IPluginInstaller::RESULT_SUCCESSCANCEL: { + if (filesTree != nullptr) { + auto iniTweakEntry = filesTree->find("INI Tweaks", FileTreeEntry::DIRECTORY); + installResult.m_iniTweaks = + iniTweakEntry != nullptr && !iniTweakEntry->astree()->empty(); + } + installResult.m_result = IPluginInstaller::RESULT_SUCCESS; + return installResult; + } break; + case IPluginInstaller::RESULT_NOTATTEMPTED: + case IPluginInstaller::RESULT_MANUALREQUESTED: { + continue; + } + default: + return installResult; + } + } + if (installResult.result() == IPluginInstaller::RESULT_NOTATTEMPTED) { + reportError( + tr("None of the available installer plugins were able to handle that archive.\n" + "This is likely due to a corrupted or incompatible download or unrecognized " + "archive format.")); + } + + return installResult; +} + +QString InstallationManager::getErrorString(Archive::Error errorCode) +{ + switch (errorCode) { + case Archive::Error::ERROR_NONE: { + return tr("no error"); + } break; + case Archive::Error::ERROR_LIBRARY_NOT_FOUND: { + return tr("7z.so not found"); + } break; + case Archive::Error::ERROR_LIBRARY_INVALID: { + return tr("7z.so isn't valid"); + } break; + case Archive::Error::ERROR_ARCHIVE_NOT_FOUND: { + return tr("archive not found"); + } break; + case Archive::Error::ERROR_FAILED_TO_OPEN_ARCHIVE: { + return tr("failed to open archive"); + } break; + case Archive::Error::ERROR_INVALID_ARCHIVE_FORMAT: { + return tr("unsupported archive type"); + } break; + case Archive::Error::ERROR_LIBRARY_ERROR: { + return tr("internal library error"); + } break; + case Archive::Error::ERROR_ARCHIVE_INVALID: { + return tr("archive invalid"); + } break; + default: { + // this probably means the archiver.dll is newer than this + return tr("unknown archive error"); + } break; + } +} + +QStringList InstallationManager::getSupportedExtensions() const +{ + std::set supportedExtensions( + {"zip", "rar", "7z", "fomod", "001"}); + for (auto* installer : m_PluginContainer->plugins()) { + if (m_PluginContainer->isEnabled(installer)) { + if (auto* installerCustom = dynamic_cast(installer)) { + std::set const extensions = installerCustom->supportedExtensions(); + supportedExtensions.insert(extensions.begin(), extensions.end()); + } + } + } + return {supportedExtensions.begin(), supportedExtensions.end()}; +} + +void InstallationManager::notifyInstallationStart(QString const& archive, + bool reinstallation, + ModInfo::Ptr currentMod) +{ + const auto& installers = m_PluginContainer->plugins(); + for (auto* installer : installers) { + if (m_PluginContainer->isEnabled(installer)) { + installer->onInstallationStart(archive, reinstallation, currentMod.get()); + } + } +} + +void InstallationManager::notifyInstallationEnd(const InstallationResult& result, + ModInfo::Ptr newMod) +{ + const auto& installers = m_PluginContainer->plugins(); + for (auto* installer : installers) { + if (m_PluginContainer->isEnabled(installer)) { + installer->onInstallationEnd(result.result(), newMod.get()); + } + } +} diff --git a/src/src/installationmanager.h b/src/src/installationmanager.h index 9e99e9b..f44b537 100644 --- a/src/src/installationmanager.h +++ b/src/src/installationmanager.h @@ -1,354 +1,354 @@ -/* -Copyright (C) 2012 Sebastian Herbord. All rights reserved. - -This file is part of Mod Organizer. - -Mod Organizer is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -Mod Organizer is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with Mod Organizer. If not, see . -*/ - -#ifndef INSTALLATIONMANAGER_H -#define INSTALLATIONMANAGER_H - -#include -#include -#include -#include -#include -#include - -#include -#include - -#include -#include - -#include "modinfo.h" -#include "plugincontainer.h" - -// contains installation result from the manager, internal class -// for MO2 that is not forwarded to plugin -class InstallationResult -{ -public: - // result status of the installation - // - auto result() const { return m_result; } - - // information about the installation, only valid for successful - // installation - // - QString name() const { return m_name; } - bool backupCreated() const { return m_backup; } - bool merged() const { return m_merged; } - bool replaced() const { return m_replaced; } - bool hasIniTweaks() const { return m_iniTweaks; } - bool mergedOrReplaced() const { return merged() || replaced(); } - - // check if the installation was a success - // - explicit operator bool() const - { - return result() == MOBase::IPluginInstaller::EInstallResult::RESULT_SUCCESS; - } - -private: - friend class InstallationManager; - - // create a failed result - InstallationResult(MOBase::IPluginInstaller::EInstallResult result = - MOBase::IPluginInstaller::EInstallResult::RESULT_FAILED); - - MOBase::IPluginInstaller::EInstallResult m_result; - - QString m_name; - - bool m_iniTweaks{false}; - bool m_backup{false}; - bool m_merged{false}; - bool m_replaced{false}; -}; - -class InstallationManager : public QObject, public MOBase::IInstallationManager -{ - Q_OBJECT - -public: - /** - * @brief constructor - * - * @param parent parent object. - **/ - explicit InstallationManager(); - - ~InstallationManager() override; - - void setParentWidget(QWidget* widget); - - /** - * @brief Notify all installer plugins that an installation is about to start. - * - * @param archive Path to the archive that is going to be installed. - * @param reinstallation True if this is a reinstallation, false otherwise. - * @param currentMod The installed mod corresponding to the archive being installed, - * or a null if there is no such mod. - */ - void notifyInstallationStart(QString const& archive, bool reinstallation, - ModInfo::Ptr currentMod); - - /** - * @brief notify all installer plugins that an installation has ended. - * - * @param result The result of the installation process. - * @param currentMod The newly install mod, if result is SUCCESS, a null pointer - * otherwise. - */ - void notifyInstallationEnd(const InstallationResult& result, ModInfo::Ptr newMod); - - /** - * @brief update the directory where mods are to be installed - * @param modsDirectory the mod directory - * @note this is called a lot, probably redundantly - */ - void setModsDirectory(const QString& modsDirectory) - { - m_ModsDirectory = modsDirectory; - } - - /** - * - */ - void setPluginContainer(const PluginContainer* pluginContainer); - - /** - * @brief update the directory where downloads are stored - * @param downloadDirectory the download directory - */ - void setDownloadDirectory(const QString& downloadDirectory) - { - m_DownloadsDirectory = downloadDirectory; - } - - /** - * @brief install a mod from an archive - * - * @param fileName absolute file name of the archive to install - * @param modName suggested name of the mod. If this is empty (the default), a name - *will be guessed based on the filename. The user will always have a chance to rename - *the mod - * @return true if the archive was installed, false if installation failed or was - *refused - * @exception std::exception an exception may be thrown if the archive can't be opened - *(maybe the format is invalid or the file is damaged) - **/ - InstallationResult install(const QString& fileName, - MOBase::GuessedValue& modName, int modID = 0); - - /** - * @return true if the installation was canceled - **/ - bool wasCancelled() const; - - /** - * @return true if an installation is currently in progress - **/ - bool isRunning() const; - - /** - * @brief retrieve a string describing the specified error code - * - * @param errorCode an error code as returned by the archiving function - * @return the error string - * @todo This function doesn't belong here, it is only public because the SelfUpdater - *class also uses "Archive" to get to the package.txt file - **/ - static QString getErrorString(Archive::Error errorCode); - - /** - * @return the extensions of archives supported by this installation manager. - */ - QStringList getSupportedExtensions() const override; - - /** - * @brief Extract the specified file from the currently opened archive to a temporary - * location. - * - * This method cannot be used to extract directory. - * - * @param entry Entry corresponding to the file to extract. - * @param silent If true, the dialog showing extraction progress will not be shown. - * - * @return the absolute path to the temporary file. - * - * @note The call will fail with an exception if no archive is open (plugins deriving - * from IPluginInstallerSimple can rely on that, custom installers should not). - * @note The temporary file is automatically cleaned up after the installation. - * @note This call can be very slow if the archive is large and "solid". - */ - QString extractFile(std::shared_ptr entry, - bool silent = false) override; - - /** - * @brief Extract the specified files from the currently opened archive to a temporary - * location. - * - * This method cannot be used to extract directory. - * - * @param entres Entries corresponding to the files to extract. - * @param silent If true, the dialog showing extraction progress will not be shown. - * - * @return the list of absolute paths to the temporary files. - * - * @note The call will fail with an exception if no archive is open (plugins deriving - * from IPluginInstallerSimple can rely on that, custom installers should not). - * @note The temporary file is automatically cleaned up after the installation. - * @note This call can be very slow if the archive is large and "solid". - * - * The flatten argument is not present here while it is present in the deprecated - * QStringList version for multiple reasons: 1) it was never used, 2) it is kind of - * fishy because there is no way to know if a file is going to be overriden, 3) it is - * quite easy to flatten a IFileTree and thus to given a list of entries flattened - * (this was not possible with the QStringList version since these were based on the - * name of the file inside the archive). - */ - QStringList - extractFiles(std::vector> const& entries, - bool silent = false) override; - - /** - * @brief Create a new file on the disk corresponding to the given entry. - * - * This method can be used by installer that needs to create files that are not in the - * original archive. At the end of the installation, if there are entries in the final - * tree that were used to create files, the corresponding files will be moved to the - * mod folder. - * - * @param entry The entry for which a temporary file should be created. - * - * @return the path to the created file. - */ - QString createFile(std::shared_ptr entry) override; - - /** - * @brief Installs the given archive. - * - * @param modName Suggested name of the mod. - * @param archiveFile Path to the archive to install. - * @param modId ID of the mod, if available. - * - * @return the installation result. - */ - MOBase::IPluginInstaller::EInstallResult - installArchive(MOBase::GuessedValue& modName, const QString& archiveName, - int modId = 0) override; - - /** - * @param modName current possible names for the mod - * - * @return an installation result containing information from the user. - */ - InstallationResult testOverwrite(MOBase::GuessedValue& modName); - - static QString generateBackupName(const QString& directoryName) ; - -private: - // actually perform the installation (write files to the disk, etc.), returns the - // installation result - // - InstallationResult doInstall(MOBase::GuessedValue& modName, QString gameName, - int modID, const QString& version, - const QString& newestVersion, int categoryID, - int fileCategoryID, const QString& repository); - - /** - * @brief Clean the list of created files by removing all entries that are not - * in the given tree. - * - * @param tree The parent tree. Usually the tree returned by the installer. - */ - void cleanCreatedFiles(std::shared_ptr fileTree); - - bool ensureValidModName(MOBase::GuessedValue& name) const; - - void postInstallCleanup(); - -private slots: - - /** - * @brief Query user for password and update the m_Password field. - */ - void queryPassword(); - -signals: - - /** - * @brief Emitted when a password is requested from the archive wrapper. - */ - void passwordRequested(); - - /** - * @brief Progress update from the extraction. - */ - void progressUpdate(); - - /** - * @brief An existing mod has been replaced with a newly installed one. - */ - void modReplaced(const QString fileName); - -private: - struct CaseInsensitive - { - bool operator()(const QString& LHS, const QString& RHS) const - { - return QString::compare(LHS, RHS, Qt::CaseInsensitive) < 0; - } - }; - - /** - * @brief Extract the files from the archived that are not disabled (that have - * output filenames associated with them) to the given path. - * - * @param extractPath Path (on the disk) were the extracted files should be put. - * - * This method is mainly a convenience method for the extractFiles() methods. - * - * @return true if the extraction was successful, false if the extraciton was - * cancelled. If an error occured, an exception is thrown. - */ - bool extractFiles(QString extractPath, QString title, bool showFilenames, - bool silent); - -private: - // The plugin container, mostly to check if installer are enabled or not. - const PluginContainer* m_PluginContainer; - - bool m_IsRunning{false}; - - QWidget* m_ParentWidget{nullptr}; - - QString m_ModsDirectory; - QString m_DownloadsDirectory; - - // Archive management. - std::unique_ptr m_ArchiveHandler; - QString m_CurrentFile; - QString m_Password; - - // Map from entries in the tree that is used by the installer and absolute - // paths to temporary files. - std::map, QString> m_CreatedFiles; - std::set m_TempFilesToDelete; -}; - -#endif // INSTALLATIONMANAGER_H +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef INSTALLATIONMANAGER_H +#define INSTALLATIONMANAGER_H + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include + +#include "modinfo.h" +#include "plugincontainer.h" + +// contains installation result from the manager, internal class +// for MO2 that is not forwarded to plugin +class InstallationResult +{ +public: + // result status of the installation + // + auto result() const { return m_result; } + + // information about the installation, only valid for successful + // installation + // + QString name() const { return m_name; } + bool backupCreated() const { return m_backup; } + bool merged() const { return m_merged; } + bool replaced() const { return m_replaced; } + bool hasIniTweaks() const { return m_iniTweaks; } + bool mergedOrReplaced() const { return merged() || replaced(); } + + // check if the installation was a success + // + explicit operator bool() const + { + return result() == MOBase::IPluginInstaller::EInstallResult::RESULT_SUCCESS; + } + +private: + friend class InstallationManager; + + // create a failed result + InstallationResult(MOBase::IPluginInstaller::EInstallResult result = + MOBase::IPluginInstaller::EInstallResult::RESULT_FAILED); + + MOBase::IPluginInstaller::EInstallResult m_result; + + QString m_name; + + bool m_iniTweaks{false}; + bool m_backup{false}; + bool m_merged{false}; + bool m_replaced{false}; +}; + +class InstallationManager : public QObject, public MOBase::IInstallationManager +{ + Q_OBJECT + +public: + /** + * @brief constructor + * + * @param parent parent object. + **/ + explicit InstallationManager(); + + ~InstallationManager() override; + + void setParentWidget(QWidget* widget); + + /** + * @brief Notify all installer plugins that an installation is about to start. + * + * @param archive Path to the archive that is going to be installed. + * @param reinstallation True if this is a reinstallation, false otherwise. + * @param currentMod The installed mod corresponding to the archive being installed, + * or a null if there is no such mod. + */ + void notifyInstallationStart(QString const& archive, bool reinstallation, + ModInfo::Ptr currentMod); + + /** + * @brief notify all installer plugins that an installation has ended. + * + * @param result The result of the installation process. + * @param currentMod The newly install mod, if result is SUCCESS, a null pointer + * otherwise. + */ + void notifyInstallationEnd(const InstallationResult& result, ModInfo::Ptr newMod); + + /** + * @brief update the directory where mods are to be installed + * @param modsDirectory the mod directory + * @note this is called a lot, probably redundantly + */ + void setModsDirectory(const QString& modsDirectory) + { + m_ModsDirectory = modsDirectory; + } + + /** + * + */ + void setPluginContainer(const PluginContainer* pluginContainer); + + /** + * @brief update the directory where downloads are stored + * @param downloadDirectory the download directory + */ + void setDownloadDirectory(const QString& downloadDirectory) + { + m_DownloadsDirectory = downloadDirectory; + } + + /** + * @brief install a mod from an archive + * + * @param fileName absolute file name of the archive to install + * @param modName suggested name of the mod. If this is empty (the default), a name + *will be guessed based on the filename. The user will always have a chance to rename + *the mod + * @return true if the archive was installed, false if installation failed or was + *refused + * @exception std::exception an exception may be thrown if the archive can't be opened + *(maybe the format is invalid or the file is damaged) + **/ + InstallationResult install(const QString& fileName, + MOBase::GuessedValue& modName, int modID = 0); + + /** + * @return true if the installation was canceled + **/ + bool wasCancelled() const; + + /** + * @return true if an installation is currently in progress + **/ + bool isRunning() const; + + /** + * @brief retrieve a string describing the specified error code + * + * @param errorCode an error code as returned by the archiving function + * @return the error string + * @todo This function doesn't belong here, it is only public because the SelfUpdater + *class also uses "Archive" to get to the package.txt file + **/ + static QString getErrorString(Archive::Error errorCode); + + /** + * @return the extensions of archives supported by this installation manager. + */ + QStringList getSupportedExtensions() const override; + + /** + * @brief Extract the specified file from the currently opened archive to a temporary + * location. + * + * This method cannot be used to extract directory. + * + * @param entry Entry corresponding to the file to extract. + * @param silent If true, the dialog showing extraction progress will not be shown. + * + * @return the absolute path to the temporary file. + * + * @note The call will fail with an exception if no archive is open (plugins deriving + * from IPluginInstallerSimple can rely on that, custom installers should not). + * @note The temporary file is automatically cleaned up after the installation. + * @note This call can be very slow if the archive is large and "solid". + */ + QString extractFile(std::shared_ptr entry, + bool silent = false) override; + + /** + * @brief Extract the specified files from the currently opened archive to a temporary + * location. + * + * This method cannot be used to extract directory. + * + * @param entres Entries corresponding to the files to extract. + * @param silent If true, the dialog showing extraction progress will not be shown. + * + * @return the list of absolute paths to the temporary files. + * + * @note The call will fail with an exception if no archive is open (plugins deriving + * from IPluginInstallerSimple can rely on that, custom installers should not). + * @note The temporary file is automatically cleaned up after the installation. + * @note This call can be very slow if the archive is large and "solid". + * + * The flatten argument is not present here while it is present in the deprecated + * QStringList version for multiple reasons: 1) it was never used, 2) it is kind of + * fishy because there is no way to know if a file is going to be overriden, 3) it is + * quite easy to flatten a IFileTree and thus to given a list of entries flattened + * (this was not possible with the QStringList version since these were based on the + * name of the file inside the archive). + */ + QStringList + extractFiles(std::vector> const& entries, + bool silent = false) override; + + /** + * @brief Create a new file on the disk corresponding to the given entry. + * + * This method can be used by installer that needs to create files that are not in the + * original archive. At the end of the installation, if there are entries in the final + * tree that were used to create files, the corresponding files will be moved to the + * mod folder. + * + * @param entry The entry for which a temporary file should be created. + * + * @return the path to the created file. + */ + QString createFile(std::shared_ptr entry) override; + + /** + * @brief Installs the given archive. + * + * @param modName Suggested name of the mod. + * @param archiveFile Path to the archive to install. + * @param modId ID of the mod, if available. + * + * @return the installation result. + */ + MOBase::IPluginInstaller::EInstallResult + installArchive(MOBase::GuessedValue& modName, const QString& archiveName, + int modId = 0) override; + + /** + * @param modName current possible names for the mod + * + * @return an installation result containing information from the user. + */ + InstallationResult testOverwrite(MOBase::GuessedValue& modName); + + static QString generateBackupName(const QString& directoryName) ; + +private: + // actually perform the installation (write files to the disk, etc.), returns the + // installation result + // + InstallationResult doInstall(MOBase::GuessedValue& modName, QString gameName, + int modID, const QString& version, + const QString& newestVersion, int categoryID, + int fileCategoryID, const QString& repository); + + /** + * @brief Clean the list of created files by removing all entries that are not + * in the given tree. + * + * @param tree The parent tree. Usually the tree returned by the installer. + */ + void cleanCreatedFiles(std::shared_ptr fileTree); + + bool ensureValidModName(MOBase::GuessedValue& name) const; + + void postInstallCleanup(); + +private slots: + + /** + * @brief Query user for password and update the m_Password field. + */ + void queryPassword(); + +signals: + + /** + * @brief Emitted when a password is requested from the archive wrapper. + */ + void passwordRequested(); + + /** + * @brief Progress update from the extraction. + */ + void progressUpdate(); + + /** + * @brief An existing mod has been replaced with a newly installed one. + */ + void modReplaced(QString fileName); + +private: + struct CaseInsensitive + { + bool operator()(const QString& LHS, const QString& RHS) const + { + return QString::compare(LHS, RHS, Qt::CaseInsensitive) < 0; + } + }; + + /** + * @brief Extract the files from the archived that are not disabled (that have + * output filenames associated with them) to the given path. + * + * @param extractPath Path (on the disk) were the extracted files should be put. + * + * This method is mainly a convenience method for the extractFiles() methods. + * + * @return true if the extraction was successful, false if the extraciton was + * cancelled. If an error occured, an exception is thrown. + */ + bool extractFiles(QString extractPath, QString title, bool showFilenames, + bool silent); + +private: + // The plugin container, mostly to check if installer are enabled or not. + const PluginContainer* m_PluginContainer; + + bool m_IsRunning{false}; + + QWidget* m_ParentWidget{nullptr}; + + QString m_ModsDirectory; + QString m_DownloadsDirectory; + + // Archive management. + std::unique_ptr m_ArchiveHandler; + QString m_CurrentFile; + QString m_Password; + + // Map from entries in the tree that is used by the installer and absolute + // paths to temporary files. + std::map, QString> m_CreatedFiles; + std::set m_TempFilesToDelete; +}; + +#endif // INSTALLATIONMANAGER_H diff --git a/src/src/instancemanager.cpp b/src/src/instancemanager.cpp index 3e1c0d5..35a15df 100644 --- a/src/src/instancemanager.cpp +++ b/src/src/instancemanager.cpp @@ -1,958 +1,958 @@ -/* -Copyright (C) 2016 Sebastian Herbord. All rights reserved. - -This file is part of Mod Organizer. - -Mod Organizer is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -Mod Organizer is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with Mod Organizer. If not, see . -*/ - -#include "instancemanager.h" -#include "createinstancedialog.h" -#include "createinstancedialogpages.h" -#include "filesystemutilities.h" -#include "fluorinepaths.h" -#include "instancemanagerdialog.h" -#include "nexusinterface.h" -#include "plugincontainer.h" -#include "selectiondialog.h" -#include "settings.h" -#include "shared/appconfig.h" -#include "shared/util.h" -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -using namespace MOBase; - -Instance::Instance(QString dir, bool portable, QString profileName) - : m_dir(std::move(dir)), m_portable(portable), - m_profile(std::move(profileName)) -{ - // Ensure portable instances have a ModOrganizer.sh launcher script. - if (m_portable && !m_dir.isEmpty()) { - const QString launchPath = QDir(m_dir).filePath("ModOrganizer.sh"); - if (!QFileInfo::exists(launchPath)) { - QFile launchFile(launchPath); - if (launchFile.open(QIODevice::WriteOnly | QIODevice::Text)) { - QTextStream out(&launchFile); - out << "#!/bin/bash\n"; - out << "# Auto-generated by fluorine-manager\n"; - out << "INSTANCE_DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n"; - out << "MO2_BIN=\"$(which ModOrganizer 2>/dev/null || echo ModOrganizer)\"\n"; - out << "exec \"${MO2_BIN}\" --instance \"${INSTANCE_DIR}\" \"$@\"\n"; - launchFile.close(); - QFile::setPermissions(launchPath, - QFileDevice::ReadOwner | QFileDevice::WriteOwner | - QFileDevice::ExeOwner | QFileDevice::ReadGroup | - QFileDevice::ExeGroup | QFileDevice::ReadOther | - QFileDevice::ExeOther); - } - } - } -} - -QString Instance::displayName() const -{ - if (isPortable()) { - const auto defaultPortable = - QDir(InstanceManager::singleton().portablePath()).absolutePath(); - if (QDir(m_dir).absolutePath() == defaultPortable) { - return QObject::tr("Portable"); - } - return QDir(m_dir).dirName(); - } - return QDir(m_dir).dirName(); -} - -QString Instance::gameName() const -{ - return m_gameName; -} - -QString Instance::gameDirectory() const -{ - return m_gameDir; -} - -QString Instance::directory() const -{ - return m_dir; -} - -QString Instance::baseDirectory() const -{ - return m_baseDir; -} - -MOBase::IPluginGame* Instance::gamePlugin() const -{ - return m_plugin; -} - -QString Instance::profileName() const -{ - return m_profile; -} - -QString Instance::iniPath() const -{ - return InstanceManager::singleton().iniPath(m_dir); -} - -bool Instance::isPortable() const -{ - return m_portable; -} - -bool Instance::isActive() const -{ - auto& m = InstanceManager::singleton(); - - if (auto i = m.currentInstance()) { - if (m_portable && i->isPortable()) { - return QDir(m_dir).absolutePath() == QDir(i->directory()).absolutePath(); - } else if (!m_portable && !i->isPortable()) { - return (i->displayName() == displayName()); - } - } - - return false; -} - -bool Instance::readFromIni() -{ - Settings s(iniPath()); - - if (s.iniStatus() != QSettings::NoError) { - log::error("can't read ini {}", iniPath()); - return false; - } - - // game name and directory are from ini unless overridden by setGame() - if (m_gameName.isEmpty()) { - if (auto v = s.game().name()) - m_gameName = *v; - } - - if (m_gameDir.isEmpty()) { - if (auto v = s.game().directory()) - m_gameDir = *v; - } - - if (m_gameVariant.isEmpty()) { - if (auto v = s.game().edition()) { - m_gameVariant = *v; - } - } - - if (m_baseDir.isEmpty()) { - m_baseDir = s.paths().base(); - } - - // figuring out profile from ini if it's missing - getProfile(s); - - return true; -} - -Instance::SetupResults Instance::setup(PluginContainer& plugins) -{ - // read initial values from the ini - log::debug("reading instance ini from '{}'", iniPath()); - if (!readFromIni()) { - log::error("readFromIni() failed for '{}'", iniPath()); - return SetupResults::BadIni; - } - log::debug("ini loaded: game='{}', dir='{}', variant='{}'", - m_gameName, m_gameDir, m_gameVariant); - - // getting game plugin - log::debug("searching for matching game plugin..."); - const auto r = getGamePlugin(plugins); - if (r != SetupResults::Okay) { - log::error("getGamePlugin() failed with result {}", static_cast(r)); - return r; - } - log::debug("matched game plugin '{}'", m_plugin->gameName()); - - // error if the variant missing and required by the plugin - if (m_gameVariant.isEmpty() && m_plugin->gameVariants().size() > 1) { - return SetupResults::MissingVariant; - } else { - m_plugin->setGameVariant(m_gameVariant); - } - - // update the ini in case anything was missing - log::debug("updating ini"); - updateIni(); - - // the game directory may be different than what the plugin detected, the user - // can change it in the settings and might have multiple versions of the game - // installed - log::debug("setting game path to '{}'", m_gameDir); - m_plugin->setGamePath(m_gameDir); - - return SetupResults::Okay; -} - -void Instance::updateIni() -{ - Settings s(iniPath()); - - if (s.iniStatus() != QSettings::NoError) { - log::error("can't open ini {}", iniPath()); - return; - } - - // updating the settings since some of these values might have been missing - s.game().setName(m_gameName); - s.game().setDirectory(m_gameDir); - s.game().setSelectedProfileName(m_profile); - - if (!m_gameVariant.isEmpty()) { - // don't write a variant to the ini if the plugin doesn't require one - s.game().setEdition(m_gameVariant); - } -} - -void Instance::setGame(const QString& name, const QString& dir) -{ - m_gameName = name; - m_gameDir = dir; -} - -void Instance::setVariant(const QString& name) -{ - m_gameVariant = name; -} - -Instance::SetupResults Instance::getGamePlugin(PluginContainer& plugins) -{ - if (!m_gameName.isEmpty() && !m_gameDir.isEmpty()) { - // normal case: both the name and dir are in the ini - - // find the plugin by name - for (IPluginGame* game : plugins.plugins()) { - log::debug(" checking game plugin '{}'...", game->gameName()); - if (m_gameName.compare(game->gameName(), Qt::CaseInsensitive) == 0) { - // plugin found, check if the game directory is valid - log::debug(" name matched, calling looksValid('{}')...", m_gameDir); - if (!game->looksValid(m_gameDir)) { - // the directory from the ini is not valid anymore - log::warn("game plugin {} says dir {} from ini {} is not valid", - game->gameName(), m_gameDir, iniPath()); - - // note that some plugins return true for isInstalled() if a path - // is found in the registry, but without actually checking if it's - // valid - - log::debug(" calling isInstalled()..."); - if (game->isInstalled() && game->looksValid(game->gameDirectory())) { - // bad game directory but the plugin reports there's a valid one - // somewhere; take it instead - log::warn("game plugin {} found a game at {}, taking it", game->gameName(), - game->gameDirectory().absolutePath()); - - m_gameDir = game->gameDirectory().absolutePath(); - } else { - // game seems to be gone completely - log::warn("game plugin {} found no game installation at all", - game->gameName()); - return SetupResults::GameGone; - } - } - - m_plugin = game; - return SetupResults::Okay; - } - } - - log::warn("game plugin {} not found", m_gameName); - return SetupResults::PluginGone; - } else if (m_gameName.isEmpty() && !m_gameDir.isEmpty()) { - // the name is missing, but there's a directory; find a plugin that can - // handle it - - log::warn("game name is missing from ini {} but dir {} is available", iniPath(), - m_gameDir); - - for (IPluginGame* game : plugins.plugins()) { - log::info(" checking plugin '{}' with looksValid('{}')...", game->gameName(), m_gameDir); - if (game->looksValid(m_gameDir)) { - // take it - log::warn("found plugin {} that can use dir {}", game->gameName(), m_gameDir); - - m_plugin = game; - m_gameName = game->gameName(); - - return SetupResults::Okay; - } - } - - log::error("no plugins can use dir {}", m_gameDir); - return SetupResults::GameGone; - } else if (!m_gameName.isEmpty() && m_gameDir.isEmpty()) { - // dir is missing, find a plugin with the correct name and use the install - // dir it detected - - log::warn("game dir is missing from ini {} but name {} is available", iniPath(), - m_gameName); - - for (IPluginGame* game : plugins.plugins()) { - if (m_gameName.compare(game->gameName(), Qt::CaseInsensitive) == 0) { - // plugin found, use its detected installation dir - - if (game->isInstalled()) { - log::warn("found plugin {} that matches name in ini {}, using auto detected " - "game dir {}", - game->gameName(), iniPath(), game->gameDirectory().absolutePath()); - - m_plugin = game; - m_gameDir = game->gameDirectory().absolutePath(); - - return SetupResults::Okay; - } else { - log::warn("found plugin {} that matches name in ini {}, but no game install " - "detected by plugin", - game->gameName(), iniPath()); - - return SetupResults::GameGone; - } - } - } - - // plugin seems to be gone - log::error("no plugin matches name {}", m_gameName); - return SetupResults::PluginGone; - } else { - // can't do anything with these two missing - log::error("both game name and dir are missing from ini {}", iniPath()); - return SetupResults::IniMissingGame; - } -} - -void Instance::getProfile(const Settings& s) -{ - if (!m_profile.isEmpty()) { - // there's already a profile set up, probably an override - return; - } - - if (auto name = s.game().selectedProfileName()) { - // use last profile - m_profile = *name; - return; - } - - // profile missing from ini, use the default - m_profile = QString::fromStdWString(AppConfig::defaultProfileName()); - - log::warn("no profile found in ini {}, using default '{}'", iniPath(), m_profile); -} - -// returns a list of files and folders that must be deleted when deleting -// this instance -// -std::vector Instance::objectsForDeletion() const -{ - // native separators and ending slash - auto prettyDir = [](auto s) { - if (!s.endsWith("/") || !s.endsWith("\\")) { - s += "/"; - } - - return QDir::toNativeSeparators(s); - }; - - // native separators - auto prettyFile = [](auto s) { - return QDir::toNativeSeparators(s); - }; - - // lowercase, native separators and ending slash - auto canonicalDir = [](QString s) { - s = s.toLower(); - - if (!s.endsWith("/") || !s.endsWith("\\")) { - s += "/"; - } - - return QDir::toNativeSeparators(s); - }; - - // lower and native separators - auto canonicalFile = [](auto s) { - return QDir::toNativeSeparators(s.toLower()); - }; - - // whether the given directory is contained in the root - auto dirInRoot = [&](const QString& root, const QString& dir) { - return canonicalDir(dir).startsWith(canonicalDir(root)); - }; - - // whether the given file is contained in the root - auto fileInRoot = [&](const QString& root, const QString& file) { - return canonicalFile(file).startsWith(canonicalDir(root)); - }; - - Settings settings(iniPath()); - - if (settings.iniStatus() != QSettings::NoError) { - log::error("can't read ini {}", iniPath()); - return {}; - } - - const QString loc = directory(); - const QString base = settings.paths().base(); - - // directories that might contain the individual files and directories - // set in the path settings - std::vector roots; - - // a portable instance has its location in the installation directory, - // don't delete that - if (!isPortable()) { - if (QDir(loc).exists()) { - roots.push_back({loc, true}); - } - } - - // the base directory is the location directory by default, don't add it - // if it's the same - if (canonicalDir(base) != canonicalDir(loc)) { - if (QDir(base).exists()) { - roots.push_back({base, false}); - } - } - - // all the directories that are part of an instance; none of them are - // mandatory for deletion - const std::vector dirs = { - settings.paths().downloads(), - settings.paths().mods(), - settings.paths().cache(), - settings.paths().profiles(), - settings.paths().overwrite(), - QDir(m_dir).filePath(QString::fromStdWString(AppConfig::dumpsDir())), - QDir(m_dir).filePath(QString::fromStdWString(AppConfig::logPath())), - }; - - // all the files that are part of an instance - const std::vector files = { - {iniPath(), true}, // the ini file must be deleted - }; - - // this will contain the root directories, plus all the individual - // directories that are not inside these roots - std::vector cleanDirs; - - for (const auto& f : dirs) { - bool inRoots = false; - - for (const auto& root : roots) { - if (dirInRoot(root.path, f.path)) { - inRoots = true; - break; - } - } - - if (!inRoots) { - // not in roots, this is a path that was changed by the user; make - // sure it exists - if (QDir(f.path).exists()) { - cleanDirs.push_back({prettyDir(f.path), f.mandatoryDelete}); - } - } - } - - // prepending the roots - for (auto itor = roots.rbegin(); itor != roots.rend(); ++itor) { - cleanDirs.insert(cleanDirs.begin(), {prettyDir(itor->path), itor->mandatoryDelete}); - } - - // this will contain the individual files that are not inside the roots; - // not that this only contains the INI file for now, so most of this is - // useless - std::vector cleanFiles; - - for (const auto& f : files) { - bool inRoots = false; - - for (const auto& root : roots) { - if (fileInRoot(root.path, f.path)) { - inRoots = true; - break; - } - } - - if (!inRoots) { - // not in roots, this is a path that was changed by the user; make - // sure it exists - if (QFileInfo(f.path).exists()) { - cleanFiles.push_back({prettyFile(f.path), f.mandatoryDelete}); - } - } - } - - // contains all the directories and files to be deleted - std::vector all; - all.insert(all.end(), cleanDirs.begin(), cleanDirs.end()); - all.insert(all.end(), cleanFiles.begin(), cleanFiles.end()); - - // mandatory on top - std::stable_sort(all.begin(), all.end()); - - return all; -} - -InstanceManager::InstanceManager() -{ - GlobalSettings::updateRegistryKey(); -} - -InstanceManager& InstanceManager::singleton() -{ - static InstanceManager s_Instance; - return s_Instance; -} - -void InstanceManager::overrideInstance(const QString& instanceName) -{ - m_overrideInstanceName = instanceName; -} - -void InstanceManager::overrideProfile(const QString& profileName) -{ - m_overrideProfileName = profileName; -} - -void InstanceManager::clearOverrides() -{ - m_overrideInstanceName = {}; - m_overrideProfileName = {}; -} - -std::unique_ptr InstanceManager::currentInstance() const -{ - const QString profile = m_overrideProfileName ? *m_overrideProfileName : ""; - - const bool hasPortable = portableInstanceExists(); - - const QString name = m_overrideInstanceName ? *m_overrideInstanceName - : GlobalSettings::currentInstance(); - - if (!allowedToChangeInstance()) { - // force portable instance - return std::make_unique(portablePath(), true, profile); - } - - if (hasPortable && !m_overrideInstanceName.has_value() && name.isEmpty()) { - // Prefer a colocated ModOrganizer.ini for portable bundles only when - // no instance has been explicitly selected by the user. - log::debug("portable instance detected at '{}', no stored instance, using portable", - portablePath()); - return std::make_unique(portablePath(), true, profile); - } - - if (name.isEmpty()) { - if (hasPortable) { - // use portable - return std::make_unique(portablePath(), true, profile); - } else { - // no instance set - return {}; - } - } - - // If the instance name is an absolute path to a directory containing - // ModOrganizer.ini, treat it as a portable instance at that path. - if (QDir::isAbsolutePath(name)) { - const QString iniPath = QDir(name).filePath("ModOrganizer.ini"); - if (QFileInfo::exists(iniPath)) { - return std::make_unique(name, true, profile); - } - } - - return std::make_unique(instancePath(name), false, profile); -} - -void InstanceManager::clearCurrentInstance() -{ - setCurrentInstance(""); - m_overrideInstanceName = {}; -} - -void InstanceManager::setCurrentInstance(const QString& name) -{ - GlobalSettings::setCurrentInstance(name); -} - -QString InstanceManager::instancePath(const QString& instanceName) const -{ - if (QDir::isAbsolutePath(instanceName)) { - return QDir::fromNativeSeparators(instanceName); - } - return QDir::fromNativeSeparators(globalInstancesRootPath() + "/" + instanceName); -} - -QString InstanceManager::globalInstancesRootPath() -{ - // Use the shared Fluorine data dir so the path is the same in native and - // Flatpak builds (QStandardPaths is remapped inside a Flatpak sandbox). - return QDir::fromNativeSeparators(fluorineDataDir()); -} - -QString InstanceManager::iniPath(const QString& instanceDir) -{ - return QDir(instanceDir).filePath(QString::fromStdWString(AppConfig::iniFileName())); -} - -std::vector InstanceManager::globalInstancePaths() const -{ - const QDir root(globalInstancesRootPath()); - const auto dirs = root.entryList(QDir::Dirs | QDir::NoDotAndDotDot); - - std::vector list; - - for (auto&& d : dirs) { - const QFileInfo iniFile(QDir(root.filePath(d)), "ModOrganizer.ini"); - log::debug("Checking for INI at path '{}'", iniFile.absoluteFilePath()); - - if (iniFile.exists()) { - log::debug("Found INI at path '{}'", iniFile.absoluteFilePath()); - list.push_back(root.filePath(d)); - } - } - - return list; -} - -bool InstanceManager::hasAnyInstances() const -{ - return portableInstanceExists() || !globalInstancePaths().empty(); -} - -QString InstanceManager::portablePath() -{ - return AppConfig::basePath(); -} - -bool InstanceManager::portableInstanceExists() -{ - return QFile::exists(AppConfig::basePath() + "/" + - QString::fromStdWString(AppConfig::iniFileName())); -} - -bool InstanceManager::allowedToChangeInstance() -{ - const auto lockFile = qApp->applicationDirPath() + "/" + - QString::fromStdWString(AppConfig::portableLockFileName()); - - return !QFile::exists(lockFile); -} - -MOBase::IPluginGame* -InstanceManager::gamePluginForDirectory(const QString& instanceDir, - PluginContainer& plugins) const -{ - return const_cast( - gamePluginForDirectory(instanceDir, const_cast(plugins))); -} - -const MOBase::IPluginGame* -InstanceManager::gamePluginForDirectory(const QString& instanceDir, - const PluginContainer& plugins) const -{ - const QString ini = iniPath(instanceDir); - - // reading ini - Settings s(ini); - - if (s.iniStatus() != QSettings::NoError) { - log::error("failed to load settings from {}", ini); - return nullptr; - } - - // using game name from ini, if available - const auto instanceGameName = s.game().name(); - - if (instanceGameName && !instanceGameName->isEmpty()) { - for (const IPluginGame* game : plugins.plugins()) { - if (instanceGameName->compare(game->gameName(), Qt::CaseInsensitive) == 0) { - return game; - } - } - - log::error("no plugin has game name '{}' that was found in ini {}", - *instanceGameName, ini); - } else { - log::error("no game name found in ini {}", ini); - } - - // using game directory from ini, if available - const auto gameDir = s.game().directory(); - - if (gameDir && !gameDir->isEmpty()) { - for (const IPluginGame* game : plugins.plugins()) { - if (game->looksValid(*gameDir)) { - return game; - } - } - - log::error("no plugin appears to support game directory '{}' from ini {}", *gameDir, - ini); - } else { - log::error("no game directory found in ini {}", ini); - } - - // looking for a plugin that can handle the directory - log::debug("falling back on looksValid check"); - - for (const IPluginGame* game : plugins.plugins()) { - if (game->looksValid(instanceDir)) { - return game; - } - } - - return nullptr; -} - -QString InstanceManager::makeUniqueName(const QString& instanceName) const -{ - const QString sanitized = MOBase::sanitizeFileName(instanceName); - - // trying "name (N)" - QString name = sanitized; - for (int i = 2; i < 100; ++i) { - if (!instanceExists(name)) { - return name; - } - - name = QString("%1 (%2)").arg(sanitized).arg(i); - } - - return {}; -} - -bool InstanceManager::instanceExists(const QString& instanceName) const -{ - const QDir root = globalInstancesRootPath(); - return root.exists(instanceName); -} - -QStringList InstanceManager::registeredPortablePaths() -{ - return GlobalSettings::portableInstances(); -} - -void InstanceManager::registerPortableInstance(const QString& path) -{ - GlobalSettings::addPortableInstance(path); -} - -void InstanceManager::unregisterPortableInstance(const QString& path) -{ - GlobalSettings::removePortableInstance(path); -} - -std::unique_ptr selectInstance() -{ - auto& m = InstanceManager::singleton(); - - // since there is no instance currently active, load plugins with a null - // OrganizerCore; see PluginContainer::initPlugin() - NexusInterface const ni(nullptr); - PluginContainer pc(nullptr); - pc.loadPlugins(); - - { - // show the instance manager dialog; it has both "Create new instance" and - // "Open existing portable" buttons, so it works whether or not instances - // already exist - InstanceManagerDialog dlg(pc); - - // the dialog normally restarts MO when an instance is selected, but this - // is not necessary here since MO hasn't really started yet - dlg.setRestartOnSelect(false); - - dlg.show(); - dlg.activateWindow(); - dlg.raise(); - - if (dlg.exec() != QDialog::Accepted) { - return {}; - } - } - - // return the new instance or the selection - return m.currentInstance(); -} - -// shows the game selection page of the create instance dialog so the user can -// pick which game is managed by this instance -// -// this is used below in setupInstance() when the game directory is gone or -// no plugins can recognize it -// -SetupInstanceResults selectGame(Instance& instance, PluginContainer& pc) -{ - CreateInstanceDialog dlg(pc, nullptr); - - // only show the game page - dlg.setSinglePage(instance.displayName()); - - dlg.show(); - dlg.activateWindow(); - dlg.raise(); - - if (dlg.exec() != QDialog::Accepted) { - // cancelled - return SetupInstanceResults::SelectAnother; - } - - // this info will be used instead of the ini, which should fix this - // particular problem - instance.setGame(dlg.creationInfo().game->gameName(), - dlg.creationInfo().gameLocation); - - return SetupInstanceResults::TryAgain; -} - -// shows the game variant page of the create instance dialog so the user can -// pick which game variant is installed -// -// this is used below in setupInstance() when there is no variant in the ini -// but the game plugin requires one; this can happen when the ini is broken -// or when a new variant has become supported by the plugin for a game the -// user already has an instance for -// -SetupInstanceResults selectVariant(Instance& instance, PluginContainer& pc) -{ - CreateInstanceDialog dlg(pc, nullptr); - - // the variant page uses the game page to know which game was selected, so - // set it manually - dlg.getPage()->select(instance.gamePlugin(), instance.gameDirectory()); - - // only show the variant page - dlg.setSinglePage(instance.displayName()); - - dlg.show(); - dlg.activateWindow(); - dlg.raise(); - - if (dlg.exec() != QDialog::Accepted) { - return SetupInstanceResults::SelectAnother; - } - - // this info will be used instead of the ini, which should fix this - // particular problem - instance.setVariant(dlg.creationInfo().gameVariant); - - return SetupInstanceResults::TryAgain; -} - -SetupInstanceResults setupInstance(Instance& instance, PluginContainer& pc) -{ - // set up the instance - log::debug("setupInstance() called for '{}', ini='{}'", - instance.displayName(), instance.iniPath()); - const auto setupResult = instance.setup(pc); - log::debug("instance.setup() returned {}", static_cast(setupResult)); - - switch (setupResult) { - case Instance::SetupResults::Okay: { - // all good - return SetupInstanceResults::Okay; - } - - case Instance::SetupResults::BadIni: { - // unreadable ini, there's not much that can be done, select another - // instance - - reportError(QObject::tr("Cannot open instance '%1', failed to read INI file %2.") - .arg(instance.displayName()) - .arg(instance.iniPath())); - - return SetupInstanceResults::SelectAnother; - } - - case Instance::SetupResults::IniMissingGame: { - // both the game name and directory are missing from the ini; although - // this shouldn't happen, setup() is able to handle when either is - // missing, but not both - // - // ask the user for the game managed by this instance - - reportError( - QObject::tr( - "Cannot open instance '%1', the managed game was not found in the INI " - "file %2. Select the game managed by this instance.") - .arg(instance.displayName()) - .arg(instance.iniPath())); - - return selectGame(instance, pc); - } - - case Instance::SetupResults::PluginGone: { - // there is no plugin that can handle the game name/directory from the - // ini, so this instance is unusable - - reportError( - QObject::tr("Cannot open instance '%1', the game plugin '%2' doesn't exist. It " - "may have been deleted by an antivirus. Select another instance.") - .arg(instance.displayName()) - .arg(instance.gameName())); - - return SetupInstanceResults::SelectAnother; - } - - case Instance::SetupResults::GameGone: { - // the game directory doesn't exist or the plugin doesn't recognize it; - // ask the user for the game managed by this instance - - reportError( - QObject::tr( - "Cannot open instance '%1', the game directory '%2' doesn't exist or " - "the game plugin '%3' doesn't recognize it. Select the game managed " - "by this instance.") - .arg(instance.displayName()) - .arg(instance.gameDirectory()) - .arg(instance.gameName())); - - return selectGame(instance, pc); - } - - case Instance::SetupResults::MissingVariant: { - // the game variant is missing from the ini, ask the user for it - return selectVariant(instance, pc); - } - - default: { - // shouldn't happen - return SetupInstanceResults::Exit; - } - } -} +/* +Copyright (C) 2016 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "instancemanager.h" +#include "createinstancedialog.h" +#include "createinstancedialogpages.h" +#include "filesystemutilities.h" +#include "fluorinepaths.h" +#include "instancemanagerdialog.h" +#include "nexusinterface.h" +#include "plugincontainer.h" +#include "selectiondialog.h" +#include "settings.h" +#include "shared/appconfig.h" +#include "shared/util.h" +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace MOBase; + +Instance::Instance(QString dir, bool portable, QString profileName) + : m_dir(std::move(dir)), m_portable(portable), + m_profile(std::move(profileName)) +{ + // Ensure portable instances have a ModOrganizer.sh launcher script. + if (m_portable && !m_dir.isEmpty()) { + const QString launchPath = QDir(m_dir).filePath("ModOrganizer.sh"); + if (!QFileInfo::exists(launchPath)) { + QFile launchFile(launchPath); + if (launchFile.open(QIODevice::WriteOnly | QIODevice::Text)) { + QTextStream out(&launchFile); + out << "#!/bin/bash\n"; + out << "# Auto-generated by fluorine-manager\n"; + out << "INSTANCE_DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n"; + out << "MO2_BIN=\"$(which ModOrganizer 2>/dev/null || echo ModOrganizer)\"\n"; + out << "exec \"${MO2_BIN}\" --instance \"${INSTANCE_DIR}\" \"$@\"\n"; + launchFile.close(); + QFile::setPermissions(launchPath, + QFileDevice::ReadOwner | QFileDevice::WriteOwner | + QFileDevice::ExeOwner | QFileDevice::ReadGroup | + QFileDevice::ExeGroup | QFileDevice::ReadOther | + QFileDevice::ExeOther); + } + } + } +} + +QString Instance::displayName() const +{ + if (isPortable()) { + const auto defaultPortable = + QDir(InstanceManager::singleton().portablePath()).absolutePath(); + if (QDir(m_dir).absolutePath() == defaultPortable) { + return QObject::tr("Portable"); + } + return QDir(m_dir).dirName(); + } + return QDir(m_dir).dirName(); +} + +QString Instance::gameName() const +{ + return m_gameName; +} + +QString Instance::gameDirectory() const +{ + return m_gameDir; +} + +QString Instance::directory() const +{ + return m_dir; +} + +QString Instance::baseDirectory() const +{ + return m_baseDir; +} + +MOBase::IPluginGame* Instance::gamePlugin() const +{ + return m_plugin; +} + +QString Instance::profileName() const +{ + return m_profile; +} + +QString Instance::iniPath() const +{ + return InstanceManager::singleton().iniPath(m_dir); +} + +bool Instance::isPortable() const +{ + return m_portable; +} + +bool Instance::isActive() const +{ + auto& m = InstanceManager::singleton(); + + if (auto i = m.currentInstance()) { + if (m_portable && i->isPortable()) { + return QDir(m_dir).absolutePath() == QDir(i->directory()).absolutePath(); + } else if (!m_portable && !i->isPortable()) { + return (i->displayName() == displayName()); + } + } + + return false; +} + +bool Instance::readFromIni() +{ + Settings s(iniPath()); + + if (s.iniStatus() != QSettings::NoError) { + log::error("can't read ini {}", iniPath()); + return false; + } + + // game name and directory are from ini unless overridden by setGame() + if (m_gameName.isEmpty()) { + if (auto v = s.game().name()) + m_gameName = *v; + } + + if (m_gameDir.isEmpty()) { + if (auto v = s.game().directory()) + m_gameDir = *v; + } + + if (m_gameVariant.isEmpty()) { + if (auto v = s.game().edition()) { + m_gameVariant = *v; + } + } + + if (m_baseDir.isEmpty()) { + m_baseDir = s.paths().base(); + } + + // figuring out profile from ini if it's missing + getProfile(s); + + return true; +} + +Instance::SetupResults Instance::setup(PluginContainer& plugins) +{ + // read initial values from the ini + log::debug("reading instance ini from '{}'", iniPath()); + if (!readFromIni()) { + log::error("readFromIni() failed for '{}'", iniPath()); + return SetupResults::BadIni; + } + log::debug("ini loaded: game='{}', dir='{}', variant='{}'", + m_gameName, m_gameDir, m_gameVariant); + + // getting game plugin + log::debug("searching for matching game plugin..."); + const auto r = getGamePlugin(plugins); + if (r != SetupResults::Okay) { + log::error("getGamePlugin() failed with result {}", static_cast(r)); + return r; + } + log::debug("matched game plugin '{}'", m_plugin->gameName()); + + // error if the variant missing and required by the plugin + if (m_gameVariant.isEmpty() && m_plugin->gameVariants().size() > 1) { + return SetupResults::MissingVariant; + } else { + m_plugin->setGameVariant(m_gameVariant); + } + + // update the ini in case anything was missing + log::debug("updating ini"); + updateIni(); + + // the game directory may be different than what the plugin detected, the user + // can change it in the settings and might have multiple versions of the game + // installed + log::debug("setting game path to '{}'", m_gameDir); + m_plugin->setGamePath(m_gameDir); + + return SetupResults::Okay; +} + +void Instance::updateIni() +{ + Settings s(iniPath()); + + if (s.iniStatus() != QSettings::NoError) { + log::error("can't open ini {}", iniPath()); + return; + } + + // updating the settings since some of these values might have been missing + s.game().setName(m_gameName); + s.game().setDirectory(m_gameDir); + s.game().setSelectedProfileName(m_profile); + + if (!m_gameVariant.isEmpty()) { + // don't write a variant to the ini if the plugin doesn't require one + s.game().setEdition(m_gameVariant); + } +} + +void Instance::setGame(const QString& name, const QString& dir) +{ + m_gameName = name; + m_gameDir = dir; +} + +void Instance::setVariant(const QString& name) +{ + m_gameVariant = name; +} + +Instance::SetupResults Instance::getGamePlugin(PluginContainer& plugins) +{ + if (!m_gameName.isEmpty() && !m_gameDir.isEmpty()) { + // normal case: both the name and dir are in the ini + + // find the plugin by name + for (IPluginGame* game : plugins.plugins()) { + log::debug(" checking game plugin '{}'...", game->gameName()); + if (m_gameName.compare(game->gameName(), Qt::CaseInsensitive) == 0) { + // plugin found, check if the game directory is valid + log::debug(" name matched, calling looksValid('{}')...", m_gameDir); + if (!game->looksValid(m_gameDir)) { + // the directory from the ini is not valid anymore + log::warn("game plugin {} says dir {} from ini {} is not valid", + game->gameName(), m_gameDir, iniPath()); + + // note that some plugins return true for isInstalled() if a path + // is found in the registry, but without actually checking if it's + // valid + + log::debug(" calling isInstalled()..."); + if (game->isInstalled() && game->looksValid(game->gameDirectory())) { + // bad game directory but the plugin reports there's a valid one + // somewhere; take it instead + log::warn("game plugin {} found a game at {}, taking it", game->gameName(), + game->gameDirectory().absolutePath()); + + m_gameDir = game->gameDirectory().absolutePath(); + } else { + // game seems to be gone completely + log::warn("game plugin {} found no game installation at all", + game->gameName()); + return SetupResults::GameGone; + } + } + + m_plugin = game; + return SetupResults::Okay; + } + } + + log::warn("game plugin {} not found", m_gameName); + return SetupResults::PluginGone; + } else if (m_gameName.isEmpty() && !m_gameDir.isEmpty()) { + // the name is missing, but there's a directory; find a plugin that can + // handle it + + log::warn("game name is missing from ini {} but dir {} is available", iniPath(), + m_gameDir); + + for (IPluginGame* game : plugins.plugins()) { + log::info(" checking plugin '{}' with looksValid('{}')...", game->gameName(), m_gameDir); + if (game->looksValid(m_gameDir)) { + // take it + log::warn("found plugin {} that can use dir {}", game->gameName(), m_gameDir); + + m_plugin = game; + m_gameName = game->gameName(); + + return SetupResults::Okay; + } + } + + log::error("no plugins can use dir {}", m_gameDir); + return SetupResults::GameGone; + } else if (!m_gameName.isEmpty() && m_gameDir.isEmpty()) { + // dir is missing, find a plugin with the correct name and use the install + // dir it detected + + log::warn("game dir is missing from ini {} but name {} is available", iniPath(), + m_gameName); + + for (IPluginGame* game : plugins.plugins()) { + if (m_gameName.compare(game->gameName(), Qt::CaseInsensitive) == 0) { + // plugin found, use its detected installation dir + + if (game->isInstalled()) { + log::warn("found plugin {} that matches name in ini {}, using auto detected " + "game dir {}", + game->gameName(), iniPath(), game->gameDirectory().absolutePath()); + + m_plugin = game; + m_gameDir = game->gameDirectory().absolutePath(); + + return SetupResults::Okay; + } else { + log::warn("found plugin {} that matches name in ini {}, but no game install " + "detected by plugin", + game->gameName(), iniPath()); + + return SetupResults::GameGone; + } + } + } + + // plugin seems to be gone + log::error("no plugin matches name {}", m_gameName); + return SetupResults::PluginGone; + } else { + // can't do anything with these two missing + log::error("both game name and dir are missing from ini {}", iniPath()); + return SetupResults::IniMissingGame; + } +} + +void Instance::getProfile(const Settings& s) +{ + if (!m_profile.isEmpty()) { + // there's already a profile set up, probably an override + return; + } + + if (auto name = s.game().selectedProfileName()) { + // use last profile + m_profile = *name; + return; + } + + // profile missing from ini, use the default + m_profile = QString::fromStdWString(AppConfig::defaultProfileName()); + + log::warn("no profile found in ini {}, using default '{}'", iniPath(), m_profile); +} + +// returns a list of files and folders that must be deleted when deleting +// this instance +// +std::vector Instance::objectsForDeletion() const +{ + // native separators and ending slash + auto prettyDir = [](auto s) { + if (!s.endsWith("/") || !s.endsWith("\\")) { + s += "/"; + } + + return QDir::toNativeSeparators(s); + }; + + // native separators + auto prettyFile = [](auto s) { + return QDir::toNativeSeparators(s); + }; + + // lowercase, native separators and ending slash + auto canonicalDir = [](QString s) { + s = s.toLower(); + + if (!s.endsWith("/") || !s.endsWith("\\")) { + s += "/"; + } + + return QDir::toNativeSeparators(s); + }; + + // lower and native separators + auto canonicalFile = [](auto s) { + return QDir::toNativeSeparators(s.toLower()); + }; + + // whether the given directory is contained in the root + auto dirInRoot = [&](const QString& root, const QString& dir) { + return canonicalDir(dir).startsWith(canonicalDir(root)); + }; + + // whether the given file is contained in the root + auto fileInRoot = [&](const QString& root, const QString& file) { + return canonicalFile(file).startsWith(canonicalDir(root)); + }; + + Settings settings(iniPath()); + + if (settings.iniStatus() != QSettings::NoError) { + log::error("can't read ini {}", iniPath()); + return {}; + } + + const QString loc = directory(); + const QString base = settings.paths().base(); + + // directories that might contain the individual files and directories + // set in the path settings + std::vector roots; + + // a portable instance has its location in the installation directory, + // don't delete that + if (!isPortable()) { + if (QDir(loc).exists()) { + roots.emplace_back(loc, true); + } + } + + // the base directory is the location directory by default, don't add it + // if it's the same + if (canonicalDir(base) != canonicalDir(loc)) { + if (QDir(base).exists()) { + roots.emplace_back(base, false); + } + } + + // all the directories that are part of an instance; none of them are + // mandatory for deletion + const std::vector dirs = { + settings.paths().downloads(), + settings.paths().mods(), + settings.paths().cache(), + settings.paths().profiles(), + settings.paths().overwrite(), + QDir(m_dir).filePath(QString::fromStdWString(AppConfig::dumpsDir())), + QDir(m_dir).filePath(QString::fromStdWString(AppConfig::logPath())), + }; + + // all the files that are part of an instance + const std::vector files = { + {iniPath(), true}, // the ini file must be deleted + }; + + // this will contain the root directories, plus all the individual + // directories that are not inside these roots + std::vector cleanDirs; + + for (const auto& f : dirs) { + bool inRoots = false; + + for (const auto& root : roots) { + if (dirInRoot(root.path, f.path)) { + inRoots = true; + break; + } + } + + if (!inRoots) { + // not in roots, this is a path that was changed by the user; make + // sure it exists + if (QDir(f.path).exists()) { + cleanDirs.emplace_back(prettyDir(f.path), f.mandatoryDelete); + } + } + } + + // prepending the roots + for (auto itor = roots.rbegin(); itor != roots.rend(); ++itor) { + cleanDirs.insert(cleanDirs.begin(), {prettyDir(itor->path), itor->mandatoryDelete}); + } + + // this will contain the individual files that are not inside the roots; + // not that this only contains the INI file for now, so most of this is + // useless + std::vector cleanFiles; + + for (const auto& f : files) { + bool inRoots = false; + + for (const auto& root : roots) { + if (fileInRoot(root.path, f.path)) { + inRoots = true; + break; + } + } + + if (!inRoots) { + // not in roots, this is a path that was changed by the user; make + // sure it exists + if (QFileInfo(f.path).exists()) { + cleanFiles.emplace_back(prettyFile(f.path), f.mandatoryDelete); + } + } + } + + // contains all the directories and files to be deleted + std::vector all; + all.insert(all.end(), cleanDirs.begin(), cleanDirs.end()); + all.insert(all.end(), cleanFiles.begin(), cleanFiles.end()); + + // mandatory on top + std::stable_sort(all.begin(), all.end()); + + return all; +} + +InstanceManager::InstanceManager() +{ + GlobalSettings::updateRegistryKey(); +} + +InstanceManager& InstanceManager::singleton() +{ + static InstanceManager s_Instance; + return s_Instance; +} + +void InstanceManager::overrideInstance(const QString& instanceName) +{ + m_overrideInstanceName = instanceName; +} + +void InstanceManager::overrideProfile(const QString& profileName) +{ + m_overrideProfileName = profileName; +} + +void InstanceManager::clearOverrides() +{ + m_overrideInstanceName = {}; + m_overrideProfileName = {}; +} + +std::unique_ptr InstanceManager::currentInstance() const +{ + const QString profile = m_overrideProfileName ? *m_overrideProfileName : ""; + + const bool hasPortable = portableInstanceExists(); + + const QString name = m_overrideInstanceName ? *m_overrideInstanceName + : GlobalSettings::currentInstance(); + + if (!allowedToChangeInstance()) { + // force portable instance + return std::make_unique(portablePath(), true, profile); + } + + if (hasPortable && !m_overrideInstanceName.has_value() && name.isEmpty()) { + // Prefer a colocated ModOrganizer.ini for portable bundles only when + // no instance has been explicitly selected by the user. + log::debug("portable instance detected at '{}', no stored instance, using portable", + portablePath()); + return std::make_unique(portablePath(), true, profile); + } + + if (name.isEmpty()) { + if (hasPortable) { + // use portable + return std::make_unique(portablePath(), true, profile); + } else { + // no instance set + return {}; + } + } + + // If the instance name is an absolute path to a directory containing + // ModOrganizer.ini, treat it as a portable instance at that path. + if (QDir::isAbsolutePath(name)) { + const QString iniPath = QDir(name).filePath("ModOrganizer.ini"); + if (QFileInfo::exists(iniPath)) { + return std::make_unique(name, true, profile); + } + } + + return std::make_unique(instancePath(name), false, profile); +} + +void InstanceManager::clearCurrentInstance() +{ + setCurrentInstance(""); + m_overrideInstanceName = {}; +} + +void InstanceManager::setCurrentInstance(const QString& name) +{ + GlobalSettings::setCurrentInstance(name); +} + +QString InstanceManager::instancePath(const QString& instanceName) const +{ + if (QDir::isAbsolutePath(instanceName)) { + return QDir::fromNativeSeparators(instanceName); + } + return QDir::fromNativeSeparators(globalInstancesRootPath() + "/" + instanceName); +} + +QString InstanceManager::globalInstancesRootPath() +{ + // Use the shared Fluorine data dir so the path is the same in native and + // Flatpak builds (QStandardPaths is remapped inside a Flatpak sandbox). + return QDir::fromNativeSeparators(fluorineDataDir()); +} + +QString InstanceManager::iniPath(const QString& instanceDir) +{ + return QDir(instanceDir).filePath(QString::fromStdWString(AppConfig::iniFileName())); +} + +std::vector InstanceManager::globalInstancePaths() const +{ + const QDir root(globalInstancesRootPath()); + const auto dirs = root.entryList(QDir::Dirs | QDir::NoDotAndDotDot); + + std::vector list; + + for (auto&& d : dirs) { + const QFileInfo iniFile(QDir(root.filePath(d)), "ModOrganizer.ini"); + log::debug("Checking for INI at path '{}'", iniFile.absoluteFilePath()); + + if (iniFile.exists()) { + log::debug("Found INI at path '{}'", iniFile.absoluteFilePath()); + list.push_back(root.filePath(d)); + } + } + + return list; +} + +bool InstanceManager::hasAnyInstances() const +{ + return portableInstanceExists() || !globalInstancePaths().empty(); +} + +QString InstanceManager::portablePath() +{ + return AppConfig::basePath(); +} + +bool InstanceManager::portableInstanceExists() +{ + return QFile::exists(AppConfig::basePath() + "/" + + QString::fromStdWString(AppConfig::iniFileName())); +} + +bool InstanceManager::allowedToChangeInstance() +{ + const auto lockFile = qApp->applicationDirPath() + "/" + + QString::fromStdWString(AppConfig::portableLockFileName()); + + return !QFile::exists(lockFile); +} + +MOBase::IPluginGame* +InstanceManager::gamePluginForDirectory(const QString& instanceDir, + PluginContainer& plugins) const +{ + return const_cast( + gamePluginForDirectory(instanceDir, const_cast(plugins))); +} + +const MOBase::IPluginGame* +InstanceManager::gamePluginForDirectory(const QString& instanceDir, + const PluginContainer& plugins) const +{ + const QString ini = iniPath(instanceDir); + + // reading ini + Settings s(ini); + + if (s.iniStatus() != QSettings::NoError) { + log::error("failed to load settings from {}", ini); + return nullptr; + } + + // using game name from ini, if available + const auto instanceGameName = s.game().name(); + + if (instanceGameName && !instanceGameName->isEmpty()) { + for (const IPluginGame* game : plugins.plugins()) { + if (instanceGameName->compare(game->gameName(), Qt::CaseInsensitive) == 0) { + return game; + } + } + + log::error("no plugin has game name '{}' that was found in ini {}", + *instanceGameName, ini); + } else { + log::error("no game name found in ini {}", ini); + } + + // using game directory from ini, if available + const auto gameDir = s.game().directory(); + + if (gameDir && !gameDir->isEmpty()) { + for (const IPluginGame* game : plugins.plugins()) { + if (game->looksValid(*gameDir)) { + return game; + } + } + + log::error("no plugin appears to support game directory '{}' from ini {}", *gameDir, + ini); + } else { + log::error("no game directory found in ini {}", ini); + } + + // looking for a plugin that can handle the directory + log::debug("falling back on looksValid check"); + + for (const IPluginGame* game : plugins.plugins()) { + if (game->looksValid(instanceDir)) { + return game; + } + } + + return nullptr; +} + +QString InstanceManager::makeUniqueName(const QString& instanceName) const +{ + const QString sanitized = MOBase::sanitizeFileName(instanceName); + + // trying "name (N)" + QString name = sanitized; + for (int i = 2; i < 100; ++i) { + if (!instanceExists(name)) { + return name; + } + + name = QString("%1 (%2)").arg(sanitized).arg(i); + } + + return {}; +} + +bool InstanceManager::instanceExists(const QString& instanceName) const +{ + const QDir root = globalInstancesRootPath(); + return root.exists(instanceName); +} + +QStringList InstanceManager::registeredPortablePaths() +{ + return GlobalSettings::portableInstances(); +} + +void InstanceManager::registerPortableInstance(const QString& path) +{ + GlobalSettings::addPortableInstance(path); +} + +void InstanceManager::unregisterPortableInstance(const QString& path) +{ + GlobalSettings::removePortableInstance(path); +} + +std::unique_ptr selectInstance() +{ + auto& m = InstanceManager::singleton(); + + // since there is no instance currently active, load plugins with a null + // OrganizerCore; see PluginContainer::initPlugin() + NexusInterface const ni(nullptr); + PluginContainer pc(nullptr); + pc.loadPlugins(); + + { + // show the instance manager dialog; it has both "Create new instance" and + // "Open existing portable" buttons, so it works whether or not instances + // already exist + InstanceManagerDialog dlg(pc); + + // the dialog normally restarts MO when an instance is selected, but this + // is not necessary here since MO hasn't really started yet + dlg.setRestartOnSelect(false); + + dlg.show(); + dlg.activateWindow(); + dlg.raise(); + + if (dlg.exec() != QDialog::Accepted) { + return {}; + } + } + + // return the new instance or the selection + return m.currentInstance(); +} + +// shows the game selection page of the create instance dialog so the user can +// pick which game is managed by this instance +// +// this is used below in setupInstance() when the game directory is gone or +// no plugins can recognize it +// +SetupInstanceResults selectGame(Instance& instance, PluginContainer& pc) +{ + CreateInstanceDialog dlg(pc, nullptr); + + // only show the game page + dlg.setSinglePage(instance.displayName()); + + dlg.show(); + dlg.activateWindow(); + dlg.raise(); + + if (dlg.exec() != QDialog::Accepted) { + // cancelled + return SetupInstanceResults::SelectAnother; + } + + // this info will be used instead of the ini, which should fix this + // particular problem + instance.setGame(dlg.creationInfo().game->gameName(), + dlg.creationInfo().gameLocation); + + return SetupInstanceResults::TryAgain; +} + +// shows the game variant page of the create instance dialog so the user can +// pick which game variant is installed +// +// this is used below in setupInstance() when there is no variant in the ini +// but the game plugin requires one; this can happen when the ini is broken +// or when a new variant has become supported by the plugin for a game the +// user already has an instance for +// +SetupInstanceResults selectVariant(Instance& instance, PluginContainer& pc) +{ + CreateInstanceDialog dlg(pc, nullptr); + + // the variant page uses the game page to know which game was selected, so + // set it manually + dlg.getPage()->select(instance.gamePlugin(), instance.gameDirectory()); + + // only show the variant page + dlg.setSinglePage(instance.displayName()); + + dlg.show(); + dlg.activateWindow(); + dlg.raise(); + + if (dlg.exec() != QDialog::Accepted) { + return SetupInstanceResults::SelectAnother; + } + + // this info will be used instead of the ini, which should fix this + // particular problem + instance.setVariant(dlg.creationInfo().gameVariant); + + return SetupInstanceResults::TryAgain; +} + +SetupInstanceResults setupInstance(Instance& instance, PluginContainer& pc) +{ + // set up the instance + log::debug("setupInstance() called for '{}', ini='{}'", + instance.displayName(), instance.iniPath()); + const auto setupResult = instance.setup(pc); + log::debug("instance.setup() returned {}", static_cast(setupResult)); + + switch (setupResult) { + case Instance::SetupResults::Okay: { + // all good + return SetupInstanceResults::Okay; + } + + case Instance::SetupResults::BadIni: { + // unreadable ini, there's not much that can be done, select another + // instance + + reportError(QObject::tr("Cannot open instance '%1', failed to read INI file %2.") + .arg(instance.displayName()) + .arg(instance.iniPath())); + + return SetupInstanceResults::SelectAnother; + } + + case Instance::SetupResults::IniMissingGame: { + // both the game name and directory are missing from the ini; although + // this shouldn't happen, setup() is able to handle when either is + // missing, but not both + // + // ask the user for the game managed by this instance + + reportError( + QObject::tr( + "Cannot open instance '%1', the managed game was not found in the INI " + "file %2. Select the game managed by this instance.") + .arg(instance.displayName()) + .arg(instance.iniPath())); + + return selectGame(instance, pc); + } + + case Instance::SetupResults::PluginGone: { + // there is no plugin that can handle the game name/directory from the + // ini, so this instance is unusable + + reportError( + QObject::tr("Cannot open instance '%1', the game plugin '%2' doesn't exist. It " + "may have been deleted by an antivirus. Select another instance.") + .arg(instance.displayName()) + .arg(instance.gameName())); + + return SetupInstanceResults::SelectAnother; + } + + case Instance::SetupResults::GameGone: { + // the game directory doesn't exist or the plugin doesn't recognize it; + // ask the user for the game managed by this instance + + reportError( + QObject::tr( + "Cannot open instance '%1', the game directory '%2' doesn't exist or " + "the game plugin '%3' doesn't recognize it. Select the game managed " + "by this instance.") + .arg(instance.displayName()) + .arg(instance.gameDirectory()) + .arg(instance.gameName())); + + return selectGame(instance, pc); + } + + case Instance::SetupResults::MissingVariant: { + // the game variant is missing from the ini, ask the user for it + return selectVariant(instance, pc); + } + + default: { + // shouldn't happen + return SetupInstanceResults::Exit; + } + } +} diff --git a/src/src/instancemanagerdialog.cpp b/src/src/instancemanagerdialog.cpp index 12d21a6..b7a232e 100644 --- a/src/src/instancemanagerdialog.cpp +++ b/src/src/instancemanagerdialog.cpp @@ -1,1019 +1,1019 @@ -#include "instancemanagerdialog.h" -#include "createinstancedialog.h" -#include "filesystemutilities.h" -#include "instancemanager.h" -#include "plugincontainer.h" -#include "selectiondialog.h" -#include "settings.h" -#include "shared/appconfig.h" -#include "shared/util.h" -#include "ui_instancemanagerdialog.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include "slrmanager.h" -#include -#include - -using namespace MOBase; - -// returns the icon for the given instance or an empty 32x32 icon if the game -// plugin couldn't be found -// -QIcon instanceIcon(PluginContainer& pc, const Instance& i) -{ - auto* game = InstanceManager::singleton().gamePluginForDirectory(i.directory(), pc); - - if (!game) { - QPixmap empty(32, 32); - empty.fill(QColor(0, 0, 0, 0)); - return QIcon(empty); - } - - // it's possible to have the game installed in a way that the game plugin - // couldn't auto detect; in this case, the instance would have a valid game - // directory, but the plugin wouldn't know about it - // - // it's also possible, but unlikely, to have multiple installations of the - // same game that have different icons for the same exe - // - // so the game directory specified for the instance needs to be given to the - // game plugin to get the appropriate icon, but since these game plugin - // objects are created on startup and are global, they should retain their - // auto detected path - // - // if not, creating a new instance for a specific plugin would use the game - // directory of the instance for which the icon was most recently shown, which - // would be really inconsistent - // - // - // this game plugin could also be the currently active plugin for the - // current instance, which should _definitely_ keep pointing to the same - // directory as before - - // remember old game directory - // - // note that gameDirectory() returns a QDir, which doesn't support empty - // strings (they get converted to "." automatically!), but the plugin _will_ - // try to return an empty string when the game has not been auto-detected - // - // so gameDirectory() _cannot_ reliably be used if `isInstalled()` is false - const QString old = game->isInstalled() ? game->gameDirectory().path() : ""; - - // revert - Guard const g([&] { - game->setGamePath(old); - }); - - // set directory for this instance - game->setGamePath(i.gameDirectory()); - - return game->gameIcon(); -} - -// pops up a dialog to ask for an instance name when renaming -// -QString getInstanceName(QWidget* parent, const QString& title, const QString& moreText, - const QString& label, const QString& oldName = {}) -{ - auto& m = InstanceManager::singleton(); - - QDialog dlg(parent); - dlg.setWindowTitle(title); - - auto* ly = new QVBoxLayout(&dlg); - - auto* bb = new QDialogButtonBox(QDialogButtonBox::Cancel | QDialogButtonBox::Ok); - - auto* text = new QLineEdit(oldName); - text->selectAll(); - - auto* error = new QLabel; - - if (!moreText.isEmpty()) { - auto* lb = new QLabel(moreText); - lb->setWordWrap(true); - ly->addWidget(lb); - ly->addSpacing(10); - } - - auto* lb = new QLabel(label); - lb->setWordWrap(true); - ly->addWidget(lb); - - ly->addWidget(text); - ly->addWidget(error); - ly->addStretch(); - ly->addWidget(bb); - - auto check = [&] { - bool okay = false; - - if (text->text().isEmpty()) { - error->setText(""); - } else if (!MOBase::validFileName(text->text())) { - error->setText(QObject::tr("The instance name must be a valid folder name.")); - } else { - const auto name = MOBase::sanitizeFileName(text->text()); - - if ((name != oldName) && m.instanceExists(text->text())) { - error->setText(QObject::tr("An instance with this name already exists.")); - } else { - okay = true; - } - } - - error->setVisible(!okay); - bb->button(QDialogButtonBox::Ok)->setEnabled(okay); - }; - - QObject::connect(text, &QLineEdit::textChanged, [&] { - check(); - }); - QObject::connect(bb, &QDialogButtonBox::accepted, [&] { - dlg.accept(); - }); - QObject::connect(bb, &QDialogButtonBox::rejected, [&] { - dlg.reject(); - }); - - check(); - - dlg.resize({400, 120}); - if (dlg.exec() != QDialog::Accepted) { - return {}; - } - - return MOBase::sanitizeFileName(text->text()); -} - -InstanceManagerDialog::~InstanceManagerDialog() = default; - -InstanceManagerDialog::InstanceManagerDialog(PluginContainer& pc, QWidget* parent) - : QDialog(parent), ui(new Ui::InstanceManagerDialog), m_pc(pc) - -{ - ui->setupUi(this); - - ui->splitter->setSizes({250, 1}); - ui->splitter->setStretchFactor(0, 0); - ui->splitter->setStretchFactor(1, 1); - - m_model = new QStandardItemModel; - ui->list->setModel(m_model); - - m_filter.setEdit(ui->filter); - m_filter.setList(ui->list); - m_filter.setFilteredBorder(false); - - updateInstances(); - updateList(); - selectActiveInstance(); - - connect(ui->createNew, &QPushButton::clicked, [&] { - createNew(); - }); - connect(ui->openExisting, &QPushButton::clicked, [&] { - openExistingPortable(); - }); - - connect(ui->list->selectionModel(), &QItemSelectionModel::selectionChanged, [&] { - onSelection(); - }); - connect(ui->list, &QListView::activated, [&] { - openSelectedInstance(); - }); - - connect(ui->rename, &QPushButton::clicked, [&] { - rename(); - }); - connect(ui->exploreLocation, &QPushButton::clicked, [&] { - exploreLocation(); - }); - connect(ui->exploreBaseDirectory, &QPushButton::clicked, [&] { - exploreBaseDirectory(); - }); - connect(ui->exploreGame, &QPushButton::clicked, [&] { - exploreGame(); - }); - - connect(ui->convertToGlobal, &QPushButton::clicked, [&] { - convertToGlobal(); - }); - connect(ui->convertToPortable, &QPushButton::clicked, [&] { - convertToPortable(); - }); - connect(ui->openINI, &QPushButton::clicked, [&] { - openINI(); - }); - connect(ui->removeFromList, &QPushButton::clicked, [&] { - removeFromList(); - }); - connect(ui->deleteInstance, &QPushButton::clicked, [&] { - deleteInstance(); - }); - - connect(ui->steamDrmCheckBox, &QCheckBox::toggled, [&](bool checked) { - const auto* inst = singleSelection(); - if (!inst) return; - const QString ini = inst->iniPath(); - if (ini.isEmpty()) return; - QSettings s(ini, QSettings::IniFormat); - s.setValue("fluorine/steam_drm", checked); - }); - - connect(ui->steamLinuxRuntimeCheckBox, &QCheckBox::toggled, [&](bool checked) { - const auto* inst = singleSelection(); - if (!inst) return; - const QString ini = inst->iniPath(); - if (ini.isEmpty()) return; - QSettings s(ini, QSettings::IniFormat); - s.setValue("fluorine/use_slr", checked); - if (checked) { - downloadSLRIfNeeded(); - } - }); - - connect(ui->vfsRootBuilderCheckBox, &QCheckBox::toggled, [&](bool checked) { - const auto* inst = singleSelection(); - if (!inst) return; - const QString ini = inst->iniPath(); - if (ini.isEmpty()) return; - QSettings s(ini, QSettings::IniFormat); - s.setValue("fluorine/vfs_root_builder", checked); - }); - - connect(ui->steamOverlayCheckBox, &QCheckBox::toggled, [&](bool checked) { - const auto* inst = singleSelection(); - if (!inst) return; - const QString ini = inst->iniPath(); - if (ini.isEmpty()) return; - QSettings s(ini, QSettings::IniFormat); - s.setValue("fluorine/steam_overlay", checked); - }); - - connect(ui->switchToInstance, &QPushButton::clicked, [&] { - openSelectedInstance(); - }); - connect(ui->close, &QPushButton::clicked, [&] { - close(); - }); -} - -void InstanceManagerDialog::showEvent(QShowEvent* e) -{ - // there might not be a global Settings object if this is called on startup - // when there's no current instance - const auto* s = Settings::maybeInstance(); - - if (s) { - s->geometry().restoreGeometry(this); - } - - QDialog::showEvent(e); -} - -void InstanceManagerDialog::done(int r) -{ - // there might not be a global Settings object if this is called on startup - // when there's no current instance - auto* s = Settings::maybeInstance(); - - if (s) { - s->geometry().saveGeometry(this); - } - - QDialog::done(r); -} - -void InstanceManagerDialog::updateInstances() -{ - auto& m = InstanceManager::singleton(); - - m_instances.clear(); - - for (auto&& d : m.globalInstancePaths()) { - m_instances.push_back(std::make_unique(d, false)); - } - - // sort first, prepend portable after so it's always on top - std::sort(m_instances.begin(), m_instances.end(), [](auto&& a, auto&& b) { - return (MOBase::naturalCompare(a->displayName(), b->displayName()) < 0); - }); - - // add registered portable instances (non-default paths) - const QString defaultPortable = QDir(m.portablePath()).absolutePath(); - for (const auto& path : m.registeredPortablePaths()) { - // skip the default portable path (handled separately below) - if (QDir(path).absolutePath() == defaultPortable) { - continue; - } - // skip paths where ModOrganizer.ini no longer exists - if (!QFileInfo::exists(QDir(path).filePath("ModOrganizer.ini"))) { - continue; - } - m_instances.push_back(std::make_unique(path, true)); - } - - // re-sort to interleave registered portables alphabetically - std::sort(m_instances.begin(), m_instances.end(), [](auto&& a, auto&& b) { - return (MOBase::naturalCompare(a->displayName(), b->displayName()) < 0); - }); - - if (m.portableInstanceExists()) { - m_instances.insert(m_instances.begin(), - std::make_unique(m.portablePath(), true)); - } - - // read all inis, ignore errors - for (auto&& i : m_instances) { - i->readFromIni(); - } -} - -void InstanceManagerDialog::updateList() -{ - const auto prevSelIndex = singleSelectionIndex(); - const auto* prevSel = singleSelection(); - - m_model->clear(); - - std::size_t sel = NoSelection; - - // creating items for instances - for (std::size_t i = 0; i < m_instances.size(); ++i) { - const auto& ii = *m_instances[i]; - - auto* item = new QStandardItem(ii.displayName()); - item->setIcon(instanceIcon(m_pc, ii)); - - m_model->appendRow(item); - - if (&ii == prevSel) { - sel = i; - } - } - - // keep current selection or select the next one if there was a selection; - // there's no selection when opening the dialog, that's handled in the ctor - if (prevSel) { - if (m_instances.empty()) { - select(-1); - } else { - if (sel == NoSelection) { - if (prevSelIndex >= m_instances.size()) { - sel = m_instances.size() - 1; - } else { - sel = prevSelIndex; - } - } - - select(sel); - } - } -} - -void InstanceManagerDialog::select(std::size_t i) -{ - if (i < m_instances.size()) { - const auto& ii = m_instances[i]; - fillData(*ii); - - ui->list->selectionModel()->select( - m_filter.mapFromSource(m_filter.sourceModel()->index(i, 0)), - QItemSelectionModel::ClearAndSelect); - } else { - clearData(); - } -} - -void InstanceManagerDialog::select(const QString& name) -{ - for (std::size_t i = 0; i < m_instances.size(); ++i) { - if (m_instances[i]->displayName() == name) { - select(i); - return; - } - } - - log::error("can't select instance {}, not in list", name); -} - -void InstanceManagerDialog::selectActiveInstance() -{ - const auto active = InstanceManager::singleton().currentInstance(); - - if (active) { - const QString activeDir = QDir(active->directory()).absolutePath(); - for (std::size_t i = 0; i < m_instances.size(); ++i) { - if (QDir(m_instances[i]->directory()).absolutePath() == activeDir) { - select(i); - - ui->list->scrollTo(m_filter.mapFromSource(m_filter.sourceModel()->index(i, 0))); - - return; - } - } - } - - select(0); -} - -void InstanceManagerDialog::openSelectedInstance() -{ - const auto i = singleSelectionIndex(); - if (i == NoSelection) { - return; - } - - const auto& to = *m_instances[i]; - - if (!confirmSwitch(to)) { - return; - } - - if (to.isPortable()) { - // Store the actual directory for portable instances so we can distinguish - // between the default portable path and user-selected portable locations. - // An empty string means "use default portable path". - auto& m = InstanceManager::singleton(); - if (to.directory() == m.portablePath()) { - m.setCurrentInstance(""); - } else { - m.setCurrentInstance(to.directory()); - } - } else { - InstanceManager::singleton().setCurrentInstance(to.displayName()); - } - - if (m_restartOnSelect) { - ExitModOrganizer(Exit::Restart); - } - - accept(); -} - -bool InstanceManagerDialog::confirmSwitch(const Instance& to) -{ - // there might not be a global Settings object if this is called on startup - // when there's no current instance - const auto* s = Settings::maybeInstance(); - - // if there is are no settings, no instances are loaded and the confirmation - // wouldn't make sense - if (!s) { - return true; - } - - if (!s->interface().showChangeGameConfirmation()) { - // user disabled confirmation - return true; - } - - MOBase::TaskDialog dlg(this); - - const auto r = dlg.title(tr("Switching instances")) - .main(tr("Mod Organizer must restart to manage the instance '%1'.") - .arg(to.displayName())) - .content(tr("This confirmation can be disabled in the settings.")) - .icon(QMessageBox::Question) - .button({tr("Restart Mod Organizer"), QMessageBox::Ok}) - .button({tr("Cancel"), QMessageBox::Cancel}) - .exec(); - - return (r == QMessageBox::Ok); -} - -void InstanceManagerDialog::rename() -{ - auto* i = singleSelection(); - if (!i) { - return; - } - - const auto selIndex = singleSelectionIndex(); - - auto& m = InstanceManager::singleton(); - if (i->isActive()) { - QMessageBox::information(this, tr("Rename instance"), - tr("The active instance cannot be renamed.")); - return; - } - - // getting new name - const auto newName = getInstanceName(this, tr("Rename instance"), "", - tr("Instance name"), i->displayName()); - - if (newName.isEmpty()) { - return; - } - - // renaming - const QString src = i->directory(); - const QString dest = - QDir::toNativeSeparators(QFileInfo(src).dir().path() + "/" + newName); - - log::info("renaming {} to {}", src, dest); - - const auto r = shell::Rename(QFileInfo(src), QFileInfo(dest), false); - - if (!r) { - QMessageBox::critical(this, tr("Error"), - tr(R"(Failed to rename "%1" to "%2": %3)") - .arg(src) - .arg(dest) - .arg(r.toString())); - - return; - } - - // updating ui - auto newInstance = std::make_unique(dest, false); - i = newInstance.get(); - - m_model->item(selIndex)->setText(newName); - m_instances[selIndex] = std::move(newInstance); - - fillData(*i); -} - -void InstanceManagerDialog::exploreLocation() -{ - if (const auto* i = singleSelection()) { - shell::Explore(i->directory()); - } -} - -void InstanceManagerDialog::exploreBaseDirectory() -{ - if (const auto* i = singleSelection()) { - shell::Explore(i->baseDirectory()); - } -} - -void InstanceManagerDialog::exploreGame() -{ - if (const auto* i = singleSelection()) { - shell::Explore(i->gameDirectory()); - } -} - -void InstanceManagerDialog::openINI() -{ - if (const auto* i = singleSelection()) { - shell::Open(i->iniPath()); - } -} - -void InstanceManagerDialog::removeFromList() -{ - const auto* i = singleSelection(); - if (!i) { - return; - } - - auto& m = InstanceManager::singleton(); - if (i->isActive()) { - QMessageBox::information(this, tr("Remove from list"), - tr("The active instance cannot be removed.")); - return; - } - - const auto r = QMessageBox::question( - this, tr("Remove from list"), - tr("Remove \"%1\" from the instance list?\n\n" - "No files will be deleted.") - .arg(i->displayName()), - QMessageBox::Yes | QMessageBox::Cancel); - - if (r != QMessageBox::Yes) { - return; - } - - if (i->isPortable()) { - m.unregisterPortableInstance(i->directory()); - } else { - // for global instances, rename the INI so it's no longer auto-discovered - const QString ini = i->iniPath(); - if (!ini.isEmpty() && QFile::exists(ini)) { - QFile::rename(ini, ini + ".disabled"); - } - } - - updateInstances(); - updateList(); -} - -void InstanceManagerDialog::deleteInstance() -{ - const auto* i = singleSelection(); - if (!i) { - return; - } - - auto& m = InstanceManager::singleton(); - if (i->isActive()) { - QMessageBox::information(this, tr("Deleting instance"), - tr("The active instance cannot be deleted.")); - return; - } - - // creating dialog - - const auto Delete = QMessageBox::Yes; - const auto Cancel = QMessageBox::Cancel; - - const auto files = i->objectsForDeletion(); - - MOBase::TaskDialog dlg(this); - - dlg.title(tr("Deleting instance")) - .main(tr("These files and folders will be permanently deleted")) - .content(tr("All checked items will be deleted.")) - .icon(QMessageBox::Warning) - .button({tr("Delete permanently"), Delete}) - .button({tr("Cancel"), Cancel}); - - auto* list = new QListWidget(); - list->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOn); - list->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); - list->setMaximumHeight(160); - - // filling the list - for (const auto& f : files) { - auto* item = new QListWidgetItem(f.path); - - if (f.mandatoryDelete) { - // disable, cannot uncheck mandatory items - item->setFlags(item->flags() & (~Qt::ItemIsEnabled)); - - // checked by default - item->setCheckState(Qt::Checked); - } else { - item->setFlags(item->flags() | Qt::ItemIsUserCheckable); - - // unchecked by default - item->setCheckState(Qt::Unchecked); - } - - list->addItem(item); - } - - dlg.addContent(list); - dlg.setWidth(600); - - const auto r = dlg.exec(); - - if (r != Delete) { - return; - } - - // gathering all the selected items - QStringList selected; - - for (int i = 0; i < list->count(); ++i) { - if (list->item(i)->checkState() == Qt::Checked) { - selected.append(list->item(i)->text()); - } - } - - if (selected.isEmpty()) { - QMessageBox::information(this, tr("Deleting instance"), tr("Nothing to delete.")); - - return; - } - - // deleting - if (!doDelete(selected, false)) { - return; - } - - // unregister portable instance from the persistent list - if (i->isPortable()) { - InstanceManager::singleton().unregisterPortableInstance(i->directory()); - } - - // updating ui - updateInstances(); - updateList(); -} - -void InstanceManagerDialog::setRestartOnSelect(bool b) -{ - m_restartOnSelect = b; -} - -bool InstanceManagerDialog::doDelete(const QStringList& files, bool recycle) -{ - // logging - for (auto&& f : files) { - if (recycle) { - log::info("will recycle {}", f); - } else { - log::info("will delete {}", f); - } - } - - if (MOBase::shellDelete(files, recycle, this)) { - return true; - } - - const auto e = GetLastError(); - if (e == ERROR_CANCELLED) { - log::debug("deletion cancelled by user"); - } else { - log::error("failed to delete, {}", formatSystemMessage(e)); - } - - return false; -} - -void InstanceManagerDialog::convertToGlobal() -{ - // not implemented -} - -void InstanceManagerDialog::convertToPortable() -{ - // not implemented -} - -void InstanceManagerDialog::onSelection() -{ - const auto i = singleSelectionIndex(); - if (i == NoSelection) { - return; - } - - select(i); -} - -void InstanceManagerDialog::createNew() -{ - // there might not be settings available; the dialog can be shown when the - // last selected instance doesn't exist anymore - CreateInstanceDialog dlg(m_pc, Settings::maybeInstance(), this); - - if (dlg.exec() != QDialog::Accepted) { - return; - } - - if (dlg.switching()) { - // restarting MO - accept(); - return; - } - - updateInstances(); - updateList(); - - select(dlg.creationInfo().instanceName); -} - -std::size_t InstanceManagerDialog::singleSelectionIndex() const -{ - const auto sel = - m_filter.mapSelectionToSource(ui->list->selectionModel()->selection()); - - if (sel.size() != 1) { - return NoSelection; - } - - const auto indexes = sel.indexes(); - if (indexes.size() != 1 || !indexes[0].isValid()) { - return NoSelection; - } - - const int row = indexes[0].row(); - if (row < 0 || static_cast(row) >= m_instances.size()) { - return NoSelection; - } - - return static_cast(row); -} - -const Instance* InstanceManagerDialog::singleSelection() const -{ - const auto i = singleSelectionIndex(); - if (i == NoSelection || i >= m_instances.size()) { - return nullptr; - } - - return m_instances[i].get(); -} - -void InstanceManagerDialog::fillData(const Instance& ii) -{ - ui->name->setText(ii.displayName()); - ui->location->setText(ii.directory()); - ui->baseDirectory->setText(ii.baseDirectory()); - ui->gameName->setText(ii.gameName()); - ui->gameDir->setText(ii.gameDirectory()); - - // read prefix info and fluorine settings from the instance's INI - { - const QString ini = ii.iniPath(); - if (!ini.isEmpty() && QFile::exists(ini)) { - QSettings const s(ini, QSettings::IniFormat); - ui->prefixPath->setText(s.value("Settings/proton_prefix_path").toString()); - ui->protonVersion->setText(s.value("fluorine/proton_name").toString()); - - ui->steamDrmCheckBox->blockSignals(true); - ui->steamDrmCheckBox->setChecked(s.value("fluorine/steam_drm", true).toBool()); - ui->steamDrmCheckBox->blockSignals(false); - - ui->steamLinuxRuntimeCheckBox->blockSignals(true); - ui->steamLinuxRuntimeCheckBox->setChecked(s.value("fluorine/use_slr", true).toBool()); - ui->steamLinuxRuntimeCheckBox->blockSignals(false); - - ui->vfsRootBuilderCheckBox->blockSignals(true); - ui->vfsRootBuilderCheckBox->setChecked(s.value("fluorine/vfs_root_builder", true).toBool()); - ui->vfsRootBuilderCheckBox->blockSignals(false); - - ui->steamOverlayCheckBox->blockSignals(true); - ui->steamOverlayCheckBox->setChecked(s.value("fluorine/steam_overlay", false).toBool()); - ui->steamOverlayCheckBox->blockSignals(false); - } else { - ui->prefixPath->clear(); - ui->protonVersion->clear(); - ui->steamDrmCheckBox->blockSignals(true); - ui->steamDrmCheckBox->setChecked(false); - ui->steamDrmCheckBox->blockSignals(false); - ui->steamLinuxRuntimeCheckBox->blockSignals(true); - ui->steamLinuxRuntimeCheckBox->setChecked(true); - ui->steamLinuxRuntimeCheckBox->blockSignals(false); - ui->vfsRootBuilderCheckBox->blockSignals(true); - ui->vfsRootBuilderCheckBox->setChecked(false); - ui->vfsRootBuilderCheckBox->blockSignals(false); - ui->steamOverlayCheckBox->blockSignals(true); - ui->steamOverlayCheckBox->setChecked(false); - ui->steamOverlayCheckBox->blockSignals(false); - } - } - - setButtonsEnabled(true); - - const auto& m = InstanceManager::singleton(); - - ui->rename->setEnabled(!ii.isPortable()); - - if (ii.isPortable()) { - ui->convertToPortable->setVisible(false); - ui->convertToGlobal->setVisible(true); - ui->convertToGlobal->setEnabled(true); - } else { - ui->convertToPortable->setVisible(true); - ui->convertToGlobal->setVisible(false); - - if (m.portableInstanceExists()) { - ui->convertToPortable->setEnabled(false); - ui->convertToPortable->setToolTip(tr("A portable instance already exists.")); - } else { - ui->convertToPortable->setEnabled(false); - ui->convertToPortable->setToolTip(""); - } - } - - // not implemented, hide the buttons - ui->convertToPortable->setVisible(false); - ui->convertToGlobal->setVisible(false); -} - -void InstanceManagerDialog::clearData() -{ - ui->name->clear(); - ui->location->clear(); - ui->baseDirectory->clear(); - ui->gameName->clear(); - ui->gameDir->clear(); - ui->prefixPath->clear(); - ui->protonVersion->clear(); - ui->steamDrmCheckBox->blockSignals(true); - ui->steamDrmCheckBox->setChecked(false); - ui->steamDrmCheckBox->blockSignals(false); - - setButtonsEnabled(false); - - ui->convertToPortable->setVisible(false); - ui->convertToGlobal->setVisible(false); -} - -void InstanceManagerDialog::setButtonsEnabled(bool b) -{ - ui->rename->setEnabled(b); - ui->exploreLocation->setEnabled(b); - ui->exploreBaseDirectory->setEnabled(b); - ui->exploreGame->setEnabled(b); - ui->convertToPortable->setEnabled(b); - ui->convertToGlobal->setEnabled(b); - ui->removeFromList->setEnabled(b); - ui->deleteInstance->setEnabled(b); - ui->switchToInstance->setEnabled(b); -} - -void InstanceManagerDialog::openExistingPortable() -{ - // On Flatpak, the native file dialog goes through the XDG Desktop Portal, - const QString dir = QFileDialog::getExistingDirectory( - this, tr("Select portable instance folder"), - QStandardPaths::writableLocation(QStandardPaths::HomeLocation)); - - if (dir.isEmpty()) { - return; - } - - const QString ini = QDir(dir).filePath("ModOrganizer.ini"); - if (!QFileInfo::exists(ini)) { - QMessageBox::warning( - this, tr("Not an instance"), - tr("The selected folder does not contain a ModOrganizer.ini file.")); - return; - } - - // Register the portable instance so it persists in the sidebar - auto& m = InstanceManager::singleton(); - m.registerPortableInstance(dir); - - // Refresh the instance list and select the newly added entry - updateInstances(); - updateList(); - - // Find and select the new instance by directory - const QString canonical = QDir(dir).absolutePath(); - for (std::size_t i = 0; i < m_instances.size(); ++i) { - if (QDir(m_instances[i]->directory()).absolutePath() == canonical) { - select(i); - break; - } - } -} - -void InstanceManagerDialog::downloadSLRIfNeeded() -{ - if (isSlrInstalled()) { - return; - } - - auto* progress = new QProgressDialog( - tr("Downloading Steam Linux Runtime (~200 MB)...\n" - "This only happens once. Check the MO2 log for details."), - tr("Cancel"), 0, 0, this); // 0,0 = indeterminate - progress->setWindowTitle(tr("Steam Linux Runtime")); - progress->setWindowModality(Qt::WindowModal); - progress->setAttribute(Qt::WA_ShowWithoutActivating); - progress->setMinimumDuration(0); - - auto* cancelFlag = new int(0); - - connect(progress, &QProgressDialog::canceled, this, [cancelFlag] { - *cancelFlag = 1; - }); - - auto* watcher = new QFutureWatcher(this); - - connect(watcher, &QFutureWatcher::finished, this, - [this, watcher, progress, cancelFlag] { - progress->close(); - watcher->deleteLater(); - progress->deleteLater(); - - const QString err = watcher->result(); - if (!err.isEmpty()) { - MOBase::log::error("[SLR] Download failed: {}", err); - QMessageBox::warning(this, tr("Steam Linux Runtime"), - tr("Download failed:\n%1\n\nSLR has been disabled for this instance.") - .arg(err)); - ui->steamLinuxRuntimeCheckBox->blockSignals(true); - ui->steamLinuxRuntimeCheckBox->setChecked(false); - ui->steamLinuxRuntimeCheckBox->blockSignals(false); - const auto* inst = singleSelection(); - if (inst) { - const QString ini = inst->iniPath(); - if (!ini.isEmpty()) { - QSettings s(ini, QSettings::IniFormat); - s.setValue("fluorine/use_slr", false); - } - } - } else { - MOBase::log::info("[SLR] Steam Linux Runtime installed successfully"); - progress->setLabelText(tr("Steam Linux Runtime is ready.")); - } - delete cancelFlag; - }); - - int* cancelPtr = cancelFlag; - watcher->setFuture(QtConcurrent::run([cancelPtr]() -> QString { - return downloadSlr(nullptr, nullptr, cancelPtr); - })); - - progress->show(); -} +#include "instancemanagerdialog.h" +#include "createinstancedialog.h" +#include "filesystemutilities.h" +#include "instancemanager.h" +#include "plugincontainer.h" +#include "selectiondialog.h" +#include "settings.h" +#include "shared/appconfig.h" +#include "shared/util.h" +#include "ui_instancemanagerdialog.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "slrmanager.h" +#include +#include + +using namespace MOBase; + +// returns the icon for the given instance or an empty 32x32 icon if the game +// plugin couldn't be found +// +QIcon instanceIcon(PluginContainer& pc, const Instance& i) +{ + auto* game = InstanceManager::singleton().gamePluginForDirectory(i.directory(), pc); + + if (!game) { + QPixmap empty(32, 32); + empty.fill(QColor(0, 0, 0, 0)); + return QIcon(empty); + } + + // it's possible to have the game installed in a way that the game plugin + // couldn't auto detect; in this case, the instance would have a valid game + // directory, but the plugin wouldn't know about it + // + // it's also possible, but unlikely, to have multiple installations of the + // same game that have different icons for the same exe + // + // so the game directory specified for the instance needs to be given to the + // game plugin to get the appropriate icon, but since these game plugin + // objects are created on startup and are global, they should retain their + // auto detected path + // + // if not, creating a new instance for a specific plugin would use the game + // directory of the instance for which the icon was most recently shown, which + // would be really inconsistent + // + // + // this game plugin could also be the currently active plugin for the + // current instance, which should _definitely_ keep pointing to the same + // directory as before + + // remember old game directory + // + // note that gameDirectory() returns a QDir, which doesn't support empty + // strings (they get converted to "." automatically!), but the plugin _will_ + // try to return an empty string when the game has not been auto-detected + // + // so gameDirectory() _cannot_ reliably be used if `isInstalled()` is false + const QString old = game->isInstalled() ? game->gameDirectory().path() : ""; + + // revert + Guard const g([&] { + game->setGamePath(old); + }); + + // set directory for this instance + game->setGamePath(i.gameDirectory()); + + return game->gameIcon(); +} + +// pops up a dialog to ask for an instance name when renaming +// +QString getInstanceName(QWidget* parent, const QString& title, const QString& moreText, + const QString& label, const QString& oldName = {}) +{ + auto& m = InstanceManager::singleton(); + + QDialog dlg(parent); + dlg.setWindowTitle(title); + + auto* ly = new QVBoxLayout(&dlg); + + auto* bb = new QDialogButtonBox(QDialogButtonBox::Cancel | QDialogButtonBox::Ok); + + auto* text = new QLineEdit(oldName); + text->selectAll(); + + auto* error = new QLabel; + + if (!moreText.isEmpty()) { + auto* lb = new QLabel(moreText); + lb->setWordWrap(true); + ly->addWidget(lb); + ly->addSpacing(10); + } + + auto* lb = new QLabel(label); + lb->setWordWrap(true); + ly->addWidget(lb); + + ly->addWidget(text); + ly->addWidget(error); + ly->addStretch(); + ly->addWidget(bb); + + auto check = [&] { + bool okay = false; + + if (text->text().isEmpty()) { + error->setText(""); + } else if (!MOBase::validFileName(text->text())) { + error->setText(QObject::tr("The instance name must be a valid folder name.")); + } else { + const auto name = MOBase::sanitizeFileName(text->text()); + + if ((name != oldName) && m.instanceExists(text->text())) { + error->setText(QObject::tr("An instance with this name already exists.")); + } else { + okay = true; + } + } + + error->setVisible(!okay); + bb->button(QDialogButtonBox::Ok)->setEnabled(okay); + }; + + QObject::connect(text, &QLineEdit::textChanged, [&] { + check(); + }); + QObject::connect(bb, &QDialogButtonBox::accepted, [&] { + dlg.accept(); + }); + QObject::connect(bb, &QDialogButtonBox::rejected, [&] { + dlg.reject(); + }); + + check(); + + dlg.resize({400, 120}); + if (dlg.exec() != QDialog::Accepted) { + return {}; + } + + return MOBase::sanitizeFileName(text->text()); +} + +InstanceManagerDialog::~InstanceManagerDialog() = default; + +InstanceManagerDialog::InstanceManagerDialog(PluginContainer& pc, QWidget* parent) + : QDialog(parent), ui(new Ui::InstanceManagerDialog), m_pc(pc) + +{ + ui->setupUi(this); + + ui->splitter->setSizes({250, 1}); + ui->splitter->setStretchFactor(0, 0); + ui->splitter->setStretchFactor(1, 1); + + m_model = new QStandardItemModel; + ui->list->setModel(m_model); + + m_filter.setEdit(ui->filter); + m_filter.setList(ui->list); + m_filter.setFilteredBorder(false); + + updateInstances(); + updateList(); + selectActiveInstance(); + + connect(ui->createNew, &QPushButton::clicked, [&] { + createNew(); + }); + connect(ui->openExisting, &QPushButton::clicked, [&] { + openExistingPortable(); + }); + + connect(ui->list->selectionModel(), &QItemSelectionModel::selectionChanged, [&] { + onSelection(); + }); + connect(ui->list, &QListView::activated, [&] { + openSelectedInstance(); + }); + + connect(ui->rename, &QPushButton::clicked, [&] { + rename(); + }); + connect(ui->exploreLocation, &QPushButton::clicked, [&] { + exploreLocation(); + }); + connect(ui->exploreBaseDirectory, &QPushButton::clicked, [&] { + exploreBaseDirectory(); + }); + connect(ui->exploreGame, &QPushButton::clicked, [&] { + exploreGame(); + }); + + connect(ui->convertToGlobal, &QPushButton::clicked, [&] { + convertToGlobal(); + }); + connect(ui->convertToPortable, &QPushButton::clicked, [&] { + convertToPortable(); + }); + connect(ui->openINI, &QPushButton::clicked, [&] { + openINI(); + }); + connect(ui->removeFromList, &QPushButton::clicked, [&] { + removeFromList(); + }); + connect(ui->deleteInstance, &QPushButton::clicked, [&] { + deleteInstance(); + }); + + connect(ui->steamDrmCheckBox, &QCheckBox::toggled, [&](bool checked) { + const auto* inst = singleSelection(); + if (!inst) return; + const QString ini = inst->iniPath(); + if (ini.isEmpty()) return; + QSettings s(ini, QSettings::IniFormat); + s.setValue("fluorine/steam_drm", checked); + }); + + connect(ui->steamLinuxRuntimeCheckBox, &QCheckBox::toggled, [&](bool checked) { + const auto* inst = singleSelection(); + if (!inst) return; + const QString ini = inst->iniPath(); + if (ini.isEmpty()) return; + QSettings s(ini, QSettings::IniFormat); + s.setValue("fluorine/use_slr", checked); + if (checked) { + downloadSLRIfNeeded(); + } + }); + + connect(ui->vfsRootBuilderCheckBox, &QCheckBox::toggled, [&](bool checked) { + const auto* inst = singleSelection(); + if (!inst) return; + const QString ini = inst->iniPath(); + if (ini.isEmpty()) return; + QSettings s(ini, QSettings::IniFormat); + s.setValue("fluorine/vfs_root_builder", checked); + }); + + connect(ui->steamOverlayCheckBox, &QCheckBox::toggled, [&](bool checked) { + const auto* inst = singleSelection(); + if (!inst) return; + const QString ini = inst->iniPath(); + if (ini.isEmpty()) return; + QSettings s(ini, QSettings::IniFormat); + s.setValue("fluorine/steam_overlay", checked); + }); + + connect(ui->switchToInstance, &QPushButton::clicked, [&] { + openSelectedInstance(); + }); + connect(ui->close, &QPushButton::clicked, [&] { + close(); + }); +} + +void InstanceManagerDialog::showEvent(QShowEvent* e) +{ + // there might not be a global Settings object if this is called on startup + // when there's no current instance + const auto* s = Settings::maybeInstance(); + + if (s) { + s->geometry().restoreGeometry(this); + } + + QDialog::showEvent(e); +} + +void InstanceManagerDialog::done(int r) +{ + // there might not be a global Settings object if this is called on startup + // when there's no current instance + auto* s = Settings::maybeInstance(); + + if (s) { + s->geometry().saveGeometry(this); + } + + QDialog::done(r); +} + +void InstanceManagerDialog::updateInstances() +{ + auto& m = InstanceManager::singleton(); + + m_instances.clear(); + + for (auto&& d : m.globalInstancePaths()) { + m_instances.push_back(std::make_unique(d, false)); + } + + // sort first, prepend portable after so it's always on top + std::sort(m_instances.begin(), m_instances.end(), [](auto&& a, auto&& b) { + return (MOBase::naturalCompare(a->displayName(), b->displayName()) < 0); + }); + + // add registered portable instances (non-default paths) + const QString defaultPortable = QDir(InstanceManager::portablePath()).absolutePath(); + for (const auto& path : InstanceManager::registeredPortablePaths()) { + // skip the default portable path (handled separately below) + if (QDir(path).absolutePath() == defaultPortable) { + continue; + } + // skip paths where ModOrganizer.ini no longer exists + if (!QFileInfo::exists(QDir(path).filePath("ModOrganizer.ini"))) { + continue; + } + m_instances.push_back(std::make_unique(path, true)); + } + + // re-sort to interleave registered portables alphabetically + std::sort(m_instances.begin(), m_instances.end(), [](auto&& a, auto&& b) { + return (MOBase::naturalCompare(a->displayName(), b->displayName()) < 0); + }); + + if (InstanceManager::portableInstanceExists()) { + m_instances.insert(m_instances.begin(), + std::make_unique(InstanceManager::portablePath(), true)); + } + + // read all inis, ignore errors + for (auto&& i : m_instances) { + i->readFromIni(); + } +} + +void InstanceManagerDialog::updateList() +{ + const auto prevSelIndex = singleSelectionIndex(); + const auto* prevSel = singleSelection(); + + m_model->clear(); + + std::size_t sel = NoSelection; + + // creating items for instances + for (std::size_t i = 0; i < m_instances.size(); ++i) { + const auto& ii = *m_instances[i]; + + auto* item = new QStandardItem(ii.displayName()); + item->setIcon(instanceIcon(m_pc, ii)); + + m_model->appendRow(item); + + if (&ii == prevSel) { + sel = i; + } + } + + // keep current selection or select the next one if there was a selection; + // there's no selection when opening the dialog, that's handled in the ctor + if (prevSel) { + if (m_instances.empty()) { + select(-1); + } else { + if (sel == NoSelection) { + if (prevSelIndex >= m_instances.size()) { + sel = m_instances.size() - 1; + } else { + sel = prevSelIndex; + } + } + + select(sel); + } + } +} + +void InstanceManagerDialog::select(std::size_t i) +{ + if (i < m_instances.size()) { + const auto& ii = m_instances[i]; + fillData(*ii); + + ui->list->selectionModel()->select( + m_filter.mapFromSource(m_filter.sourceModel()->index(i, 0)), + QItemSelectionModel::ClearAndSelect); + } else { + clearData(); + } +} + +void InstanceManagerDialog::select(const QString& name) +{ + for (std::size_t i = 0; i < m_instances.size(); ++i) { + if (m_instances[i]->displayName() == name) { + select(i); + return; + } + } + + log::error("can't select instance {}, not in list", name); +} + +void InstanceManagerDialog::selectActiveInstance() +{ + const auto active = InstanceManager::singleton().currentInstance(); + + if (active) { + const QString activeDir = QDir(active->directory()).absolutePath(); + for (std::size_t i = 0; i < m_instances.size(); ++i) { + if (QDir(m_instances[i]->directory()).absolutePath() == activeDir) { + select(i); + + ui->list->scrollTo(m_filter.mapFromSource(m_filter.sourceModel()->index(i, 0))); + + return; + } + } + } + + select(0); +} + +void InstanceManagerDialog::openSelectedInstance() +{ + const auto i = singleSelectionIndex(); + if (i == NoSelection) { + return; + } + + const auto& to = *m_instances[i]; + + if (!confirmSwitch(to)) { + return; + } + + if (to.isPortable()) { + // Store the actual directory for portable instances so we can distinguish + // between the default portable path and user-selected portable locations. + // An empty string means "use default portable path". + auto& m = InstanceManager::singleton(); + if (to.directory() == InstanceManager::portablePath()) { + InstanceManager::setCurrentInstance(""); + } else { + InstanceManager::setCurrentInstance(to.directory()); + } + } else { + InstanceManager::singleton().setCurrentInstance(to.displayName()); + } + + if (m_restartOnSelect) { + ExitModOrganizer(Exit::Restart); + } + + accept(); +} + +bool InstanceManagerDialog::confirmSwitch(const Instance& to) +{ + // there might not be a global Settings object if this is called on startup + // when there's no current instance + const auto* s = Settings::maybeInstance(); + + // if there is are no settings, no instances are loaded and the confirmation + // wouldn't make sense + if (!s) { + return true; + } + + if (!s->interface().showChangeGameConfirmation()) { + // user disabled confirmation + return true; + } + + MOBase::TaskDialog dlg(this); + + const auto r = dlg.title(tr("Switching instances")) + .main(tr("Mod Organizer must restart to manage the instance '%1'.") + .arg(to.displayName())) + .content(tr("This confirmation can be disabled in the settings.")) + .icon(QMessageBox::Question) + .button({tr("Restart Mod Organizer"), QMessageBox::Ok}) + .button({tr("Cancel"), QMessageBox::Cancel}) + .exec(); + + return (r == QMessageBox::Ok); +} + +void InstanceManagerDialog::rename() +{ + const auto* i = singleSelection(); + if (!i) { + return; + } + + const auto selIndex = singleSelectionIndex(); + + auto& m = InstanceManager::singleton(); + if (i->isActive()) { + QMessageBox::information(this, tr("Rename instance"), + tr("The active instance cannot be renamed.")); + return; + } + + // getting new name + const auto newName = getInstanceName(this, tr("Rename instance"), "", + tr("Instance name"), i->displayName()); + + if (newName.isEmpty()) { + return; + } + + // renaming + const QString src = i->directory(); + const QString dest = + QDir::toNativeSeparators(QFileInfo(src).dir().path() + "/" + newName); + + log::info("renaming {} to {}", src, dest); + + const auto r = shell::Rename(QFileInfo(src), QFileInfo(dest), false); + + if (!r) { + QMessageBox::critical(this, tr("Error"), + tr(R"(Failed to rename "%1" to "%2": %3)") + .arg(src) + .arg(dest) + .arg(r.toString())); + + return; + } + + // updating ui + auto newInstance = std::make_unique(dest, false); + i = newInstance.get(); + + m_model->item(selIndex)->setText(newName); + m_instances[selIndex] = std::move(newInstance); + + fillData(*i); +} + +void InstanceManagerDialog::exploreLocation() +{ + if (const auto* i = singleSelection()) { + shell::Explore(i->directory()); + } +} + +void InstanceManagerDialog::exploreBaseDirectory() +{ + if (const auto* i = singleSelection()) { + shell::Explore(i->baseDirectory()); + } +} + +void InstanceManagerDialog::exploreGame() +{ + if (const auto* i = singleSelection()) { + shell::Explore(i->gameDirectory()); + } +} + +void InstanceManagerDialog::openINI() +{ + if (const auto* i = singleSelection()) { + shell::Open(i->iniPath()); + } +} + +void InstanceManagerDialog::removeFromList() +{ + const auto* i = singleSelection(); + if (!i) { + return; + } + + auto& m = InstanceManager::singleton(); + if (i->isActive()) { + QMessageBox::information(this, tr("Remove from list"), + tr("The active instance cannot be removed.")); + return; + } + + const auto r = QMessageBox::question( + this, tr("Remove from list"), + tr("Remove \"%1\" from the instance list?\n\n" + "No files will be deleted.") + .arg(i->displayName()), + QMessageBox::Yes | QMessageBox::Cancel); + + if (r != QMessageBox::Yes) { + return; + } + + if (i->isPortable()) { + InstanceManager::unregisterPortableInstance(i->directory()); + } else { + // for global instances, rename the INI so it's no longer auto-discovered + const QString ini = i->iniPath(); + if (!ini.isEmpty() && QFile::exists(ini)) { + QFile::rename(ini, ini + ".disabled"); + } + } + + updateInstances(); + updateList(); +} + +void InstanceManagerDialog::deleteInstance() +{ + const auto* i = singleSelection(); + if (!i) { + return; + } + + auto& m = InstanceManager::singleton(); + if (i->isActive()) { + QMessageBox::information(this, tr("Deleting instance"), + tr("The active instance cannot be deleted.")); + return; + } + + // creating dialog + + const auto Delete = QMessageBox::Yes; + const auto Cancel = QMessageBox::Cancel; + + const auto files = i->objectsForDeletion(); + + MOBase::TaskDialog dlg(this); + + dlg.title(tr("Deleting instance")) + .main(tr("These files and folders will be permanently deleted")) + .content(tr("All checked items will be deleted.")) + .icon(QMessageBox::Warning) + .button({tr("Delete permanently"), Delete}) + .button({tr("Cancel"), Cancel}); + + auto* list = new QListWidget(); + list->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOn); + list->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + list->setMaximumHeight(160); + + // filling the list + for (const auto& f : files) { + auto* item = new QListWidgetItem(f.path); + + if (f.mandatoryDelete) { + // disable, cannot uncheck mandatory items + item->setFlags(item->flags() & (~Qt::ItemIsEnabled)); + + // checked by default + item->setCheckState(Qt::Checked); + } else { + item->setFlags(item->flags() | Qt::ItemIsUserCheckable); + + // unchecked by default + item->setCheckState(Qt::Unchecked); + } + + list->addItem(item); + } + + dlg.addContent(list); + dlg.setWidth(600); + + const auto r = dlg.exec(); + + if (r != Delete) { + return; + } + + // gathering all the selected items + QStringList selected; + + for (int i = 0; i < list->count(); ++i) { + if (list->item(i)->checkState() == Qt::Checked) { + selected.append(list->item(i)->text()); + } + } + + if (selected.isEmpty()) { + QMessageBox::information(this, tr("Deleting instance"), tr("Nothing to delete.")); + + return; + } + + // deleting + if (!doDelete(selected, false)) { + return; + } + + // unregister portable instance from the persistent list + if (i->isPortable()) { + InstanceManager::singleton().unregisterPortableInstance(i->directory()); + } + + // updating ui + updateInstances(); + updateList(); +} + +void InstanceManagerDialog::setRestartOnSelect(bool b) +{ + m_restartOnSelect = b; +} + +bool InstanceManagerDialog::doDelete(const QStringList& files, bool recycle) +{ + // logging + for (auto&& f : files) { + if (recycle) { + log::info("will recycle {}", f); + } else { + log::info("will delete {}", f); + } + } + + if (MOBase::shellDelete(files, recycle, this)) { + return true; + } + + const auto e = GetLastError(); + if (e == ERROR_CANCELLED) { + log::debug("deletion cancelled by user"); + } else { + log::error("failed to delete, {}", formatSystemMessage(e)); + } + + return false; +} + +void InstanceManagerDialog::convertToGlobal() +{ + // not implemented +} + +void InstanceManagerDialog::convertToPortable() +{ + // not implemented +} + +void InstanceManagerDialog::onSelection() +{ + const auto i = singleSelectionIndex(); + if (i == NoSelection) { + return; + } + + select(i); +} + +void InstanceManagerDialog::createNew() +{ + // there might not be settings available; the dialog can be shown when the + // last selected instance doesn't exist anymore + CreateInstanceDialog dlg(m_pc, Settings::maybeInstance(), this); + + if (dlg.exec() != QDialog::Accepted) { + return; + } + + if (dlg.switching()) { + // restarting MO + accept(); + return; + } + + updateInstances(); + updateList(); + + select(dlg.creationInfo().instanceName); +} + +std::size_t InstanceManagerDialog::singleSelectionIndex() const +{ + const auto sel = + m_filter.mapSelectionToSource(ui->list->selectionModel()->selection()); + + if (sel.size() != 1) { + return NoSelection; + } + + const auto indexes = sel.indexes(); + if (indexes.size() != 1 || !indexes[0].isValid()) { + return NoSelection; + } + + const int row = indexes[0].row(); + if (row < 0 || static_cast(row) >= m_instances.size()) { + return NoSelection; + } + + return static_cast(row); +} + +const Instance* InstanceManagerDialog::singleSelection() const +{ + const auto i = singleSelectionIndex(); + if (i == NoSelection || i >= m_instances.size()) { + return nullptr; + } + + return m_instances[i].get(); +} + +void InstanceManagerDialog::fillData(const Instance& ii) +{ + ui->name->setText(ii.displayName()); + ui->location->setText(ii.directory()); + ui->baseDirectory->setText(ii.baseDirectory()); + ui->gameName->setText(ii.gameName()); + ui->gameDir->setText(ii.gameDirectory()); + + // read prefix info and fluorine settings from the instance's INI + { + const QString ini = ii.iniPath(); + if (!ini.isEmpty() && QFile::exists(ini)) { + QSettings const s(ini, QSettings::IniFormat); + ui->prefixPath->setText(s.value("Settings/proton_prefix_path").toString()); + ui->protonVersion->setText(s.value("fluorine/proton_name").toString()); + + ui->steamDrmCheckBox->blockSignals(true); + ui->steamDrmCheckBox->setChecked(s.value("fluorine/steam_drm", true).toBool()); + ui->steamDrmCheckBox->blockSignals(false); + + ui->steamLinuxRuntimeCheckBox->blockSignals(true); + ui->steamLinuxRuntimeCheckBox->setChecked(s.value("fluorine/use_slr", true).toBool()); + ui->steamLinuxRuntimeCheckBox->blockSignals(false); + + ui->vfsRootBuilderCheckBox->blockSignals(true); + ui->vfsRootBuilderCheckBox->setChecked(s.value("fluorine/vfs_root_builder", true).toBool()); + ui->vfsRootBuilderCheckBox->blockSignals(false); + + ui->steamOverlayCheckBox->blockSignals(true); + ui->steamOverlayCheckBox->setChecked(s.value("fluorine/steam_overlay", false).toBool()); + ui->steamOverlayCheckBox->blockSignals(false); + } else { + ui->prefixPath->clear(); + ui->protonVersion->clear(); + ui->steamDrmCheckBox->blockSignals(true); + ui->steamDrmCheckBox->setChecked(false); + ui->steamDrmCheckBox->blockSignals(false); + ui->steamLinuxRuntimeCheckBox->blockSignals(true); + ui->steamLinuxRuntimeCheckBox->setChecked(true); + ui->steamLinuxRuntimeCheckBox->blockSignals(false); + ui->vfsRootBuilderCheckBox->blockSignals(true); + ui->vfsRootBuilderCheckBox->setChecked(false); + ui->vfsRootBuilderCheckBox->blockSignals(false); + ui->steamOverlayCheckBox->blockSignals(true); + ui->steamOverlayCheckBox->setChecked(false); + ui->steamOverlayCheckBox->blockSignals(false); + } + } + + setButtonsEnabled(true); + + const auto& m = InstanceManager::singleton(); + + ui->rename->setEnabled(!ii.isPortable()); + + if (ii.isPortable()) { + ui->convertToPortable->setVisible(false); + ui->convertToGlobal->setVisible(true); + ui->convertToGlobal->setEnabled(true); + } else { + ui->convertToPortable->setVisible(true); + ui->convertToGlobal->setVisible(false); + + if (InstanceManager::portableInstanceExists()) { + ui->convertToPortable->setEnabled(false); + ui->convertToPortable->setToolTip(tr("A portable instance already exists.")); + } else { + ui->convertToPortable->setEnabled(false); + ui->convertToPortable->setToolTip(""); + } + } + + // not implemented, hide the buttons + ui->convertToPortable->setVisible(false); + ui->convertToGlobal->setVisible(false); +} + +void InstanceManagerDialog::clearData() +{ + ui->name->clear(); + ui->location->clear(); + ui->baseDirectory->clear(); + ui->gameName->clear(); + ui->gameDir->clear(); + ui->prefixPath->clear(); + ui->protonVersion->clear(); + ui->steamDrmCheckBox->blockSignals(true); + ui->steamDrmCheckBox->setChecked(false); + ui->steamDrmCheckBox->blockSignals(false); + + setButtonsEnabled(false); + + ui->convertToPortable->setVisible(false); + ui->convertToGlobal->setVisible(false); +} + +void InstanceManagerDialog::setButtonsEnabled(bool b) +{ + ui->rename->setEnabled(b); + ui->exploreLocation->setEnabled(b); + ui->exploreBaseDirectory->setEnabled(b); + ui->exploreGame->setEnabled(b); + ui->convertToPortable->setEnabled(b); + ui->convertToGlobal->setEnabled(b); + ui->removeFromList->setEnabled(b); + ui->deleteInstance->setEnabled(b); + ui->switchToInstance->setEnabled(b); +} + +void InstanceManagerDialog::openExistingPortable() +{ + // On Flatpak, the native file dialog goes through the XDG Desktop Portal, + const QString dir = QFileDialog::getExistingDirectory( + this, tr("Select portable instance folder"), + QStandardPaths::writableLocation(QStandardPaths::HomeLocation)); + + if (dir.isEmpty()) { + return; + } + + const QString ini = QDir(dir).filePath("ModOrganizer.ini"); + if (!QFileInfo::exists(ini)) { + QMessageBox::warning( + this, tr("Not an instance"), + tr("The selected folder does not contain a ModOrganizer.ini file.")); + return; + } + + // Register the portable instance so it persists in the sidebar + auto& m = InstanceManager::singleton(); + InstanceManager::registerPortableInstance(dir); + + // Refresh the instance list and select the newly added entry + updateInstances(); + updateList(); + + // Find and select the new instance by directory + const QString canonical = QDir(dir).absolutePath(); + for (std::size_t i = 0; i < m_instances.size(); ++i) { + if (QDir(m_instances[i]->directory()).absolutePath() == canonical) { + select(i); + break; + } + } +} + +void InstanceManagerDialog::downloadSLRIfNeeded() +{ + if (isSlrInstalled()) { + return; + } + + auto* progress = new QProgressDialog( + tr("Downloading Steam Linux Runtime (~200 MB)...\n" + "This only happens once. Check the MO2 log for details."), + tr("Cancel"), 0, 0, this); // 0,0 = indeterminate + progress->setWindowTitle(tr("Steam Linux Runtime")); + progress->setWindowModality(Qt::WindowModal); + progress->setAttribute(Qt::WA_ShowWithoutActivating); + progress->setMinimumDuration(0); + + auto* cancelFlag = new int(0); + + connect(progress, &QProgressDialog::canceled, this, [cancelFlag] { + *cancelFlag = 1; + }); + + auto* watcher = new QFutureWatcher(this); + + connect(watcher, &QFutureWatcher::finished, this, + [this, watcher, progress, cancelFlag] { + progress->close(); + watcher->deleteLater(); + progress->deleteLater(); + + const QString err = watcher->result(); + if (!err.isEmpty()) { + MOBase::log::error("[SLR] Download failed: {}", err); + QMessageBox::warning(this, tr("Steam Linux Runtime"), + tr("Download failed:\n%1\n\nSLR has been disabled for this instance.") + .arg(err)); + ui->steamLinuxRuntimeCheckBox->blockSignals(true); + ui->steamLinuxRuntimeCheckBox->setChecked(false); + ui->steamLinuxRuntimeCheckBox->blockSignals(false); + const auto* inst = singleSelection(); + if (inst) { + const QString ini = inst->iniPath(); + if (!ini.isEmpty()) { + QSettings s(ini, QSettings::IniFormat); + s.setValue("fluorine/use_slr", false); + } + } + } else { + MOBase::log::info("[SLR] Steam Linux Runtime installed successfully"); + progress->setLabelText(tr("Steam Linux Runtime is ready.")); + } + delete cancelFlag; + }); + + int* cancelPtr = cancelFlag; + watcher->setFuture(QtConcurrent::run([cancelPtr]() -> QString { + return downloadSlr(nullptr, nullptr, cancelPtr); + })); + + progress->show(); +} diff --git a/src/src/knowngames.h b/src/src/knowngames.h index 03851b8..dd0a45b 100644 --- a/src/src/knowngames.h +++ b/src/src/knowngames.h @@ -21,55 +21,55 @@ struct KnownGame { // Static list of all known games. inline constexpr KnownGame KNOWN_GAMES[] = { // Bethesda Games - {"Enderal", "933480", "1708684988", nullptr, - "Enderal", nullptr, nullptr, - R"(Software\SureAI\Enderal)", "Install_Path", "Enderal"}, - {"Enderal Special Edition", "976620", nullptr, nullptr, - "Enderal Special Edition", nullptr, nullptr, - R"(Software\SureAI\Enderal SE)", "installed path", "Enderal Special Edition"}, - {"Fallout 3", "22300", "1454315831", "adeae8bbfc94427db57c7dfecce3f1d4", - "Fallout3", "Fallout3", nullptr, - R"(Software\Bethesda Softworks\Fallout3)", "installed path", "Fallout 3"}, - {"Fallout 4", "377160", "1998527297", "61d52ce4d09d41e48800c22784d13ae8", - "Fallout4", "Fallout4", nullptr, - R"(Software\Bethesda Softworks\Fallout4)", "installed path", "Fallout 4"}, - {"Fallout 4 VR", "611660", nullptr, nullptr, - "Fallout4VR", nullptr, nullptr, - R"(Software\Bethesda Softworks\Fallout 4 VR)", "installed path", "Fallout 4 VR"}, - {"Fallout New Vegas", "22380", "1454587428", "5daeb974a22a435988892319b3a4f476", - "FalloutNV", "FalloutNV", nullptr, - R"(Software\Bethesda Softworks\FalloutNV)", "installed path", "Fallout New Vegas"}, - {"Morrowind", "22320", "1440163901", nullptr, - "Morrowind", nullptr, nullptr, - R"(Software\Bethesda Softworks\Morrowind)", "installed path", "Morrowind"}, - {"Oblivion", "22330", "1458058109", nullptr, - "Oblivion", "Oblivion", nullptr, - R"(Software\Bethesda Softworks\Oblivion)", "installed path", "Oblivion"}, - {"Skyrim", "72850", nullptr, nullptr, - "Skyrim", "Skyrim", nullptr, - R"(Software\Bethesda Softworks\Skyrim)", "installed path", "Skyrim"}, - {"Skyrim Special Edition", "489830", "1711230643", "ac82db5035584c7f8a2c548d98c86b2c", - "Skyrim Special Edition", "Skyrim Special Edition", nullptr, - R"(Software\Bethesda Softworks\Skyrim Special Edition)", "installed path", - "Skyrim Special Edition"}, - {"Skyrim VR", "611670", nullptr, nullptr, - "Skyrim VR", nullptr, nullptr, - R"(Software\Bethesda Softworks\Skyrim VR)", "installed path", "Skyrim VR"}, - {"Starfield", "1716740", nullptr, nullptr, - "Starfield", nullptr, nullptr, - R"(Software\Bethesda Softworks\Starfield)", "installed path", "Starfield"}, + {.name="Enderal", .steam_app_id="933480", .gog_app_id="1708684988", .epic_app_id=nullptr, + .my_games_folder="Enderal", .appdata_local_folder=nullptr, .appdata_roaming_folder=nullptr, + .registry_path=R"(Software\SureAI\Enderal)", .registry_value="Install_Path", .steam_folder="Enderal"}, + {.name="Enderal Special Edition", .steam_app_id="976620", .gog_app_id=nullptr, .epic_app_id=nullptr, + .my_games_folder="Enderal Special Edition", .appdata_local_folder=nullptr, .appdata_roaming_folder=nullptr, + .registry_path=R"(Software\SureAI\Enderal SE)", .registry_value="installed path", .steam_folder="Enderal Special Edition"}, + {.name="Fallout 3", .steam_app_id="22300", .gog_app_id="1454315831", .epic_app_id="adeae8bbfc94427db57c7dfecce3f1d4", + .my_games_folder="Fallout3", .appdata_local_folder="Fallout3", .appdata_roaming_folder=nullptr, + .registry_path=R"(Software\Bethesda Softworks\Fallout3)", .registry_value="installed path", .steam_folder="Fallout 3"}, + {.name="Fallout 4", .steam_app_id="377160", .gog_app_id="1998527297", .epic_app_id="61d52ce4d09d41e48800c22784d13ae8", + .my_games_folder="Fallout4", .appdata_local_folder="Fallout4", .appdata_roaming_folder=nullptr, + .registry_path=R"(Software\Bethesda Softworks\Fallout4)", .registry_value="installed path", .steam_folder="Fallout 4"}, + {.name="Fallout 4 VR", .steam_app_id="611660", .gog_app_id=nullptr, .epic_app_id=nullptr, + .my_games_folder="Fallout4VR", .appdata_local_folder=nullptr, .appdata_roaming_folder=nullptr, + .registry_path=R"(Software\Bethesda Softworks\Fallout 4 VR)", .registry_value="installed path", .steam_folder="Fallout 4 VR"}, + {.name="Fallout New Vegas", .steam_app_id="22380", .gog_app_id="1454587428", .epic_app_id="5daeb974a22a435988892319b3a4f476", + .my_games_folder="FalloutNV", .appdata_local_folder="FalloutNV", .appdata_roaming_folder=nullptr, + .registry_path=R"(Software\Bethesda Softworks\FalloutNV)", .registry_value="installed path", .steam_folder="Fallout New Vegas"}, + {.name="Morrowind", .steam_app_id="22320", .gog_app_id="1440163901", .epic_app_id=nullptr, + .my_games_folder="Morrowind", .appdata_local_folder=nullptr, .appdata_roaming_folder=nullptr, + .registry_path=R"(Software\Bethesda Softworks\Morrowind)", .registry_value="installed path", .steam_folder="Morrowind"}, + {.name="Oblivion", .steam_app_id="22330", .gog_app_id="1458058109", .epic_app_id=nullptr, + .my_games_folder="Oblivion", .appdata_local_folder="Oblivion", .appdata_roaming_folder=nullptr, + .registry_path=R"(Software\Bethesda Softworks\Oblivion)", .registry_value="installed path", .steam_folder="Oblivion"}, + {.name="Skyrim", .steam_app_id="72850", .gog_app_id=nullptr, .epic_app_id=nullptr, + .my_games_folder="Skyrim", .appdata_local_folder="Skyrim", .appdata_roaming_folder=nullptr, + .registry_path=R"(Software\Bethesda Softworks\Skyrim)", .registry_value="installed path", .steam_folder="Skyrim"}, + {.name="Skyrim Special Edition", .steam_app_id="489830", .gog_app_id="1711230643", .epic_app_id="ac82db5035584c7f8a2c548d98c86b2c", + .my_games_folder="Skyrim Special Edition", .appdata_local_folder="Skyrim Special Edition", .appdata_roaming_folder=nullptr, + .registry_path=R"(Software\Bethesda Softworks\Skyrim Special Edition)", .registry_value="installed path", + .steam_folder="Skyrim Special Edition"}, + {.name="Skyrim VR", .steam_app_id="611670", .gog_app_id=nullptr, .epic_app_id=nullptr, + .my_games_folder="Skyrim VR", .appdata_local_folder=nullptr, .appdata_roaming_folder=nullptr, + .registry_path=R"(Software\Bethesda Softworks\Skyrim VR)", .registry_value="installed path", .steam_folder="Skyrim VR"}, + {.name="Starfield", .steam_app_id="1716740", .gog_app_id=nullptr, .epic_app_id=nullptr, + .my_games_folder="Starfield", .appdata_local_folder=nullptr, .appdata_roaming_folder=nullptr, + .registry_path=R"(Software\Bethesda Softworks\Starfield)", .registry_value="installed path", .steam_folder="Starfield"}, // CD Projekt RED Games - {"The Witcher 3", "292030", "1495134320", nullptr, - "The Witcher 3", nullptr, nullptr, - R"(Software\CD Projekt Red\The Witcher 3)", "InstallFolder", - "The Witcher 3 Wild Hunt"}, - {"Cyberpunk 2077", "1091500", "1423049311", nullptr, - nullptr, "CD Projekt Red/Cyberpunk 2077", nullptr, - R"(Software\CD Projekt Red\Cyberpunk 2077)", "InstallFolder", "Cyberpunk 2077"}, + {.name="The Witcher 3", .steam_app_id="292030", .gog_app_id="1495134320", .epic_app_id=nullptr, + .my_games_folder="The Witcher 3", .appdata_local_folder=nullptr, .appdata_roaming_folder=nullptr, + .registry_path=R"(Software\CD Projekt Red\The Witcher 3)", .registry_value="InstallFolder", + .steam_folder="The Witcher 3 Wild Hunt"}, + {.name="Cyberpunk 2077", .steam_app_id="1091500", .gog_app_id="1423049311", .epic_app_id=nullptr, + .my_games_folder=nullptr, .appdata_local_folder="CD Projekt Red/Cyberpunk 2077", .appdata_roaming_folder=nullptr, + .registry_path=R"(Software\CD Projekt Red\Cyberpunk 2077)", .registry_value="InstallFolder", .steam_folder="Cyberpunk 2077"}, // Other popular moddable games - {"Baldur's Gate 3", "1086940", "1456460669", nullptr, - nullptr, "Larian Studios/Baldur's Gate 3", nullptr, - R"(Software\Larian Studios\Baldur's Gate 3)", "InstallDir", "Baldurs Gate 3"}, + {.name="Baldur's Gate 3", .steam_app_id="1086940", .gog_app_id="1456460669", .epic_app_id=nullptr, + .my_games_folder=nullptr, .appdata_local_folder="Larian Studios/Baldur's Gate 3", .appdata_roaming_folder=nullptr, + .registry_path=R"(Software\Larian Studios\Baldur's Gate 3)", .registry_value="InstallDir", .steam_folder="Baldurs Gate 3"}, }; inline constexpr int KNOWN_GAMES_COUNT = @@ -148,7 +148,7 @@ inline const KnownGame* findKnownGameByTitle(const QString& title) const QString titleLower = title.toLower(); // Exact match first. - if (auto* g = findKnownGameByName(title)) + if (const auto* g = findKnownGameByName(title)) return g; // Sort by name length descending so "Skyrim Special Edition" beats "Skyrim". diff --git a/src/src/loglist.cpp b/src/src/loglist.cpp index de9c79a..9edfb68 100644 --- a/src/src/loglist.cpp +++ b/src/src/loglist.cpp @@ -1,425 +1,425 @@ -/* -Copyright (C) 2012 Sebastian Herbord. All rights reserved. - -This file is part of Mod Organizer. - -Mod Organizer is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -Mod Organizer is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with Mod Organizer. If not, see . -*/ - -#include "loglist.h" -#include "copyeventfilter.h" -#include "env.h" -#include "organizercore.h" -#include - -using namespace MOBase; - -static LogModel* g_instance = nullptr; -const std::size_t MaxLines = 1000; - -static std::unique_ptr m_console; -static bool m_stdout = false; -static std::mutex m_stdoutMutex; - -LogModel::LogModel() = default; - -void LogModel::create() -{ - g_instance = new LogModel; -} - -LogModel& LogModel::instance() -{ - return *g_instance; -} - -void LogModel::add(MOBase::log::Entry e) -{ - QMetaObject::invokeMethod( - this, - [this, e] { - onEntryAdded(std::move(e)); - }, - Qt::QueuedConnection); -} - -QString LogModel::formattedMessage(const QModelIndex& index) const -{ - if (!index.isValid()) { - return ""; - } - return QString::fromStdString(m_entries[index.row()].formattedMessage); -} - -void LogModel::clear() -{ - beginResetModel(); - m_entries.clear(); - endResetModel(); -} - -const std::deque& LogModel::entries() const -{ - return m_entries; -} - -void LogModel::onEntryAdded(MOBase::log::Entry e) -{ - bool full = false; - if (m_entries.size() > MaxLines) { - m_entries.pop_front(); - full = true; - } - - const int row = static_cast(m_entries.size()); - - if (!full) { - beginInsertRows(QModelIndex(), row, row + 1); - } - - m_entries.emplace_back(std::move(e)); - - if (!full) { - endInsertRows(); - } else { - emit dataChanged(createIndex(row, 0), createIndex(row + 1, columnCount({}))); - } -} - -QModelIndex LogModel::index(int row, int column, const QModelIndex&) const -{ - return createIndex(row, column, row); -} - -QModelIndex LogModel::parent(const QModelIndex&) const -{ - return {}; -} - -int LogModel::rowCount(const QModelIndex& parent) const -{ - if (parent.isValid()) - return 0; - else - return static_cast(m_entries.size()); -} - -int LogModel::columnCount(const QModelIndex&) const -{ - return 3; -} - -QVariant LogModel::data(const QModelIndex& index, int role) const -{ - using namespace std::chrono; - - const auto row = static_cast(index.row()); - if (row >= m_entries.size()) { - return {}; - } - - const auto& e = m_entries[row]; - - if (role == Qt::DisplayRole) { - if (index.column() == 0) { - const auto ms = duration_cast(e.time.time_since_epoch()); - const auto s = duration_cast(ms); - - const std::time_t tt = s.count(); - const int frac = static_cast(ms.count() % 1000); - - const auto time = QDateTime::fromSecsSinceEpoch(tt).time().addMSecs(frac); - return time.toString("hh:mm:ss.zzz"); - } else if (index.column() == 2) { - return QString::fromStdString(e.message); - } - } - - if (role == Qt::DecorationRole) { - if (index.column() == 1) { - switch (e.level) { - case log::Warning: - return QIcon(":/MO/gui/warning"); - - case log::Error: - return QIcon(":/MO/gui/problem"); - - case log::Debug: - return QIcon(":/MO/gui/debug"); - case log::Info: - return QIcon(":/MO/gui/information"); - default: - return {}; - } - } - } - - return {}; -} - -QVariant LogModel::headerData(int, Qt::Orientation, int) const -{ - return {}; -} - -LogList::LogList(QWidget* parent) - : QTreeView(parent), m_copyFilter(this, [=](auto&& index) { - return LogModel::instance().formattedMessage(index); - }) -{ - setModel(&LogModel::instance()); - - header()->setMinimumSectionSize(0); - header()->resizeSection(1, 20); - header()->setSectionResizeMode(0, QHeaderView::ResizeToContents); - - setAutoScroll(true); - scrollToBottom(); - - connect(this, &QWidget::customContextMenuRequested, [&](auto&& pos) { - onContextMenu(pos); - }); - - connect(model(), &LogModel::rowsInserted, this, [&] { - onNewEntry(); - }); - connect(model(), &LogModel::dataChanged, this, [&] { - onNewEntry(); - }); - - m_timer.setSingleShot(true); - connect(&m_timer, &QTimer::timeout, [&] { - scrollToBottom(); - }); - - installEventFilter(&m_copyFilter); -} - -void LogList::onNewEntry() -{ - m_timer.start(std::chrono::milliseconds(10)); -} - -void LogList::setCore(OrganizerCore& core) -{ - m_core = &core; -} - -void LogList::copyToClipboard() -{ - std::string s; - - for (const auto& e : LogModel::instance().entries()) { - s += e.formattedMessage + "\n"; - } - - if (!s.empty()) { - // last newline - s.pop_back(); - } - - QApplication::clipboard()->setText(QString::fromStdString(s)); -} - -void LogList::clear() -{ - static_cast(model())->clear(); -} - -void LogList::openLogsFolder() -{ - const QString logsPath = qApp->property("dataPath").toString() + "/logs"; - shell::Explore(logsPath); -} - -QMenu* LogList::createMenu(QWidget* parent) -{ - auto* menu = new QMenu(parent); - - menu->addAction(tr("Copy"), [&] { - m_copyFilter.copySelection(); - }); - menu->addAction(tr("&Copy all"), [&] { - copyToClipboard(); - }); - menu->addSeparator(); - menu->addAction(tr("C&lear all"), [&] { - clear(); - }); - menu->addAction(tr("&Open logs folder"), [&] { - openLogsFolder(); - }); - - auto* levels = new QMenu(tr("&Level")); - menu->addMenu(levels); - - auto* ag = new QActionGroup(menu); - - auto addAction = [&](auto&& text, auto&& level) { - auto* a = new QAction(text, ag); - - a->setCheckable(true); - a->setChecked(log::getDefault().level() == level); - - connect(a, &QAction::triggered, [this, level] { - if (m_core) { - m_core->setLogLevel(level); - } - }); - - levels->addAction(a); - }; - - addAction(tr("&Debug"), log::Debug); - addAction(tr("&Info"), log::Info); - addAction(tr("&Warnings"), log::Warning); - addAction(tr("&Errors"), log::Error); - - return menu; -} - -void LogList::onContextMenu(const QPoint& pos) -{ - auto* menu = createMenu(this); - menu->popup(viewport()->mapToGlobal(pos)); -} - -log::Levels convertQtLevel(QtMsgType t) -{ - switch (t) { - case QtDebugMsg: - return log::Debug; - - case QtWarningMsg: - return log::Warning; - - case QtCriticalMsg: // fall-through - case QtFatalMsg: - return log::Error; - - case QtInfoMsg: // fall-through - default: - return log::Info; - } -} - -void qtLogCallback(QtMsgType type, const QMessageLogContext& context, - const QString& message) -{ - std::string_view file = ""; - - if (type != QtDebugMsg) { - if (context.file) { - file = context.file; - - const auto lastSep = file.find_last_of("/\\"); - if (lastSep != std::string_view::npos) { - file = {context.file + lastSep + 1}; - } - } - } - - if (file.empty()) { - log::log(convertQtLevel(type), "{}", message.toStdString()); - } else { - log::log(convertQtLevel(type), "[{}:{}] {}", file, context.line, - message.toStdString()); - } -} - -void logToStdout(bool b) -{ - m_stdout = b; - - // logging to stdout is already set up in uibase by log::createDefault(), - // all it needs is to redirect stdout to the console, which is done by - // creating an env::Console object - - if (m_stdout) { - m_console.reset(new env::Console); - } else { - m_console.reset(); - } -} - -void initLogging() -{ - LogModel::create(); - - log::LoggerConfiguration conf; - conf.maxLevel = MOBase::log::Debug; - conf.pattern = "%^[%Y-%m-%d %H:%M:%S.%e %L] %v%$"; - conf.utc = true; - - log::createDefault(conf); - - log::getDefault().setCallback([](log::Entry e) { - LogModel::instance().add(e); - }); - - const char* username = std::getenv("USERNAME"); - if (username == nullptr || username[0] == '\0') { - username = std::getenv("USER"); - } - - if (username != nullptr && username[0] != '\0') { - log::getDefault().addToBlacklist(std::string("\\") + username, "\\USERNAME"); - log::getDefault().addToBlacklist(std::string("/") + username, "/USERNAME"); - } - - qInstallMessageHandler(qtLogCallback); -} - -bool createAndMakeWritable(const std::wstring& subPath) -{ - QString const dataPath = qApp->property("dataPath").toString(); - QString const fullPath = dataPath + "/" + QString::fromStdWString(subPath); - - if (!QDir(fullPath).exists() && !QDir().mkdir(fullPath)) { - QMessageBox::critical(nullptr, QObject::tr("Error"), - QObject::tr("Failed to create \"%1\". Your user " - "account probably lacks permission.") - .arg(fullPath)); - return false; - } else { - return true; - } -} - -bool setLogDirectory(const QString& dir) -{ - // Place logs in the instance directory so each instance has its own logs. - const QString logDir = dir + "/logs"; - QDir().mkpath(logDir); - - // Create a timestamped log file for this session. - const QString timestamp = - QDateTime::currentDateTime().toString("yyyy-MM-dd_HH-mm-ss"); - const auto logFile = logDir + "/mo_interface_" + timestamp + ".log"; - - // Clean up old logs, keeping only the most recent (numLogFiles - 1) so that - // together with the new one we have numLogFiles total. - { - QDir d(logDir); - QStringList existing = d.entryList({"mo_interface_*.log"}, QDir::Files, QDir::Name); - const int keep = std::max(0, AppConfig::numLogFiles() - 1); - while (existing.size() > keep) { - d.remove(existing.takeFirst()); - } - } - - log::getDefault().setFile(MOBase::log::File::single(logFile.toStdWString())); - - return true; -} +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "loglist.h" +#include "copyeventfilter.h" +#include "env.h" +#include "organizercore.h" +#include + +using namespace MOBase; + +static LogModel* g_instance = nullptr; +const std::size_t MaxLines = 1000; + +static std::unique_ptr m_console; +static bool m_stdout = false; +static std::mutex m_stdoutMutex; + +LogModel::LogModel() = default; + +void LogModel::create() +{ + g_instance = new LogModel; +} + +LogModel& LogModel::instance() +{ + return *g_instance; +} + +void LogModel::add(MOBase::log::Entry e) +{ + QMetaObject::invokeMethod( + this, + [this, e] { + onEntryAdded(std::move(e)); + }, + Qt::QueuedConnection); +} + +QString LogModel::formattedMessage(const QModelIndex& index) const +{ + if (!index.isValid()) { + return ""; + } + return QString::fromStdString(m_entries[index.row()].formattedMessage); +} + +void LogModel::clear() +{ + beginResetModel(); + m_entries.clear(); + endResetModel(); +} + +const std::deque& LogModel::entries() const +{ + return m_entries; +} + +void LogModel::onEntryAdded(MOBase::log::Entry e) +{ + bool full = false; + if (m_entries.size() > MaxLines) { + m_entries.pop_front(); + full = true; + } + + const int row = static_cast(m_entries.size()); + + if (!full) { + beginInsertRows(QModelIndex(), row, row + 1); + } + + m_entries.emplace_back(std::move(e)); + + if (!full) { + endInsertRows(); + } else { + emit dataChanged(createIndex(row, 0), createIndex(row + 1, columnCount({}))); + } +} + +QModelIndex LogModel::index(int row, int column, const QModelIndex&) const +{ + return createIndex(row, column, row); +} + +QModelIndex LogModel::parent(const QModelIndex&) const +{ + return {}; +} + +int LogModel::rowCount(const QModelIndex& parent) const +{ + if (parent.isValid()) + return 0; + else + return static_cast(m_entries.size()); +} + +int LogModel::columnCount(const QModelIndex&) const +{ + return 3; +} + +QVariant LogModel::data(const QModelIndex& index, int role) const +{ + using namespace std::chrono; + + const auto row = static_cast(index.row()); + if (row >= m_entries.size()) { + return {}; + } + + const auto& e = m_entries[row]; + + if (role == Qt::DisplayRole) { + if (index.column() == 0) { + const auto ms = duration_cast(e.time.time_since_epoch()); + const auto s = duration_cast(ms); + + const std::time_t tt = s.count(); + const int frac = static_cast(ms.count() % 1000); + + const auto time = QDateTime::fromSecsSinceEpoch(tt).time().addMSecs(frac); + return time.toString("hh:mm:ss.zzz"); + } else if (index.column() == 2) { + return QString::fromStdString(e.message); + } + } + + if (role == Qt::DecorationRole) { + if (index.column() == 1) { + switch (e.level) { + case log::Warning: + return QIcon(":/MO/gui/warning"); + + case log::Error: + return QIcon(":/MO/gui/problem"); + + case log::Debug: + return QIcon(":/MO/gui/debug"); + case log::Info: + return QIcon(":/MO/gui/information"); + default: + return {}; + } + } + } + + return {}; +} + +QVariant LogModel::headerData(int, Qt::Orientation, int) const +{ + return {}; +} + +LogList::LogList(QWidget* parent) + : QTreeView(parent), m_copyFilter(this, [=](auto&& index) { + return LogModel::instance().formattedMessage(index); + }) +{ + setModel(&LogModel::instance()); + + header()->setMinimumSectionSize(0); + header()->resizeSection(1, 20); + header()->setSectionResizeMode(0, QHeaderView::ResizeToContents); + + setAutoScroll(true); + scrollToBottom(); + + connect(this, &QWidget::customContextMenuRequested, [&](auto&& pos) { + onContextMenu(pos); + }); + + connect(model(), &LogModel::rowsInserted, this, [&] { + onNewEntry(); + }); + connect(model(), &LogModel::dataChanged, this, [&] { + onNewEntry(); + }); + + m_timer.setSingleShot(true); + connect(&m_timer, &QTimer::timeout, [&] { + scrollToBottom(); + }); + + installEventFilter(&m_copyFilter); +} + +void LogList::onNewEntry() +{ + m_timer.start(std::chrono::milliseconds(10)); +} + +void LogList::setCore(OrganizerCore& core) +{ + m_core = &core; +} + +void LogList::copyToClipboard() +{ + std::string s; + + for (const auto& e : LogModel::instance().entries()) { + s += e.formattedMessage + "\n"; + } + + if (!s.empty()) { + // last newline + s.pop_back(); + } + + QApplication::clipboard()->setText(QString::fromStdString(s)); +} + +void LogList::clear() +{ + static_cast(model())->clear(); +} + +void LogList::openLogsFolder() +{ + const QString logsPath = qApp->property("dataPath").toString() + "/logs"; + shell::Explore(logsPath); +} + +QMenu* LogList::createMenu(QWidget* parent) +{ + auto* menu = new QMenu(parent); + + menu->addAction(tr("Copy"), [&] { + m_copyFilter.copySelection(); + }); + menu->addAction(tr("&Copy all"), [&] { + copyToClipboard(); + }); + menu->addSeparator(); + menu->addAction(tr("C&lear all"), [&] { + clear(); + }); + menu->addAction(tr("&Open logs folder"), [&] { + openLogsFolder(); + }); + + auto* levels = new QMenu(tr("&Level")); + menu->addMenu(levels); + + auto* ag = new QActionGroup(menu); + + auto addAction = [&](auto&& text, auto&& level) { + auto* a = new QAction(text, ag); + + a->setCheckable(true); + a->setChecked(log::getDefault().level() == level); + + connect(a, &QAction::triggered, [this, level] { + if (m_core) { + m_core->setLogLevel(level); + } + }); + + levels->addAction(a); + }; + + addAction(tr("&Debug"), log::Debug); + addAction(tr("&Info"), log::Info); + addAction(tr("&Warnings"), log::Warning); + addAction(tr("&Errors"), log::Error); + + return menu; +} + +void LogList::onContextMenu(const QPoint& pos) +{ + auto* menu = createMenu(this); + menu->popup(viewport()->mapToGlobal(pos)); +} + +log::Levels convertQtLevel(QtMsgType t) +{ + switch (t) { + case QtDebugMsg: + return log::Debug; + + case QtWarningMsg: + return log::Warning; + + case QtCriticalMsg: // fall-through + case QtFatalMsg: + return log::Error; + + case QtInfoMsg: // fall-through + default: + return log::Info; + } +} + +void qtLogCallback(QtMsgType type, const QMessageLogContext& context, + const QString& message) +{ + std::string_view file; + + if (type != QtDebugMsg) { + if (context.file) { + file = context.file; + + const auto lastSep = file.find_last_of("/\\"); + if (lastSep != std::string_view::npos) { + file = {context.file + lastSep + 1}; + } + } + } + + if (file.empty()) { + log::log(convertQtLevel(type), "{}", message.toStdString()); + } else { + log::log(convertQtLevel(type), "[{}:{}] {}", file, context.line, + message.toStdString()); + } +} + +void logToStdout(bool b) +{ + m_stdout = b; + + // logging to stdout is already set up in uibase by log::createDefault(), + // all it needs is to redirect stdout to the console, which is done by + // creating an env::Console object + + if (m_stdout) { + m_console.reset(new env::Console); + } else { + m_console.reset(); + } +} + +void initLogging() +{ + LogModel::create(); + + log::LoggerConfiguration conf; + conf.maxLevel = MOBase::log::Debug; + conf.pattern = "%^[%Y-%m-%d %H:%M:%S.%e %L] %v%$"; + conf.utc = true; + + log::createDefault(conf); + + log::getDefault().setCallback([](log::Entry e) { + LogModel::instance().add(e); + }); + + const char* username = std::getenv("USERNAME"); + if (username == nullptr || username[0] == '\0') { + username = std::getenv("USER"); + } + + if (username != nullptr && username[0] != '\0') { + log::getDefault().addToBlacklist(std::string("\\") + username, "\\USERNAME"); + log::getDefault().addToBlacklist(std::string("/") + username, "/USERNAME"); + } + + qInstallMessageHandler(qtLogCallback); +} + +bool createAndMakeWritable(const std::wstring& subPath) +{ + QString const dataPath = qApp->property("dataPath").toString(); + QString const fullPath = dataPath + "/" + QString::fromStdWString(subPath); + + if (!QDir(fullPath).exists() && !QDir().mkdir(fullPath)) { + QMessageBox::critical(nullptr, QObject::tr("Error"), + QObject::tr("Failed to create \"%1\". Your user " + "account probably lacks permission.") + .arg(fullPath)); + return false; + } else { + return true; + } +} + +bool setLogDirectory(const QString& dir) +{ + // Place logs in the instance directory so each instance has its own logs. + const QString logDir = dir + "/logs"; + QDir().mkpath(logDir); + + // Create a timestamped log file for this session. + const QString timestamp = + QDateTime::currentDateTime().toString("yyyy-MM-dd_HH-mm-ss"); + const auto logFile = logDir + "/mo_interface_" + timestamp + ".log"; + + // Clean up old logs, keeping only the most recent (numLogFiles - 1) so that + // together with the new one we have numLogFiles total. + { + QDir d(logDir); + QStringList existing = d.entryList({"mo_interface_*.log"}, QDir::Files, QDir::Name); + const int keep = std::max(0, AppConfig::numLogFiles() - 1); + while (existing.size() > keep) { + d.remove(existing.takeFirst()); + } + } + + log::getDefault().setFile(MOBase::log::File::single(logFile.toStdWString())); + + return true; +} diff --git a/src/src/mainwindow.cpp b/src/src/mainwindow.cpp index e96a4ea..e429138 100644 --- a/src/src/mainwindow.cpp +++ b/src/src/mainwindow.cpp @@ -1,4178 +1,4174 @@ -/* -Copyright (C) 2012 Sebastian Herbord. All rights reserved. - -This file is part of Mod Organizer. - -Mod Organizer is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -Mod Organizer is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with Mod Organizer. If not, see . -*/ - -#include "mainwindow.h" -#include "ui_mainwindow.h" - -#include - -#include "aboutdialog.h" -#ifdef MO2_WEBENGINE -#include "browserdialog.h" -#endif -#include "categories.h" -#include "categoriesdialog.h" -#include "datatab.h" -#include "filetree.h" -#include "downloadlist.h" -#include "downloadstab.h" -#include "editexecutablesdialog.h" -#include "envshortcut.h" -#include "eventfilter.h" -#include "executableinfo.h" -#include "executableslist.h" -#include "filedialogmemory.h" -#include "filterlist.h" -#include "guessedvalue.h" -#include "imodinterface.h" -#include "installationmanager.h" -#include "instancemanager.h" -#include "instancemanagerdialog.h" -#include "iplugindiagnose.h" -#include "iplugingame.h" -#include "isavegame.h" -#include "isavegameinfowidget.h" -#include "listdialog.h" -#include "localsavegames.h" -#include "messagedialog.h" -#include "modlist.h" -#include "modlistcontextmenu.h" -#include "modlistviewactions.h" -#include "motddialog.h" -#include "nexusinterface.h" -#include "nxmaccessmanager.h" -#include "organizercore.h" -#include "overwriteinfodialog.h" -#include "pluginlist.h" -#include "previewdialog.h" -#include "previewgenerator.h" -#include "problemsdialog.h" -#include "profile.h" -#include "profilesdialog.h" -#include "savestab.h" -#include "selectiondialog.h" -#include "serverinfo.h" -#include "settingsdialog.h" -#include "shared/appconfig.h" -#include "spawn.h" -#include "statusbar.h" -#include "systemtraymanager.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include "fluorinepaths.h" - -#include "directoryrefresher.h" -#include "shared/directoryentry.h" -#include "shared/fileentry.h" -#include "shared/filesorigin.h" - -#include "slrmanager.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#ifdef MO2_WEBENGINE -#include -#endif -#include -#include - -#include -#include - -#ifndef Q_MOC_RUN -#include -#include -#include -#include -#include -#endif - -#include -#include -#include -#include -#include -#include -#include -#include - -#include "gameplugins.h" - -#ifdef TEST_MODELS -#include "modeltest.h" -#endif // TEST_MODELS - -#pragma warning(disable : 4428) - -using namespace MOBase; -using namespace MOShared; - -const QSize SmallToolbarSize(24, 24); -const QSize MediumToolbarSize(32, 32); -const QSize LargeToolbarSize(42, 36); - -QString UnmanagedModName() -{ - return QObject::tr(""); -} - -void setFilterShortcuts(QWidget* widget, QLineEdit* edit) -{ - auto activate = [=] { - edit->setFocus(); - edit->selectAll(); - }; - - auto reset = [=] { - edit->clear(); - widget->setFocus(); - }; - - auto hookActivate = [activate](auto* w) { - auto* s = new QShortcut(QKeySequence::Find, w); - s->setAutoRepeat(false); - s->setContext(Qt::WidgetWithChildrenShortcut); - QObject::connect(s, &QShortcut::activated, activate); - }; - - auto hookReset = [reset](auto* w) { - auto* s = new QShortcut(QKeySequence(Qt::Key_Escape), w); - s->setAutoRepeat(false); - s->setContext(Qt::WidgetWithChildrenShortcut); - QObject::connect(s, &QShortcut::activated, reset); - }; - - hookActivate(widget); - hookReset(widget); - - hookActivate(edit); - hookReset(edit); -} - -MainWindow::MainWindow(Settings& settings, OrganizerCore& organizerCore, - PluginContainer& pluginContainer, QWidget* parent) - : QMainWindow(parent), ui(new Ui::MainWindow), m_Tutorial(this, "MainWindow"), - - m_CategoryFactory(CategoryFactory::instance()), m_OrganizerCore(organizerCore), - m_PluginContainer(pluginContainer), - m_ArchiveListWriter(std::bind(&MainWindow::saveArchiveList, this)), - m_NumberOfProblems(0), - m_ProblemsCheckRequired(false) -{ - // disables incredibly slow menu fade in effect that looks and feels like crap. - // this was only happening to users with the windows - // "Fade or slide menus into view" effect enabled. - // maybe in the future the effects will be better at this moment they aren't. - QApplication::setEffectEnabled(Qt::UI_FadeMenu, false); - QApplication::setEffectEnabled(Qt::UI_AnimateMenu, false); - QApplication::setEffectEnabled(Qt::UI_AnimateCombo, false); - QApplication::setEffectEnabled(Qt::UI_AnimateTooltip, false); - QApplication::setEffectEnabled(Qt::UI_FadeTooltip, false); - // setDesktopFileName moved to MOApplication constructor (must be set before - // first window surface for Wayland app_id to take effect) - -#ifdef MO2_WEBENGINE - QWebEngineProfile::defaultProfile()->setPersistentCookiesPolicy( - QWebEngineProfile::NoPersistentCookies); - QWebEngineProfile::defaultProfile()->setHttpCacheMaximumSize(52428800); - QWebEngineProfile::defaultProfile()->setCachePath(settings.paths().cache()); - QWebEngineProfile::defaultProfile()->setPersistentStoragePath( - settings.paths().cache()); - - // qt resets the thread name somewhere within the QWebEngineProfile calls - // above - MOShared::SetThisThreadName("main"); -#endif - - ui->setupUi(this); - languageChange(settings.interface().language()); - ui->statusBar->setup(ui, settings); - - m_SystemTrayManager = new SystemTrayManager(this, ui->logDock); - - { - auto& ni = NexusInterface::instance(); - - // there are two ways to get here: - // 1) the user just started MO, and - // 2) the user has changed some setting that required a restart - // - // "restarting" MO doesn't actually re-execute the binary, it just basically - // executes most of main() again, so a bunch of things are actually not - // reset - // - // one of these things is the api status, which will have fired its events - // long before the execution gets here because stuff is still cached and no - // real request to nexus is actually done - // - // therefore, when the user starts MO normally, the user account and stats - // will be empty (which is fine) and populated later on when the api key - // check has finished - // - // in the rare case where the user restarts MO through the settings, this - // will correctly pick up the previous values - updateWindowTitle(ni.getAPIUserAccount()); - ui->statusBar->setAPI(ni.getAPIStats(), ni.getAPIUserAccount()); - } - - m_CategoryFactory.loadCategories(); - - ui->logList->setCore(m_OrganizerCore); - - setupToolbar(); - toggleMO2EndorseState(); - toggleUpdateAction(); - - TaskProgressManager::instance().tryCreateTaskbar(); - - setupModList(); - ui->espList->setup(m_OrganizerCore, this, ui); - ui->bsaList->setLocalMoveOnly(true); - ui->bsaList->setHeaderHidden(true); - - const bool pluginListAdjusted = - settings.geometry().restoreState(ui->espList->header()); - - // data tab - m_DataTab.reset(new DataTab(m_OrganizerCore, m_PluginContainer, this, ui)); - m_DataTab->restoreState(settings); - - connect(m_DataTab.get(), &DataTab::executablesChanged, [&] { - refreshExecutablesList(); - }); - - connect(m_DataTab.get(), &DataTab::originModified, [&](int id) { - originModified(id); - }); - - connect(m_DataTab.get(), &DataTab::displayModInformation, - [&](auto&& m, auto&& i, auto&& tab) { - displayModInformation(m, i, tab); - }); - - // downloads tab - m_DownloadsTab.reset(new DownloadsTab(m_OrganizerCore, ui)); - - // saves tab - m_SavesTab.reset(new SavesTab(this, m_OrganizerCore, ui)); - - // Hide stuff we do not need: - auto& features = m_OrganizerCore.gameFeatures(); - if (!features.gameFeature()) { - ui->tabWidget->removeTab(ui->tabWidget->indexOf(ui->espTab)); - } - if (!features.gameFeature()) { - ui->tabWidget->removeTab(ui->tabWidget->indexOf(ui->bsaTab)); - } - - settings.geometry().restoreState(ui->downloadView->header()); - settings.geometry().restoreState(ui->savegameList->header()); - - ui->splitter->setStretchFactor(0, 3); - ui->splitter->setStretchFactor(1, 2); - - resizeLists(pluginListAdjusted); - - QMenu* linkMenu = new QMenu(this); - m_LinkToolbar = linkMenu->addAction(QIcon(":/MO/gui/link"), tr("Toolbar and Menu"), - this, SLOT(linkToolbar())); - m_LinkDesktop = linkMenu->addAction(QIcon(":/MO/gui/link"), tr("Desktop"), this, - SLOT(linkDesktop())); - m_LinkStartMenu = linkMenu->addAction(QIcon(":/MO/gui/link"), - tr("Application Launcher"), this, - SLOT(linkMenu())); - ui->linkButton->setMenu(linkMenu); - - ui->listOptionsBtn->setMenu( - new ModListGlobalContextMenu(m_OrganizerCore, ui->modList, this)); - - ui->openFolderMenu->setMenu(openFolderMenu()); - - // don't allow mouse wheel to switch grouping, too many people accidentally - // turn on grouping and then don't understand what happened - EventFilter* noWheel = new EventFilter(this, [](QObject*, QEvent* event) -> bool { - return event->type() == QEvent::Wheel; - }); - - ui->groupCombo->installEventFilter(noWheel); - ui->profileBox->installEventFilter(noWheel); - - connect(&m_PluginContainer, SIGNAL(diagnosisUpdate()), this, - SLOT(scheduleCheckForProblems())); - - connect(&m_OrganizerCore, &OrganizerCore::directoryStructureReady, this, - &MainWindow::onDirectoryStructureChanged); - - // OrganizerCore::modStatusChanged updates the DirectoryStructure in place - // but never emits directoryStructureReady, so without this the Data tab - // stays stale until the user clicks Refresh. Route the ModList signal to - // DataTab::updateTree directly. - connect(m_OrganizerCore.modList(), &ModList::modStatesChanged, this, - [this] { m_DataTab->updateTree(); }); - connect(m_OrganizerCore.directoryRefresher(), - SIGNAL(progress(const DirectoryRefreshProgress*)), this, - SLOT(refresherProgress(const DirectoryRefreshProgress*))); - connect(m_OrganizerCore.directoryRefresher(), SIGNAL(error(QString)), this, - SLOT(showError(QString))); - - connect(&m_OrganizerCore.settings(), SIGNAL(languageChanged(QString)), this, - SLOT(languageChange(QString))); - connect(&m_OrganizerCore.settings(), SIGNAL(styleChanged(QString)), this, - SIGNAL(styleChanged(QString))); - - connect(m_OrganizerCore.updater(), SIGNAL(restart()), this, SLOT(close())); - connect(m_OrganizerCore.updater(), SIGNAL(updateAvailable()), this, - SLOT(updateAvailable())); - connect(m_OrganizerCore.updater(), SIGNAL(motdAvailable(QString)), this, - SLOT(motdReceived(QString))); - connect(&NexusInterface::instance(), SIGNAL(requestNXMDownload(QString)), - &m_OrganizerCore, SLOT(downloadRequestedNXM(QString))); - connect(&NexusInterface::instance(), - SIGNAL(nxmDownloadURLsAvailable(QString, int, int, QVariant, QVariant, int)), - this, SLOT(nxmDownloadURLs(QString, int, int, QVariant, QVariant, int))); - connect(&NexusInterface::instance(), SIGNAL(needLogin()), &m_OrganizerCore, - SLOT(nexusApi())); - - connect(NexusInterface::instance().getAccessManager(), - &NXMAccessManager::credentialsReceived, this, &MainWindow::updateWindowTitle); - connect(&NexusInterface::instance(), &NexusInterface::requestsChanged, ui->statusBar, - &StatusBar::setAPI); - - connect(&TutorialManager::instance(), SIGNAL(windowTutorialFinished(QString)), this, - SLOT(windowTutorialFinished(QString))); - connect(ui->tabWidget, SIGNAL(currentChanged(int)), &TutorialManager::instance(), - SIGNAL(tabChanged(int))); - connect(ui->toolBar, SIGNAL(customContextMenuRequested(QPoint)), this, - SLOT(toolBar_customContextMenuRequested(QPoint))); - connect(ui->menuToolbars, &QMenu::aboutToShow, [&] { - updateToolbarMenu(); - }); - connect(ui->menuView, &QMenu::aboutToShow, [&] { - updateViewMenu(); - }); - connect(ui->actionTool->menu(), &QMenu::aboutToShow, [&] { - updateToolMenu(); - }); - connect(&m_PluginContainer, &PluginContainer::pluginEnabled, this, - [this](IPlugin* plugin) { - if (m_PluginContainer.implementInterface(plugin)) { - updateModPageMenu(); - } - }); - connect(&m_PluginContainer, &PluginContainer::pluginDisabled, this, - [this](IPlugin* plugin) { - if (m_PluginContainer.implementInterface(plugin)) { - updateModPageMenu(); - } - }); - connect(&m_PluginContainer, &PluginContainer::pluginRegistered, this, - &MainWindow::onPluginRegistrationChanged); - connect(&m_PluginContainer, &PluginContainer::pluginUnregistered, this, - &MainWindow::onPluginRegistrationChanged); - - connect(&m_OrganizerCore, &OrganizerCore::modInstalled, this, - &MainWindow::modInstalled); - - connect(&m_CategoryFactory, SIGNAL(nexusCategoryRefresh(CategoriesDialog*)), this, - SLOT(refreshNexusCategories(CategoriesDialog*))); - connect(&m_CategoryFactory, SIGNAL(categoriesSaved()), this, SLOT(categoriesSaved())); - - m_CheckBSATimer.setSingleShot(true); - connect(&m_CheckBSATimer, SIGNAL(timeout()), this, SLOT(checkBSAList())); - - setFilterShortcuts(ui->modList, ui->modFilterEdit); - setFilterShortcuts(ui->espList, ui->espFilterEdit); - setFilterShortcuts(ui->downloadView, ui->downloadFilterEdit); - - m_UpdateProblemsTimer.setSingleShot(true); - connect(&m_UpdateProblemsTimer, &QTimer::timeout, this, - &MainWindow::checkForProblemsAsync); - connect(this, &MainWindow::checkForProblemsDone, this, - &MainWindow::updateProblemsButton, Qt::ConnectionType::QueuedConnection); - - m_SaveMetaTimer.setSingleShot(false); - connect(&m_SaveMetaTimer, SIGNAL(timeout()), this, SLOT(saveModMetas())); - m_SaveMetaTimer.start(5000); - - FileDialogMemory::restore(settings); - - fixCategories(); - - m_StartTime = QTime::currentTime(); - - m_Tutorial.expose("modList", m_OrganizerCore.modList()); - m_Tutorial.expose("espList", m_OrganizerCore.pluginList()); - - m_OrganizerCore.setUserInterface(this); - m_OrganizerCore.onFinishedRun([=, this](const QString, unsigned int) { - if (isHidden()) { - m_SystemTrayManager->restoreFromSystemTray(); - } - }); - - connect(m_OrganizerCore.modList(), &ModList::showMessage, [=, this](auto&& message) { - showMessage(message); - }); - connect(m_OrganizerCore.modList(), &ModList::modRenamed, - [=, this](auto&& oldName, auto&& newName) { - modRenamed(oldName, newName); - }); - connect(m_OrganizerCore.modList(), &ModList::modUninstalled, [=, this](auto&& name) { - modRemoved(name); - }); - connect(m_OrganizerCore.modList(), &ModList::fileMoved, [=, this](auto&&... args) { - fileMoved(args...); - }); - connect(m_OrganizerCore.installationManager(), &InstallationManager::modReplaced, - [=, this](auto&& name) { - modRemoved(name); - }); - connect(m_OrganizerCore.downloadManager(), &DownloadManager::showMessage, - [=, this](auto&& message) { - showMessage(message); - }); - for (const QString& fileName : m_PluginContainer.pluginFileNames()) { - installTranslator(QFileInfo(fileName).baseName()); - } - - updateModPageMenu(); - - // refresh profiles so the current profile can be activated - refreshProfiles(false); - - ui->profileBox->setCurrentText(m_OrganizerCore.currentProfile()->name()); - - if (settings.archiveParsing()) { - ui->dataTabShowFromArchives->setCheckState(Qt::Checked); - ui->dataTabShowFromArchives->setEnabled(true); - } else { - ui->dataTabShowFromArchives->setCheckState(Qt::Unchecked); - ui->dataTabShowFromArchives->setEnabled(false); - } - - QApplication::instance()->installEventFilter(this); - - scheduleCheckForProblems(); - refreshExecutablesList(); - updatePinnedExecutables(); - resetActionIcons(); - resetButtonIcons(); - processUpdates(); - - ui->modList->updateModCount(); - ui->espList->updatePluginCount(); - ui->statusBar->updateNormalMessage(m_OrganizerCore); -} - -void MainWindow::setupModList() -{ - ui->modList->setup(m_OrganizerCore, m_CategoryFactory, this, ui); - - connect(&ui->modList->actions(), &ModListViewActions::overwriteCleared, [=, this]() { - scheduleCheckForProblems(); - }); - connect(&ui->modList->actions(), &ModListViewActions::originModified, this, - &MainWindow::originModified); - connect(&ui->modList->actions(), &ModListViewActions::modInfoDisplayed, this, - &MainWindow::modInfoDisplayed); - - connect(m_OrganizerCore.modList(), &ModList::modPrioritiesChanged, [&]() { - m_ArchiveListWriter.write(); - }); -} - -void MainWindow::resetActionIcons() -{ - // this is a bit of a hack - // - // the .qss files have historically set qproperty-icon by id and these ids - // correspond to the QActions created in the .ui file - // - // the problem is that QActions do not support having their icon property - // set from a .qss because they're not widgets (they don't inherit from - // QWidget), and styling only works on widget - // - // a QAction _does_ have an associated icon, it just can't be set from a .qss - // file - // - // so here, a dummy QToolButton widget is created for each QAction and is - // given the same name as the action, which makes it pick up the icon - // specified in the .qss file - // - // that icon is then given to the widget used by the QAction (if it's some - // sort of button, which typically happens on the toolbar) _and_ to the - // QAction itself, which is used in the menu bar - - // clearing the notification, will be set below if the stylesheet has set - // anything for it - m_originalNotificationIcon = {}; - - // QActions created from the .ui file are children of the main window - for (QAction* action : findChildren()) { - // creating a dummy button - auto dummy = std::make_unique(); - - // reusing the action name - dummy->setObjectName(action->objectName()); - - // styling the button, this has to be done manually because the button is - // never added anywhere - style()->polish(dummy.get()); - - // the button's icon may be null if it wasn't specified in the .qss file, - // which can happen if the stylesheet just doesn't override icons, or for - // other actions like the pinned custom executables - const auto icon = dummy->icon(); - if (icon.isNull()) { - continue; - } - - // button associated with the action on the toolbar - QWidget* actionWidget = ui->toolBar->widgetForAction(action); - - if (auto* actionButton = dynamic_cast(actionWidget)) { - actionButton->setIcon(icon); - } - - // the action's icon is used by the menu bar - action->setIcon(icon); - - if (action == ui->actionNotifications) { - // if the stylesheet has set a notification icon, remember it here so it - // can be used in updateProblemsButton() - m_originalNotificationIcon = icon; - } - } - - // update the button for the potentially new icon - updateProblemsButton(); -} - -void MainWindow::resetButtonIcons() -{ - // Some icon-only QPushButtons can lose their icons after stylesheet repolish on - // Linux/AppImage. Re-apply explicit resource icons to keep these controls visible. - ui->listOptionsBtn->setIcon(QIcon::fromTheme( - "preferences-system", QIcon(":/MO/gui/settings"))); - ui->openFolderMenu->setIcon( - QIcon::fromTheme("folder-open", QIcon(":/MO/gui/open_folder"))); - ui->restoreModsButton->setIcon( - QIcon::fromTheme("edit-undo", QIcon(":/MO/gui/restore"))); - ui->saveModsButton->setIcon( - QIcon::fromTheme("document-save", QIcon(":/MO/gui/backup"))); - ui->restoreButton->setIcon( - QIcon::fromTheme("edit-undo", QIcon(":/MO/gui/restore"))); - ui->saveButton->setIcon(QIcon::fromTheme("document-save", QIcon(":/MO/gui/backup"))); - - ui->listOptionsBtn->setIconSize(QSize(16, 16)); - ui->openFolderMenu->setIconSize(QSize(16, 16)); - ui->restoreModsButton->setIconSize(QSize(16, 16)); - ui->saveModsButton->setIconSize(QSize(16, 16)); - ui->restoreButton->setIconSize(QSize(16, 16)); - ui->saveButton->setIconSize(QSize(16, 16)); -} - -MainWindow::~MainWindow() -{ - try { - cleanup(); - - m_OrganizerCore.setUserInterface(nullptr); - -#ifdef MO2_WEBENGINE - if (m_IntegratedBrowser) { - m_IntegratedBrowser->close(); - m_IntegratedBrowser.reset(); - } -#endif - - delete ui; - } catch (std::exception& e) { - QMessageBox::critical( - nullptr, tr("Crash on exit"), - tr("MO crashed while exiting. Some settings may not be saved.\n\nError: %1") - .arg(e.what()), - QMessageBox::Ok); - } -} - -void MainWindow::updateWindowTitle(const APIUserAccount& user) -{ - //"\xe2\x80\x93" is an "em dash", a longer "-" -#if FLUORINE_IS_BETA_BUILD - // Beta builds mutate on every CI run, so a fixed semver is misleading — - // show the short commit hash instead so users can tell exactly which - // build they're on at a glance. - const QString commitShort = QStringLiteral(FLUORINE_BUILD_COMMIT); - const QString versionLabel = - commitShort.isEmpty() - ? QStringLiteral("beta") - : (QStringLiteral("beta @ ") + commitShort); -#else - const QString versionLabel = - m_OrganizerCore.getVersion().string(Version::FormatCondensed); -#endif - - QString title = - QString("%1 \xe2\x80\x93 Fluorine Manager %2") - .arg(m_OrganizerCore.managedGame()->displayGameName(), versionLabel); - - if (!user.name().isEmpty()) { - const QString premium = (user.type() == APIUserAccountTypes::Premium ? "*" : ""); - title.append(QString(" (%1%2)").arg(user.name(), premium)); - } - - this->setWindowTitle(title); -} - -void MainWindow::resizeLists(bool pluginListCustom) -{ - // ensure the columns aren't so small you can't see them any more - for (int i = 0; i < ui->modList->header()->count(); ++i) { - if (ui->modList->header()->sectionSize(i) < 10) { - ui->modList->header()->resizeSection(i, 10); - } - } - - if (!pluginListCustom) { - // resize plugin list to fit content - for (int i = 0; i < ui->espList->header()->count(); ++i) { - ui->espList->header()->setSectionResizeMode(i, QHeaderView::ResizeToContents); - } - ui->espList->header()->setSectionResizeMode(0, QHeaderView::Stretch); - } -} - -void MainWindow::allowListResize() -{ - // allow resize on mod list - for (int i = 0; i < ui->modList->header()->count(); ++i) { - ui->modList->header()->setSectionResizeMode(i, QHeaderView::Interactive); - } - ui->modList->header()->setStretchLastSection(true); - - // allow resize on plugin list - for (int i = 0; i < ui->espList->header()->count(); ++i) { - ui->espList->header()->setSectionResizeMode(i, QHeaderView::Interactive); - } - ui->espList->header()->setStretchLastSection(true); -} - -void MainWindow::updateStyle(const QString&) -{ - resetActionIcons(); - resetButtonIcons(); -} - -void MainWindow::resizeEvent(QResizeEvent* event) -{ - m_Tutorial.resize(event->size()); - QMainWindow::resizeEvent(event); -} - -void MainWindow::setupToolbar() -{ - setupActionMenu(ui->actionModPage); - setupActionMenu(ui->actionTool); - setupActionMenu(ui->actionHelp); - setupActionMenu(ui->actionEndorseMO); - - createHelpMenu(); - createEndorseMenu(); - - // find last separator, add a spacer just before it so the icons are - // right-aligned - m_linksSeparator = nullptr; - for (auto* a : ui->toolBar->actions()) { - if (a->isSeparator()) { - m_linksSeparator = a; - } - } - - if (m_linksSeparator) { - auto* spacer = new QWidget(ui->toolBar); - spacer->setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Preferred); - ui->toolBar->insertWidget(m_linksSeparator, spacer); - - } else { - log::warn("no separator found on the toolbar, icons won't be right-aligned"); - } - - if (!InstanceManager::singleton().allowedToChangeInstance()) { - ui->actionChange_Game->setVisible(false); - } -} - -void MainWindow::setupActionMenu(QAction* a) -{ - a->setMenu(new QMenu(this)); - - auto* w = ui->toolBar->widgetForAction(a); - if (auto* tb = dynamic_cast(w)) - tb->setPopupMode(QToolButton::InstantPopup); -} - -void MainWindow::updatePinnedExecutables() -{ - for (auto* a : ui->toolBar->actions()) { - if (a->objectName().startsWith("custom__")) { - ui->toolBar->removeAction(a); - a->deleteLater(); - } - } - - ui->menuRun->clear(); - - bool hasLinks = false; - - for (const auto& exe : *m_OrganizerCore.executablesList()) { - if (!exe.hide() && exe.isShownOnToolbar()) { - hasLinks = true; - - QAction* exeAction = - new QAction(iconForExecutable(exe.binaryInfo().filePath()), exe.title()); - - exeAction->setObjectName(QString("custom__") + exe.title()); - exeAction->setStatusTip(exe.binaryInfo().filePath()); - - if (!connect(exeAction, SIGNAL(triggered()), this, SLOT(startExeAction()))) { - log::debug("failed to connect trigger?"); - } - - if (m_linksSeparator) { - ui->toolBar->insertAction(m_linksSeparator, exeAction); - } else { - // separator wasn't found, add it to the end - ui->toolBar->addAction(exeAction); - } - - ui->menuRun->addAction(exeAction); - } - } - - // don't show the menu if there are no links - ui->menuRun->menuAction()->setVisible(hasLinks); -} - -void MainWindow::updateToolbarMenu() -{ - ui->actionMainMenuToggle->setChecked(ui->menuBar->isVisible()); - ui->actionToolBarMainToggle->setChecked(ui->toolBar->isVisible()); - ui->actionStatusBarToggle->setChecked(ui->statusBar->isVisible()); - - ui->actionToolBarSmallIcons->setChecked(ui->toolBar->iconSize() == SmallToolbarSize); - ui->actionToolBarMediumIcons->setChecked(ui->toolBar->iconSize() == - MediumToolbarSize); - ui->actionToolBarLargeIcons->setChecked(ui->toolBar->iconSize() == LargeToolbarSize); - - ui->actionToolBarIconsOnly->setChecked(ui->toolBar->toolButtonStyle() == - Qt::ToolButtonIconOnly); - ui->actionToolBarTextOnly->setChecked(ui->toolBar->toolButtonStyle() == - Qt::ToolButtonTextOnly); - ui->actionToolBarIconsAndText->setChecked(ui->toolBar->toolButtonStyle() == - Qt::ToolButtonTextUnderIcon); -} - -void MainWindow::updateViewMenu() -{ - ui->actionViewLog->setChecked(ui->logDock->isVisible()); -} - -QMenu* MainWindow::createPopupMenu() -{ - auto* m = new QMenu; - - // add all the actions from the toolbars menu - for (auto* a : ui->menuToolbars->actions()) { - m->addAction(a); - } - - m->addSeparator(); - - // other actions - m->addAction(ui->actionViewLog); - - // make sure the actions are updated - updateToolbarMenu(); - updateViewMenu(); - - return m; -} - -void MainWindow::on_actionMainMenuToggle_triggered() -{ - ui->menuBar->setVisible(!ui->menuBar->isVisible()); -} - -void MainWindow::on_actionToolBarMainToggle_triggered() -{ - ui->toolBar->setVisible(!ui->toolBar->isVisible()); -} - -void MainWindow::on_actionStatusBarToggle_triggered() -{ - ui->statusBar->setVisible(!ui->statusBar->isVisible()); -} - -void MainWindow::on_actionToolBarSmallIcons_triggered() -{ - setToolbarSize(SmallToolbarSize); -} - -void MainWindow::on_actionToolBarMediumIcons_triggered() -{ - setToolbarSize(MediumToolbarSize); -} - -void MainWindow::on_actionToolBarLargeIcons_triggered() -{ - setToolbarSize(LargeToolbarSize); -} - -void MainWindow::on_actionToolBarIconsOnly_triggered() -{ - setToolbarButtonStyle(Qt::ToolButtonIconOnly); -} - -void MainWindow::on_actionToolBarTextOnly_triggered() -{ - setToolbarButtonStyle(Qt::ToolButtonTextOnly); -} - -void MainWindow::on_actionToolBarIconsAndText_triggered() -{ - setToolbarButtonStyle(Qt::ToolButtonTextUnderIcon); -} - -void MainWindow::on_actionViewLog_triggered() -{ - ui->logDock->setVisible(!ui->logDock->isVisible()); -} - -void MainWindow::setToolbarSize(const QSize& s) -{ - for (auto* tb : findChildren()) { - tb->setIconSize(s); - } -} - -void MainWindow::setToolbarButtonStyle(Qt::ToolButtonStyle s) -{ - for (auto* tb : findChildren()) { - tb->setToolButtonStyle(s); - } -} - -void MainWindow::on_centralWidget_customContextMenuRequested(const QPoint& pos) -{ - // this allows for getting the context menu even if both the menubar and all - // the toolbars are hidden; an alternative is the Alt key handled in - // keyPressEvent() below - - // the custom context menu event bubbles up to here if widgets don't actually - // process this, which would show the menu when right-clicking button, labels, - // etc. - // - // only show the context menu when right-clicking on the central widget - // itself, which is basically just the outer edges of the main window - auto* w = childAt(pos); - if (w != ui->centralWidget) { - return; - } - - createPopupMenu()->exec(ui->centralWidget->mapToGlobal(pos)); -} - -void MainWindow::scheduleCheckForProblems() -{ - if (!m_UpdateProblemsTimer.isActive()) { - m_UpdateProblemsTimer.start(500); - } -} - -void MainWindow::updateProblemsButton() -{ - // if the current stylesheet doesn't provide an icon, this is used instead - const char* DefaultIconName = ":/MO/gui/warning"; - - const std::size_t numProblems = m_NumberOfProblems; - - // original icon without a count painted on it - const QIcon original = m_originalNotificationIcon.isNull() - ? QIcon(DefaultIconName) - : m_originalNotificationIcon; - - // final icon - QIcon final; - - if (numProblems > 0) { - ui->actionNotifications->setToolTip(tr("There are notifications to read")); - - // will contain the original icon, plus a notification count; this also - // makes sure the pixmap is exactly 64x64 by requesting the icon that's - // as close to 64x64 as possible, and then scaling it up if it's too small - QPixmap merged = original.pixmap(64, 64).scaled(64, 64); - - { - QPainter painter(&merged); - - const std::string badgeName = - std::string(":/MO/gui/badge_") + - (numProblems < 10 ? std::to_string(static_cast(numProblems)) - : "more"); - - painter.drawPixmap(32, 32, 32, 32, QPixmap(badgeName.c_str())); - } - - final = QIcon(merged); - } else { - ui->actionNotifications->setToolTip(tr("There are no notifications")); - - // no change - final = original; - } - - ui->actionNotifications->setEnabled(numProblems > 0); - - // setting the icon on the action (shown on the menu) - ui->actionNotifications->setIcon(final); - - // setting the icon on the toolbar button - if (auto* actionWidget = ui->toolBar->widgetForAction(ui->actionNotifications)) { - if (auto* button = dynamic_cast(actionWidget)) { - button->setIcon(final); - } - } - - // updating the status bar, may be null very early when MO is starting - if (ui->statusBar) { - ui->statusBar->setNotifications(numProblems > 0); - } -} - -bool MainWindow::errorReported(QString& logFile) -{ - QDir const dir(qApp->property("dataPath").toString() + "/" + - QString::fromStdWString(AppConfig::logPath())); - QFileInfoList const files = - dir.entryInfoList(QStringList("ModOrganizer_??_??_??_??_??.log"), QDir::Files, - QDir::Name | QDir::Reversed); - - if (files.count() > 0) { - logFile = files.at(0).absoluteFilePath(); - QFile file(logFile); - if (file.open(QIODevice::ReadOnly | QIODevice::Text)) { - char buffer[1024]; - int line = 0; - while (!file.atEnd()) { - file.readLine(buffer, 1024); - if (strncmp(buffer, "ERROR", 5) == 0) { - return true; - } - - // prevent this function from taking forever - if (line++ >= 50000) { - break; - } - } - } - } - - return false; -} - -QFuture MainWindow::checkForProblemsAsync() -{ - return QtConcurrent::run([this]() { - checkForProblemsImpl(); - }); -} - -void MainWindow::checkForProblemsImpl() -{ - m_ProblemsCheckRequired = true; - - std::scoped_lock const lk(m_CheckForProblemsMutex); - - // another thread might already have checked while this one was waiting on the lock - if (m_ProblemsCheckRequired) { - m_ProblemsCheckRequired = false; - TimeThis const tt("MainWindow::checkForProblemsImpl()"); - size_t numProblems = 0; - for (QObject* pluginObj : m_PluginContainer.plugins()) { - IPlugin* plugin = qobject_cast(pluginObj); - if (plugin == nullptr || m_PluginContainer.isEnabled(plugin)) { - IPluginDiagnose* diagnose = qobject_cast(pluginObj); - if (diagnose != nullptr) - numProblems += diagnose->activeProblems().size(); - } - } - m_NumberOfProblems = numProblems; - emit checkForProblemsDone(); - } -} - -void MainWindow::about() -{ - AboutDialog(m_OrganizerCore.getVersion().string(Version::FormatCondensed), this) - .exec(); -} - -void MainWindow::createEndorseMenu() -{ - auto* menu = ui->actionEndorseMO->menu(); - if (!menu) { - // shouldn't happen - return; - } - - menu->clear(); - - QAction* endorseAction = new QAction(tr("Endorse"), menu); - connect(endorseAction, SIGNAL(triggered()), this, SLOT(actionEndorseMO())); - menu->addAction(endorseAction); - - QAction* wontEndorseAction = new QAction(tr("Won't Endorse"), menu); - connect(wontEndorseAction, SIGNAL(triggered()), this, SLOT(actionWontEndorseMO())); - menu->addAction(wontEndorseAction); -} - -void MainWindow::createHelpMenu() -{ - //: Translation strings for tutorial names - static std::map translate = { - {"First Steps", QT_TR_NOOP("First Steps")}, - {"Conflict Resolution", QT_TR_NOOP("Conflict Resolution")}, - {"Overview", QT_TR_NOOP("Overview")}}; - - auto* menu = ui->actionHelp->menu(); - if (!menu) { - // this happens on startup because languageChanged() (which calls this) is - // called before the menus are actually created - return; - } - - menu->clear(); - - QAction* helpAction = new QAction(tr("Help on UI"), menu); - connect(helpAction, SIGNAL(triggered()), this, SLOT(helpTriggered())); - menu->addAction(helpAction); - - QAction* wikiAction = new QAction(tr("Documentation"), menu); - connect(wikiAction, SIGNAL(triggered()), this, SLOT(wikiTriggered())); - menu->addAction(wikiAction); - - if (!m_OrganizerCore.managedGame()->getSupportURL().isEmpty()) { - QAction* gameSupportAction = new QAction(tr("Game Support Wiki"), menu); - connect(gameSupportAction, SIGNAL(triggered()), this, SLOT(gameSupportTriggered())); - menu->addAction(gameSupportAction); - } - - QAction* discordAction = new QAction(tr("Chat on Discord"), menu); - connect(discordAction, SIGNAL(triggered()), this, SLOT(discordTriggered())); - menu->addAction(discordAction); - - QAction* issueAction = new QAction(tr("Report Issue"), menu); - connect(issueAction, SIGNAL(triggered()), this, SLOT(issueTriggered())); - menu->addAction(issueAction); - - QMenu* tutorialMenu = new QMenu(tr("Tutorials"), menu); - - using ActionList = std::vector>; - - ActionList tutorials; - - QDirIterator dirIter(QApplication::applicationDirPath() + "/tutorials", - QStringList("*.js"), QDir::Files); - while (dirIter.hasNext()) { - dirIter.next(); - QString fileName = dirIter.fileName(); - - QFile file(dirIter.filePath()); - if (!file.open(QIODevice::ReadOnly)) { - log::error("Failed to open {}", fileName); - continue; - } - QString const firstLine = QString::fromUtf8(file.readLine()); - if (firstLine.startsWith("//TL")) { - QStringList const params = firstLine.mid(4).trimmed().split('#'); - if (params.size() != 2) { - log::error("invalid header line for tutorial {}, expected 2 parameters", - fileName); - continue; - } - QAction* tutAction = new QAction(tr(translate[params.at(0)]), tutorialMenu); - tutAction->setData(fileName); - tutorials.push_back(std::make_pair(params.at(1).toInt(), tutAction)); - } - } - - std::sort(tutorials.begin(), tutorials.end(), - [](const ActionList::value_type& LHS, const ActionList::value_type& RHS) { - return LHS.first < RHS.first; - }); - - for (auto iter = tutorials.begin(); iter != tutorials.end(); ++iter) { - connect(iter->second, SIGNAL(triggered()), this, SLOT(tutorialTriggered())); - tutorialMenu->addAction(iter->second); - } - - menu->addMenu(tutorialMenu); - menu->addAction(tr("About"), this, SLOT(about())); - menu->addAction(tr("About Qt"), qApp, SLOT(aboutQt())); -} - -bool MainWindow::addProfile() -{ - QComboBox* profileBox = findChild("profileBox"); - bool okClicked = false; - - QString const name = QInputDialog::getText(this, tr("Name"), - tr("Please enter a name for the new profile"), - QLineEdit::Normal, QString(), &okClicked); - if (okClicked && (name.size() > 0)) { - try { - profileBox->addItem(name); - profileBox->setCurrentIndex(profileBox->count() - 1); - return true; - } catch (const std::exception& e) { - reportError(tr("failed to create profile: %1").arg(e.what())); - return false; - } - } else { - return false; - } -} - -void MainWindow::hookUpWindowTutorials() -{ - QDirIterator dirIter(QApplication::applicationDirPath() + "/tutorials", - QStringList("*.js"), QDir::Files); - while (dirIter.hasNext()) { - dirIter.next(); - QString fileName = dirIter.fileName(); - QFile file(dirIter.filePath()); - if (!file.open(QIODevice::ReadOnly)) { - log::error("Failed to open {}", fileName); - continue; - } - QString const firstLine = QString::fromUtf8(file.readLine()); - if (firstLine.startsWith("//WIN")) { - QString const windowName = firstLine.mid(6).trimmed(); - if (!m_OrganizerCore.settings().interface().isTutorialCompleted(windowName)) { - TutorialManager::instance().activateTutorial(windowName, fileName); - } - } - } -} - -bool MainWindow::shouldStartTutorial() -{ - if (GlobalSettings::hideTutorialQuestion()) { - return false; - } - - QMessageBox dlg( - QMessageBox::Question, tr("Show tutorial?"), - tr("You are starting Mod Organizer for the first time. " - "Do you want to show a tutorial of its basic features? If you choose " - "no you can always start the tutorial from the \"Help\" menu."), - QMessageBox::Yes | QMessageBox::No); - - dlg.setCheckBox(new QCheckBox(tr("Never ask to show tutorials"))); - - const auto r = dlg.exec(); - - if (dlg.checkBox()->isChecked()) { - GlobalSettings::setHideTutorialQuestion(true); - } - - return (r == QMessageBox::Yes); -} - -void MainWindow::showEvent(QShowEvent* event) -{ - QMainWindow::showEvent(event); - - if (!m_WasVisible) { - ui->modList->refreshFilters(); - readSettings(); - - // this needs to be connected here instead of in the constructor because the - // actual changing of the stylesheet is done by MOApplication, which - // connects its signal in runApplication() (in main.cpp), and that happens - // _after_ the MainWindow is constructed, but _before_ it is shown - // - // by connecting the event here, changing the style setting will first be - // handled by MOApplication, and then in updateStyle(), at which point the - // stylesheet has already been set correctly - connect(this, SIGNAL(styleChanged(QString)), this, SLOT(updateStyle(QString))); - - // only the first time the window becomes visible - m_Tutorial.registerControl(); - - hookUpWindowTutorials(); - - if (m_OrganizerCore.settings().firstStart()) { - QString firstStepsTutorial = ToQString(AppConfig::firstStepsTutorial()); - if (TutorialManager::instance().hasTutorial(firstStepsTutorial)) { - if (shouldStartTutorial()) { - TutorialManager::instance().activateTutorial("MainWindow", - firstStepsTutorial); - } - } else { - log::error("{} missing", firstStepsTutorial); - QPoint pos = ui->toolBar->mapToGlobal(QPoint()); - pos.rx() += ui->toolBar->width() / 2; - pos.ry() += ui->toolBar->height(); - QWhatsThis::showText(pos, - QObject::tr("Please use \"Help\" from the toolbar to get " - "usage instructions to all elements")); - } - - if (!m_OrganizerCore.managedGame()->getSupportURL().isEmpty()) { - QMessageBox::information(this, tr("Game Support Wiki"), - tr("Do you know how to mod this game? Do you need to " - "learn? There's a game support wiki available! " - "Click OK to open the wiki. In the future, you can " - "access this link from the \"Help\" menu."), - QMessageBox::Ok); - gameSupportTriggered(); - } - - QMessageBox newCatDialog; - newCatDialog.setWindowTitle(tr("Category Setup")); - newCatDialog.setText( - tr("Please choose how to handle the default category setup.\n\n" - "If you've already connected to Nexus, you can automatically import Nexus " - "categories for this game (if applicable). Otherwise, use the old Mod " - "Organizer default category structure, or leave the categories blank (for " - "manual setup).")); - QPushButton importBtn(tr("&Import Nexus Categories")); - QPushButton defaultBtn(tr("Use &Old Category Defaults")); - QPushButton cancelBtn(tr("Do &Nothing")); - if (NexusInterface::instance().getAccessManager()->validated()) { - newCatDialog.addButton(&importBtn, QMessageBox::ButtonRole::AcceptRole); - } - newCatDialog.addButton(&defaultBtn, QMessageBox::ButtonRole::AcceptRole); - newCatDialog.addButton(&cancelBtn, QMessageBox::ButtonRole::RejectRole); - newCatDialog.exec(); - if (newCatDialog.clickedButton() == &importBtn) { - importCategories(false); - } else if (newCatDialog.clickedButton() == &cancelBtn) { - m_CategoryFactory.reset(); - } else if (newCatDialog.clickedButton() == &defaultBtn) { - m_CategoryFactory.loadCategories(); - } - m_CategoryFactory.saveCategories(); - - m_OrganizerCore.settings().setFirstStart(false); - } else { - auto& settings = m_OrganizerCore.settings(); - // The category migration dialog was meant to fire once per upgrade - // across the upstream MO2 2.4 -> 2.5 cut. Fluorine now stores its own - // 0.x.y version in the same slot, so lastVersion < 2.5 is true on - // every Fluorine launch and the dialog would nag forever. Skip it - // when lastVersion looks like a Fluorine build. - const bool fluorineVersionScheme = - m_LastVersion.majorVersion() == 0; - if (!fluorineVersionScheme && - m_LastVersion < QVersionNumber(2, 5) && - !GlobalSettings::hideCategoryReminder()) { - QMessageBox migrateCatDialog; - migrateCatDialog.setWindowTitle("Category Migration"); - migrateCatDialog.setText( - tr("This is your first time running version 2.5 or higher with an old MO2 " - "instance. The category system now relies on an updated system to map " - "Nexus categories.\n\n" - "In order to assign Nexus categories automatically, you will need to " - "import the Nexus categories for the currently managed game and map " - "them to your preferred category structure.\n\n" - "You can either manually open the category editor, via the Settings " - "dialog or the category filter sidebar, and set up the mappings as you " - "see fit, or you can automatically import and map the categories as " - "defined on Nexus.\n\n" - "As a final option, you can disable Nexus category mapping altogether, " - "which can be changed at any time in the Settings dialog.")); - QPushButton importBtn(tr("&Import Nexus Categories")); - QPushButton openSettingsBtn(tr("&Open Categories Dialog")); - QPushButton disableBtn(tr("&Disable Nexus Mappings")); - QPushButton closeBtn(tr("&Close")); - QCheckBox dontShow(tr("&Don't show this again")); - if (NexusInterface::instance().getAccessManager()->validated()) { - migrateCatDialog.addButton(&importBtn, QMessageBox::ButtonRole::AcceptRole); - } - migrateCatDialog.addButton(&openSettingsBtn, - QMessageBox::ButtonRole::ActionRole); - migrateCatDialog.addButton(&disableBtn, - QMessageBox::ButtonRole::DestructiveRole); - migrateCatDialog.addButton(&closeBtn, QMessageBox::ButtonRole::RejectRole); - migrateCatDialog.setCheckBox(&dontShow); - migrateCatDialog.exec(); - if (migrateCatDialog.clickedButton() == &importBtn) { - importCategories(dontShow.isChecked()); - } else if (migrateCatDialog.clickedButton() == &openSettingsBtn) { - this->ui->filtersEdit->click(); - } else if (migrateCatDialog.clickedButton() == &disableBtn) { - Settings::instance().nexus().setCategoryMappings(false); - } - if (dontShow.isChecked()) { - GlobalSettings::setHideCategoryReminder(true); - } - } - } - - m_OrganizerCore.settings().widgets().restoreIndex(ui->groupCombo); - - m_OrganizerCore.settings().nexus().registerAsNXMHandler(false); - m_WasVisible = true; - updateProblemsButton(); - - // notify plugins that the MO2 is ready - m_PluginContainer.startPlugins(this); - - // forces a log list refresh to display startup logs - // - // since the log list is not visible until this point, the automatic - // resize of columns seems to break the log list (since Qt 5.15.1 or - // 5.15.2), an make the list empty on startup (in debug the list is not - // empty because some logs are added after the log list becomes visible) - // - // the reset() forces a re-computation of the column size, thus properly - // the logs that are already in the log model - // - ui->logList->reset(); - ui->logList->scrollToBottom(); - } -} - -void MainWindow::paintEvent(QPaintEvent* event) -{ - if (m_FirstPaint) { - allowListResize(); - m_FirstPaint = false; - } - - QMainWindow::paintEvent(event); -} - -void MainWindow::onBeforeClose() -{ - storeSettings(); -} - -void MainWindow::closeEvent(QCloseEvent* event) -{ - if (isVisible()) { - // this is messy - // - // the main problem this is solving is when closing MO, then getting the - // lock overlay because processes are still running, then pressing the X - // again - // - // in this case, closeEvent() is _not_ called for the second event and the - // window is immediately hidden - // - // this always saves the settings here; in the event where a lock overlay - // is then shown, it might save settings multiple times, but it's harmless - onBeforeClose(); - } - - // this happens for two reasons: - // 1) the user requested to close the window, such as clicking the X - // 2) close() is called in runApplication() after application.exec() - // returns, which happens when qApp->exit() is called - // - // the window must never actually close for 1), because settings haven't been - // saved yet: the state of many widgets is saved to the ini, which relies on - // the window still being onscreen (or else everything is considered hidden) - // - // for 2), the settings have been saved and the window can just close - - if (ModOrganizerCanCloseNow()) { - // the user has confirmed if necessary and all settings have been saved, - // just close it - QMainWindow::closeEvent(event); - return; - } - - if (UILocker::instance().locked()) { - // don't bother asking the user to confirm if the ui is already locked - event->ignore(); - ExitModOrganizer(Exit::Force); - return; - } - - if (ModOrganizerExiting()) { - // ignore repeated attempts - event->ignore(); - return; - } - - // never close the window because settings might need to be changed - event->ignore(); - - // start the process of exiting, which may require confirmation by calling - // canExit(), among other things - ExitModOrganizer(); -} - -bool MainWindow::canExit() -{ - if (m_OrganizerCore.downloadManager()->downloadsInProgressNoPause()) { - if (QMessageBox::question( - this, tr("Downloads in progress"), - tr("There are still downloads in progress, do you really want to quit?"), - QMessageBox::Yes | QMessageBox::Cancel) == QMessageBox::Cancel) { - return false; - } else { - m_OrganizerCore.downloadManager()->pauseAll(); - } - } - - const auto r = m_OrganizerCore.waitForAllUSVFSProcesses(); - if (r == ProcessRunner::Cancelled) { - return false; - } - - setCursor(Qt::WaitCursor); - return true; -} - -void MainWindow::cleanup() -{ -#ifdef MO2_WEBENGINE - QWebEngineProfile::defaultProfile()->clearAllVisitedLinks(); -#endif - -#ifdef MO2_WEBENGINE - if (m_IntegratedBrowser) { - m_IntegratedBrowser->close(); - m_IntegratedBrowser = {}; - } -#endif - - m_SaveMetaTimer.stop(); - m_MetaSave.waitForFinished(); -} - -bool MainWindow::eventFilter(QObject* object, QEvent* event) -{ - if (event->type() == QEvent::StatusTip && object != this) { - QMainWindow::event(event); - return true; - } - - return false; -} - -void MainWindow::registerPluginTool(IPluginTool* tool, QString name, QMenu* menu) -{ - if (!menu) { - menu = ui->actionTool->menu(); - } - - if (name.isEmpty()) - name = tool->displayName(); - - QAction* action = new QAction(tool->icon(), name, menu); - action->setToolTip(tool->tooltip()); - tool->setParentWidget(this); - connect( - action, &QAction::triggered, this, - [this, tool]() { - try { - tool->display(); - } catch (const std::exception& e) { - reportError( - tr("Plugin \"%1\" failed: %2").arg(tool->localizedName()).arg(e.what())); - } catch (...) { - reportError(tr("Plugin \"%1\" failed").arg(tool->localizedName())); - } - }, - Qt::QueuedConnection); - - menu->addAction(action); -} - -void MainWindow::updateToolMenu() -{ - // Clear the menu: - ui->actionTool->menu()->clear(); - - std::vector toolPlugins = m_PluginContainer.plugins(); - - // Sort the plugins by display name - std::sort(std::begin(toolPlugins), std::end(toolPlugins), - [](IPluginTool* left, IPluginTool* right) { - return left->displayName().toLower() < right->displayName().toLower(); - }); - - // Remove disabled plugins: - toolPlugins.erase(std::remove_if(std::begin(toolPlugins), std::end(toolPlugins), - [&](auto* tool) { - return !m_PluginContainer.isEnabled(tool); - }), - toolPlugins.end()); - - // Group the plugins into submenus - QMap>> submenuMap; - for (auto toolPlugin : toolPlugins) { - QStringList toolName = toolPlugin->displayName().split("/"); - QString const submenu = toolName[0]; - toolName.pop_front(); - submenuMap[submenu].append( - QPair(toolName.join("/"), toolPlugin)); - } - - // Start registering plugins - for (const auto& submenuKey : submenuMap.keys()) { - if (submenuMap[submenuKey].length() > 1) { - QMenu* submenu = new QMenu(submenuKey, this); - for (const auto& info : submenuMap[submenuKey]) { - registerPluginTool(info.second, info.first, submenu); - } - ui->actionTool->menu()->addMenu(submenu); - } else { - registerPluginTool(submenuMap[submenuKey].front().second); - } - } -} - -void MainWindow::registerModPage(IPluginModPage* modPage) -{ - QAction* action = new QAction(modPage->icon(), modPage->displayName(), this); - connect( - action, &QAction::triggered, this, - [this, modPage]() { -#ifdef MO2_WEBENGINE - if (modPage->useIntegratedBrowser()) { - - if (!m_IntegratedBrowser) { - m_IntegratedBrowser.reset(new BrowserDialog); - - connect(m_IntegratedBrowser.get(), - SIGNAL(requestDownload(QUrl, QNetworkReply*)), &m_OrganizerCore, - SLOT(requestDownload(QUrl, QNetworkReply*))); - } - - m_IntegratedBrowser->setWindowTitle(modPage->displayName()); - m_IntegratedBrowser->openUrl(modPage->pageURL()); - } else -#endif - { - shell::Open(QUrl(modPage->pageURL())); - } - }, - Qt::QueuedConnection); - - ui->actionModPage->menu()->addAction(action); -} - -bool MainWindow::registerNexusPage(const QString& gameName) -{ - // Get the plugin - IPluginGame* plugin = m_OrganizerCore.getGame(gameName); - if (plugin == nullptr) - return false; - - // Get the gameURL - QString const gameURL = NexusInterface::instance().getGameURL(gameName); - if (gameURL.isEmpty()) - return false; - - // Create an action - QAction* action = new QAction(plugin->gameIcon(), - QObject::tr("Visit %1 on Nexus").arg(gameName), this); - - // Bind the action - connect( - action, &QAction::triggered, this, - [this, gameURL]() { - shell::Open(QUrl(gameURL)); - }, - Qt::QueuedConnection); - - // Add the action - ui->actionModPage->menu()->addAction(action); - - return true; -} - -void MainWindow::updateModPageMenu() -{ - // Clear the menu: - ui->actionModPage->menu()->clear(); - - // Determine the loaded mod page plugins - std::vector modPagePlugins = - m_PluginContainer.plugins(); - - // Sort the plugins by display name - std::sort(std::begin(modPagePlugins), std::end(modPagePlugins), - [](IPluginModPage* left, IPluginModPage* right) { - return left->displayName().toLower() < right->displayName().toLower(); - }); - - // Remove disabled plugins - modPagePlugins.erase(std::remove_if(std::begin(modPagePlugins), - std::end(modPagePlugins), - [&](auto* tool) { - return !m_PluginContainer.isEnabled(tool); - }), - modPagePlugins.end()); - - for (auto* modPagePlugin : modPagePlugins) { - registerModPage(modPagePlugin); - } - - QStringList registeredSources; - - // Add the primary game - QString const gameShortName = m_OrganizerCore.managedGame()->gameShortName(); - if (registerNexusPage(gameShortName)) - registeredSources << gameShortName; - - // Add the primary sources - for (const auto& gameName : m_OrganizerCore.managedGame()->primarySources()) { - if (!registeredSources.contains(gameName) && registerNexusPage(gameName)) - registeredSources << gameName; - } - - // Add a separator if needed - if (!registeredSources.empty()) - ui->actionModPage->menu()->addSeparator(); - - // Add the secondary games (sorted) - QStringList secondaryGames = m_OrganizerCore.managedGame()->validShortNames(); - secondaryGames.sort(Qt::CaseInsensitive); - for (const auto& gameName : secondaryGames) { - if (!registeredSources.contains(gameName) && registerNexusPage(gameName)) - registeredSources << gameName; - } - - // No mod page plugin and the menu was visible - bool const keepOriginalAction = - modPagePlugins.empty() && registeredSources.length() <= 1; - if (keepOriginalAction) { - ui->toolBar->insertAction(ui->actionAdd_Profile, ui->actionNexus); - } else { - ui->toolBar->removeAction(ui->actionNexus); - } - ui->actionModPage->setVisible(!keepOriginalAction); -} - -void MainWindow::startExeAction() -{ - QAction* action = qobject_cast(sender()); - - if (action == nullptr) { - log::error("not an action?"); - return; - } - - const auto& list = *m_OrganizerCore.executablesList(); - - const auto title = action->text(); - auto itor = list.find(title); - - if (itor == list.end()) { - log::warn("startExeAction(): executable '{}' not found", title); - return; - } - - action->setEnabled(false); - Guard const g([&] { - action->setEnabled(true); - }); - - if (itor->minimizeToSystemTray()) { - m_SystemTrayManager->minimizeToSystemTray(); - } - - m_OrganizerCore.processRunner() - .setFromExecutable(*itor) - .setWaitForCompletion(ProcessRunner::TriggerRefresh) - .run(); -} - -void MainWindow::activateSelectedProfile() -{ - m_OrganizerCore.setCurrentProfile(ui->profileBox->currentText()); - - m_SavesTab->refreshSaveList(); - m_OrganizerCore.refresh(); - ui->modList->updateModCount(); - ui->espList->updatePluginCount(); - ui->statusBar->updateNormalMessage(m_OrganizerCore); -} - -void MainWindow::on_profileBox_currentIndexChanged(int index) -{ - if (!ui->profileBox->isEnabled()) { - return; - } - - int const previousIndex = m_OldProfileIndex; - m_OldProfileIndex = index; - - // select has changed, save stuff - if ((previousIndex != -1) && (m_OrganizerCore.currentProfile() != nullptr) && - m_OrganizerCore.currentProfile()->exists()) { - m_OrganizerCore.saveCurrentLists(); - } - - // Avoid doing any refresh if currentProfile is already set but previous - // index was -1 as it means that this is happening during initialization so - // everything has already been set. - if (previousIndex == -1 && m_OrganizerCore.currentProfile() != nullptr && - m_OrganizerCore.currentProfile()->exists() && - ui->profileBox->currentText() == m_OrganizerCore.currentProfile()->name()) { - return; - } - - // ensure the new index is valid - if (index < 0 || index >= ui->profileBox->count()) { - log::debug("invalid profile index, using last profile"); - ui->profileBox->setCurrentIndex(ui->profileBox->count() - 1); - } - - // handle item - if (ui->profileBox->currentIndex() == 0) { - // remember the profile name that was selected before, previousIndex can't - // be used again because adding/deleting profiles will change the order - // in the list - const QString previousName = ui->profileBox->itemText(previousIndex); - - // show the dialog - ProfilesDialog dlg(previousName, m_OrganizerCore, this); - dlg.exec(); - - // check if the user clicked 'select' to select another profile - std::optional newSelection = dlg.selectedProfile(); - - // refresh the profile box; this loops until there is at least one profile - // available, which shouldn't really happen because the dialog won't allow - // it - // - // the `false` to refreshProfiles() is so it doesn't try to select the - // profile in the list because 1) it's done just below, and 2) it might be - // wrong profile if there's something in newSelection - while (!refreshProfiles(false)) { - ProfilesDialog dlg(previousName, m_OrganizerCore, this); - dlg.exec(); - newSelection = dlg.selectedProfile(); - } - - // note that setCurrentText() is recursive, it will re-execute this function - if (newSelection) { - ui->profileBox->setCurrentText(*newSelection); - } else { - ui->profileBox->setCurrentText(previousName); - } - - // nothing else to do because setCurrentText() is recursive and will - // have re-executed on_profileBox_currentIndexChanged() again, doing all - // the stuff below for the new selection - return; - } - - activateSelectedProfile(); - - auto saveGames = m_OrganizerCore.gameFeatures().gameFeature(); - if (saveGames != nullptr) { - if (saveGames->prepareProfile(m_OrganizerCore.currentProfile().get())) { - m_SavesTab->refreshSaveList(); - } - } - - auto invalidation = m_OrganizerCore.gameFeatures().gameFeature(); - if (invalidation != nullptr) { - if (invalidation->prepareProfile(m_OrganizerCore.currentProfile().get())) { - QTimer::singleShot(5, [this] { - m_OrganizerCore.refresh(); - }); - } - } -} - -bool MainWindow::refreshProfiles(bool selectProfile, QString newProfile) -{ - QComboBox* profileBox = findChild("profileBox"); - - QString const currentProfileName = profileBox->currentText(); - - profileBox->blockSignals(true); - profileBox->clear(); - profileBox->addItem(QObject::tr("")); - - QDir profilesDir(Settings::instance().paths().profiles()); - profilesDir.setFilter(QDir::AllDirs | QDir::NoDotAndDotDot); - - QDirIterator profileIter(profilesDir); - - while (profileIter.hasNext()) { - profileIter.next(); - try { - profileBox->addItem(profileIter.fileName()); - } catch (const std::runtime_error& error) { - reportError(QObject::tr("failed to parse profile %1: %2") - .arg(profileIter.fileName()) - .arg(error.what())); - } - } - - // now select one of the profiles, preferably the one that was selected before - profileBox->blockSignals(false); - - if (selectProfile) { - if (profileBox->count() > 1) { - if (newProfile.isEmpty()) { - profileBox->setCurrentText(currentProfileName); - } else { - profileBox->setCurrentText(newProfile); - } - if (profileBox->currentIndex() == 0) { - profileBox->setCurrentIndex(1); - } - } - } - return profileBox->count() > 1; -} - -void MainWindow::refreshExecutablesList() -{ - QAbstractItemModel* model = ui->executablesListBox->model(); - - auto add = [&](const QString& title, const QFileInfo& binary) { - QIcon icon; - if (!binary.fileName().isEmpty()) { - icon = iconForExecutable(binary.filePath()); - } - - ui->executablesListBox->addItem(icon, title); - - const auto i = ui->executablesListBox->count() - 1; - - model->setData(model->index(i, 0), - QSize(0, ui->executablesListBox->iconSize().height() + 4), - Qt::SizeHintRole); - }; - - ui->executablesListBox->setEnabled(false); - ui->executablesListBox->clear(); - - add(tr(""), {}); - - auto shouldSkipInLaunchDropdown = [](const Executable& exe) { - const QString title = exe.title().trimmed(); - const QString lower = title.toLower(); - if (lower == QStringLiteral("proton") || lower == QStringLiteral("prefix") || - lower.startsWith(QStringLiteral("proton ")) || - lower.startsWith(QStringLiteral("prefix "))) { - return true; - } - - return false; - }; - - for (const auto& exe : *m_OrganizerCore.executablesList()) { - if (exe.hide()) { - continue; - } - - if (shouldSkipInLaunchDropdown(exe)) { - log::debug("Skipping internal executable entry '{}' from launch dropdown", - exe.title()); - continue; - } - - add(exe.title(), exe.binaryInfo()); - } - - if (ui->executablesListBox->count() == 1) { - // all executables are hidden, add an empty one to at least be able to - // switch to edit - add(tr("(no executables)"), QFileInfo(":badfile")); - } - - ui->executablesListBox->setCurrentIndex(1); - ui->executablesListBox->setEnabled(true); -} - -static bool BySortValue(const std::pair& LHS, - const std::pair& RHS) -{ - return LHS.first < RHS.first; -} - -template -static QStringList toStringList(InputIterator current, InputIterator end) -{ - QStringList result; - for (; current != end; ++current) { - result.append(*current); - } - return result; -} - -void MainWindow::updateBSAList(const QStringList& defaultArchives, - const QStringList& activeArchives) -{ - m_DefaultArchives = defaultArchives; - ui->bsaList->clear(); - ui->bsaList->header()->setSectionResizeMode(QHeaderView::ResizeToContents); - std::vector> items; - - auto invalidation = m_OrganizerCore.gameFeatures().gameFeature(); - std::vector const files = m_OrganizerCore.directoryStructure()->getFiles(); - - QStringList const plugins = - m_OrganizerCore.findFiles("", [](const QString& fileName) -> bool { - return fileName.endsWith(".esp", Qt::CaseInsensitive) || - fileName.endsWith(".esm", Qt::CaseInsensitive) || - fileName.endsWith(".esl", Qt::CaseInsensitive); - }); - - QList> pluginNamePairs; - pluginNamePairs.reserve(plugins.size()); - for (const QString& pluginName : plugins) { - QFileInfo const pluginInfo(pluginName); - pluginNamePairs.append( - std::make_pair(pluginInfo.completeBaseName(), pluginInfo.fileName())); - } - - auto hasAssociatedPlugin = [&](const QString& bsaName) -> bool { - for (const auto& [completeBaseName, fileName] : pluginNamePairs) { - if (bsaName.startsWith(completeBaseName, Qt::CaseInsensitive) && - (m_OrganizerCore.pluginList()->state(fileName) == - IPluginList::STATE_ACTIVE)) { - return true; - } - } - return false; - }; - - for (const FileEntryPtr& current : files) { - QFileInfo const fileInfo(ToQString(current->getName().c_str())); - - if (fileInfo.suffix().toLower() == "bsa" || fileInfo.suffix().toLower() == "ba2") { - int index = activeArchives.indexOf(fileInfo.fileName()); - if (index == -1) { - index = 0xFFFF; - } else { - index += 2; - } - - if ((invalidation != nullptr) && - invalidation->isInvalidationBSA(fileInfo.fileName())) { - index = 1; - } - - int const originId = current->getOrigin(); - FilesOrigin const& origin = - m_OrganizerCore.directoryStructure()->getOriginByID(originId); - - QTreeWidgetItem* newItem = new QTreeWidgetItem( - QStringList() << fileInfo.fileName() << ToQString(origin.getName())); - newItem->setData(0, Qt::UserRole, index); - newItem->setData(1, Qt::UserRole, originId); - newItem->setFlags(newItem->flags() & - ~(Qt::ItemIsDropEnabled | Qt::ItemIsUserCheckable)); - newItem->setCheckState(0, (index != -1) ? Qt::Checked : Qt::Unchecked); - newItem->setData(0, Qt::UserRole, false); - if (m_OrganizerCore.settings().game().forceEnableCoreFiles() && - defaultArchives.contains(fileInfo.fileName())) { - newItem->setCheckState(0, Qt::Checked); - newItem->setDisabled(true); - newItem->setData(0, Qt::UserRole, true); - } else if (fileInfo.fileName().compare("update.bsa", Qt::CaseInsensitive) == 0) { - newItem->setCheckState(0, Qt::Checked); - newItem->setDisabled(true); - } else if (hasAssociatedPlugin(fileInfo.fileName())) { - newItem->setCheckState(0, Qt::Checked); - newItem->setDisabled(true); - } else { - newItem->setCheckState(0, Qt::Unchecked); - newItem->setDisabled(true); - } - if (index < 0) - index = 0; - - UINT32 const sortValue = ((origin.getPriority() & 0xFFFF) << 16) | (index & 0xFFFF); - items.push_back(std::make_pair(sortValue, newItem)); - } - } - std::sort(items.begin(), items.end(), BySortValue); - - for (auto iter = items.begin(); iter != items.end(); ++iter) { - int const originID = iter->second->data(1, Qt::UserRole).toInt(); - - const FilesOrigin& origin = - m_OrganizerCore.directoryStructure()->getOriginByID(originID); - - QString modName; - const unsigned int modIndex = ModInfo::getIndex(ToQString(origin.getName())); - - if (modIndex == UINT_MAX) { - modName = UnmanagedModName(); - } else { - ModInfo::Ptr const modInfo = ModInfo::getByIndex(modIndex); - modName = modInfo->name(); - } - - QList const items = - ui->bsaList->findItems(modName, Qt::MatchFixedString); - QTreeWidgetItem* subItem = nullptr; - if (!items.empty()) { - subItem = items.at(0); - } else { - subItem = new QTreeWidgetItem(QStringList(modName)); - subItem->setFlags(subItem->flags() & ~Qt::ItemIsDragEnabled); - ui->bsaList->addTopLevelItem(subItem); - } - subItem->addChild(iter->second); - subItem->setExpanded(true); - } - checkBSAList(); -} - -void MainWindow::checkBSAList() -{ - auto archives = m_OrganizerCore.gameFeatures().gameFeature(); - - if (archives != nullptr) { - ui->bsaList->blockSignals(true); - ON_BLOCK_EXIT([&]() { - ui->bsaList->blockSignals(false); - }); - - QStringList const defaultArchives = - archives->archives(m_OrganizerCore.currentProfile().get()); - - bool warning = false; - - for (int i = 0; i < ui->bsaList->topLevelItemCount(); ++i) { - bool modWarning = false; - QTreeWidgetItem* tlItem = ui->bsaList->topLevelItem(i); - for (int j = 0; j < tlItem->childCount(); ++j) { - QTreeWidgetItem* item = tlItem->child(j); - QString const filename = item->text(0); - item->setIcon(0, QIcon()); - item->setToolTip(0, QString()); - - if (item->checkState(0) == Qt::Unchecked) { - if (defaultArchives.contains(filename)) { - item->setIcon(0, QIcon(":/MO/gui/warning")); - item->setToolTip( - 0, tr("This bsa is enabled in the ini file so it may be required!")); - modWarning = true; - } - } - } - if (modWarning) { - ui->bsaList->expandItem(ui->bsaList->topLevelItem(i)); - warning = true; - } - } - if (warning) { - ui->tabWidget->setTabIcon(1, QIcon(":/MO/gui/warning")); - } else { - ui->tabWidget->setTabIcon(1, QIcon()); - } - } -} - -void MainWindow::saveModMetas() -{ - if (m_MetaSave.isFinished()) { - m_MetaSave = QtConcurrent::run([this]() { - for (unsigned int i = 0; i < ModInfo::getNumMods(); ++i) { - ModInfo::Ptr const modInfo = ModInfo::getByIndex(i); - modInfo->saveMeta(); - } - }); - } -} - -void MainWindow::fixCategories() -{ - for (unsigned int i = 0; i < ModInfo::getNumMods(); ++i) { - ModInfo::Ptr const modInfo = ModInfo::getByIndex(i); - std::set const categories = modInfo->getCategories(); - for (std::set::iterator iter = categories.begin(); iter != categories.end(); - ++iter) { - if (!m_CategoryFactory.categoryExists(*iter)) { - modInfo->setCategory(*iter, false); - } - } - } -} - -void MainWindow::setupNetworkProxy(bool activate) -{ - QNetworkProxyFactory::setUseSystemConfiguration(activate); -} - -void MainWindow::activateProxy(bool activate) -{ - QProgressDialog busyDialog(tr("Activating Network Proxy"), QString(), 0, 0, - parentWidget()); - busyDialog.setWindowFlags(busyDialog.windowFlags() & - ~Qt::WindowContextHelpButtonHint); - busyDialog.setWindowModality(Qt::WindowModal); - busyDialog.show(); - - QFutureWatcher futureWatcher; - QEventLoop loop; - connect(&futureWatcher, &QFutureWatcher::finished, &loop, &QEventLoop::quit, - Qt::QueuedConnection); - - futureWatcher.setFuture(QtConcurrent::run(MainWindow::setupNetworkProxy, activate)); - - // wait for setupNetworkProxy while keeping ui responsive - loop.exec(); - - busyDialog.hide(); -} - -void MainWindow::readSettings() -{ - const auto& s = m_OrganizerCore.settings(); - - if (!s.geometry().restoreGeometry(this)) { - resize(1300, 800); - } - - s.geometry().restoreState(this); - s.geometry().restoreDocks(this); - s.geometry().restoreToolbars(this); - s.geometry().restoreState(ui->splitter); - s.geometry().restoreState(ui->categoriesSplitter); - s.geometry().restoreVisibility(ui->menuBar); - s.geometry().restoreVisibility(ui->statusBar); - - FilterWidget::setOptions(s.interface().filterOptions()); - - { - // special case in case someone puts 0 in the INI - auto v = s.widgets().index(ui->executablesListBox); - if (!v || v == 0) { - v = 1; - } - - ui->executablesListBox->setCurrentIndex(*v); - } - - s.widgets().restoreIndex(ui->tabWidget); - - ui->modList->restoreState(s); - - { - s.geometry().restoreVisibility(ui->categoriesGroup, false); - const auto v = ui->categoriesGroup->isVisible(); - setCategoryListVisible(v); - ui->displayCategoriesBtn->setChecked(v); - } - - if (s.network().useProxy()) { - activateProxy(true); - } -} - -void MainWindow::processUpdates() -{ - auto& settings = m_OrganizerCore.settings(); - const auto earliest = QVersionNumber::fromString("2.1.2").normalized(); - - const auto lastVersion = settings.version().value_or(earliest); - const auto currentVersion = - QVersionNumber::fromString(m_OrganizerCore.getVersion().string()).normalized(); - - m_LastVersion = lastVersion; - - settings.processUpdates(currentVersion, lastVersion); - - if (!settings.firstStart()) { - if (lastVersion < QVersionNumber(2, 1, 6)) { - ui->modList->header()->setSectionHidden(ModList::COL_NOTES, true); - } - - if (lastVersion < QVersionNumber(2, 2, 1)) { - // hide new columns by default - for (int i = DownloadList::COL_MODNAME; i < DownloadList::COL_COUNT; ++i) { - ui->downloadView->header()->hideSection(i); - } - } - - if (lastVersion < QVersionNumber(2, 3)) { - for (int i = 1; i < ui->dataTree->header()->count(); ++i) - ui->dataTree->setColumnWidth(i, 150); - } - } - - if (currentVersion < lastVersion) { - // Fluorine Manager versions its own 0.x.y releases separately from the - // embedded MO2 engine. Instances created under upstream MO2 (or an - // earlier Fluorine build that echoed the engine version) will have - // a stored 2.x.y — the "2 → 0" drop isn't a downgrade, it's a schema - // change. Detect that and suppress the warning. - const bool schemaTransition = - lastVersion.majorVersion() >= 2 && currentVersion.majorVersion() == 0; - if (!schemaTransition) { - const auto text = - tr("Notice: Your current Fluorine Manager version (%1) is lower than " - "the previously used one (%2). The GUI may not downgrade " - "gracefully, so you may experience oddities. However, there " - "should be no serious issues.") - .arg(currentVersion.toString()) - .arg(lastVersion.toString()); - - log::warn("{}", text); - } else { - log::debug( - "skipping downgrade warning: {} -> {} is MO2->Fluorine schema change", - lastVersion.toString(), currentVersion.toString()); - } - } -} - -void MainWindow::storeSettings() -{ - auto& s = m_OrganizerCore.settings(); - - s.geometry().saveState(this); - s.geometry().saveGeometry(this); - s.geometry().saveDocks(this); - - s.geometry().saveVisibility(ui->menuBar); - s.geometry().saveVisibility(ui->statusBar); - s.geometry().saveToolbars(this); - s.geometry().saveState(ui->splitter); - s.geometry().saveState(ui->categoriesSplitter); - s.geometry().saveMainWindowMonitor(this); - s.geometry().saveVisibility(ui->categoriesGroup); - - s.geometry().saveState(ui->espList->header()); - s.geometry().saveState(ui->downloadView->header()); - s.geometry().saveState(ui->savegameList->header()); - - s.widgets().saveIndex(ui->executablesListBox); - s.widgets().saveIndex(ui->tabWidget); - - m_DataTab->saveState(s); - ui->modList->saveState(s); - - s.interface().setFilterOptions(FilterWidget::options()); -} - -QMainWindow* MainWindow::mainWindow() -{ - return this; -} - -void MainWindow::on_tabWidget_currentChanged(int index) -{ - QWidget* currentWidget = ui->tabWidget->widget(index); - if (currentWidget == ui->espTab) { - m_OrganizerCore.refreshESPList(); - ui->espList->activated(); - } else if (currentWidget == ui->bsaTab) { - m_OrganizerCore.refreshBSAList(); - } else if (currentWidget == ui->dataTab) { - m_DataTab->activated(); - } else if (currentWidget == ui->savesTab) { - m_SavesTab->refreshSaveList(); - } -} - -void MainWindow::on_startButton_clicked() -{ - const Executable* selectedExecutable = getSelectedExecutable(); - if (!selectedExecutable) { - return; - } - - ui->startButton->setEnabled(false); - Guard const g([&] { - ui->startButton->setEnabled(true); - }); - - // Pre-check: if this executable uses Proton and the current instance has SLR - // enabled, download SLR before launching if it isn't installed yet. - if (selectedExecutable->useProton()) { - const auto* s = Settings::maybeInstance(); - bool useSLR = true; - if (s) { - QSettings const instanceIni(s->filename(), QSettings::IniFormat); - useSLR = instanceIni.value("fluorine/use_slr", true).toBool(); - } - if (useSLR && !isSlrInstalled()) { - auto* progress = new QProgressDialog( - tr("Downloading Steam Linux Runtime (~200 MB)...\n" - "This is required to launch games. Check the MO2 log for details."), - tr("Cancel"), 0, 0, this); - progress->setWindowTitle(tr("Steam Linux Runtime")); - progress->setWindowModality(Qt::WindowModal); - progress->setAttribute(Qt::WA_ShowWithoutActivating); - progress->setMinimumDuration(0); - - int cancelFlag = 0; - connect(progress, &QProgressDialog::canceled, this, [&cancelFlag] { - cancelFlag = 1; - }); - - // Run download synchronously using an event loop so the dialog stays responsive. - QFutureWatcher watcher; - QEventLoop loop; - connect(&watcher, &QFutureWatcher::finished, &loop, &QEventLoop::quit); - watcher.setFuture(QtConcurrent::run([&cancelFlag]() -> QString { - return downloadSlr(nullptr, nullptr, &cancelFlag); - })); - progress->show(); - loop.exec(); - progress->close(); - progress->deleteLater(); - - const QString err = watcher.result(); - if (cancelFlag) { - return; // user cancelled, don't launch - } - if (!err.isEmpty()) { - log::error("[SLR] Download failed: {}", err); - QMessageBox::warning(this, tr("Steam Linux Runtime"), - tr("Steam Linux Runtime download failed:\n%1\n\n" - "You can disable SLR in the Instance Manager and try again.").arg(err)); - return; - } - } - } - - if (selectedExecutable->minimizeToSystemTray()) { - m_SystemTrayManager->minimizeToSystemTray(); - } - - m_OrganizerCore.processRunner() - .setFromExecutable(*selectedExecutable) - .setWaitForCompletion(ProcessRunner::TriggerRefresh) - .run(); -} - -bool MainWindow::modifyExecutablesDialog(int selection) -{ - bool result = false; - - try { - EditExecutablesDialog dialog(m_OrganizerCore, selection, this); - - result = (dialog.exec() == QDialog::Accepted); - - refreshExecutablesList(); - updatePinnedExecutables(); - } catch (const std::exception& e) { - reportError(e.what()); - } - - return result; -} - -void MainWindow::on_executablesListBox_currentIndexChanged(int index) -{ - if (!ui->executablesListBox->isEnabled()) { - return; - } - - const int previousIndex = (m_OldExecutableIndex > 0 ? m_OldExecutableIndex : 1); - - m_OldExecutableIndex = index; - - if (index == 0) { - modifyExecutablesDialog(previousIndex - 1); - const auto newCount = ui->executablesListBox->count(); - - if (previousIndex >= 0 && previousIndex < newCount) { - ui->executablesListBox->setCurrentIndex(previousIndex); - } else { - ui->executablesListBox->setCurrentIndex(newCount - 1); - } - } -} - -void MainWindow::helpTriggered() -{ - QWhatsThis::enterWhatsThisMode(); -} - -void MainWindow::wikiTriggered() -{ - shell::Open(QUrl("https://modorganizer2.github.io/")); -} - -void MainWindow::gameSupportTriggered() -{ - shell::Open(QUrl(m_OrganizerCore.managedGame()->getSupportURL())); -} - -void MainWindow::discordTriggered() -{ - shell::Open(QUrl("https://discord.gg/ewUVAqyrQX")); -} - -void MainWindow::issueTriggered() -{ - shell::Open(QUrl("https://github.com/Modorganizer2/modorganizer/issues")); -} - -void MainWindow::tutorialTriggered() -{ - QAction* tutorialAction = qobject_cast(sender()); - if (tutorialAction != nullptr) { - TutorialManager::instance().activateTutorial("MainWindow", - tutorialAction->data().toString()); - } -} - -void MainWindow::on_actionInstallMod_triggered() -{ - ui->modList->actions().installMod(); -} - -void MainWindow::on_action_Refresh_triggered() -{ - refreshProfile_activated(); -} - -void MainWindow::on_actionAdd_Profile_triggered() -{ - for (;;) { - ProfilesDialog profilesDialog(m_OrganizerCore.currentProfile()->name(), - m_OrganizerCore, this); - - // workaround: need to disable monitoring of the saves directory, otherwise the - // active profile directory is locked - m_SavesTab->stopMonitorSaves(); - profilesDialog.exec(); - m_SavesTab->refreshSaveList(); // since the save list may now be outdated we have - // to refresh it completely - - if (refreshProfiles(true, profilesDialog.selectedProfile().value_or("")) && - !profilesDialog.failed()) { - break; - } - } - - auto saveGames = m_OrganizerCore.gameFeatures().gameFeature(); - if (saveGames != nullptr) { - if (saveGames->prepareProfile(m_OrganizerCore.currentProfile().get())) { - m_SavesTab->refreshSaveList(); - } - } - - auto invalidation = m_OrganizerCore.gameFeatures().gameFeature(); - if (invalidation != nullptr) { - if (invalidation->prepareProfile(m_OrganizerCore.currentProfile().get())) { - QTimer::singleShot(5, [this] { - m_OrganizerCore.refresh(); - }); - } - } -} - -void MainWindow::on_actionModify_Executables_triggered() -{ - const auto sel = (m_OldExecutableIndex > 0 ? m_OldExecutableIndex - 1 : 0); - - if (modifyExecutablesDialog(sel)) { - const auto newCount = ui->executablesListBox->count(); - if (m_OldExecutableIndex >= 0 && m_OldExecutableIndex < newCount) { - ui->executablesListBox->setCurrentIndex(m_OldExecutableIndex); - } else { - ui->executablesListBox->setCurrentIndex(newCount - 1); - } - } -} - -void MainWindow::refresherProgress(const DirectoryRefreshProgress* p) -{ - if (p->finished()) { - setEnabled(true); - ui->statusBar->setProgress(100); - } else { - setEnabled(false); - ui->statusBar->setProgress(p->percentDone()); - } -} - -void MainWindow::onDirectoryStructureChanged() -{ - // some problem-reports may rely on the virtual directory tree so they need to be - // updated now - scheduleCheckForProblems(); - m_DataTab->updateTree(); -} - -void MainWindow::modInstalled(const QString& modName) -{ - if (!m_OrganizerCore.settings().interface().checkUpdateAfterInstallation()) { - return; - } - - unsigned int const index = ModInfo::getIndex(modName); - - if (index == UINT_MAX) { - return; - } - - // force an update to happen - ui->modList->actions().checkModsForUpdates( - {m_OrganizerCore.modList()->index(index, 0)}); -} - -void MainWindow::importCategories(bool) -{ - NexusInterface& nexus = NexusInterface::instance(); - nexus.setPluginContainer(&m_OrganizerCore.pluginContainer()); - nexus.requestGameInfo(Settings::instance().game().plugin()->gameShortName(), this, - QVariant(), QString()); -} - -void MainWindow::showMessage(const QString& message) -{ - MessageDialog::showMessage(message, this); -} - -void MainWindow::showError(const QString& message) -{ - reportError(message); -} - -void MainWindow::modRenamed(const QString& oldName, const QString& newName) -{ - Profile::renameModInAllProfiles(oldName, newName); - - // immediately refresh the active profile because the data in memory is invalid - m_OrganizerCore.currentProfile()->refreshModStatus(); - - // also fix the directory structure - try { - if (m_OrganizerCore.directoryStructure()->originExists(ToWString(oldName))) { - FilesOrigin& origin = - m_OrganizerCore.directoryStructure()->getOriginByName(ToWString(oldName)); - origin.setName(ToWString(newName)); - } else { - } - } catch (const std::exception& e) { - reportError(tr("failed to change origin name: %1").arg(e.what())); - } -} - -void MainWindow::fileMoved(const QString& filePath, const QString& oldOriginName, - const QString& newOriginName) -{ - const FileEntryPtr filePtr = - m_OrganizerCore.directoryStructure()->findFile(ToWString(filePath)); - if (filePtr.get() != nullptr) { - try { - if (m_OrganizerCore.directoryStructure()->originExists( - ToWString(newOriginName))) { - FilesOrigin const& newOrigin = m_OrganizerCore.directoryStructure()->getOriginByName( - ToWString(newOriginName)); - - QString const fullNewPath = ToQString(newOrigin.getPath()) + "/" + filePath; - FILETIME ft = {}; - QFileInfo const fi(fullNewPath); - if (fi.exists()) { - // Win32 FILETIME = 100-ns intervals since 1601-01-01. - qint64 const msecs = fi.birthTime().toMSecsSinceEpoch(); - uint64_t const ticks = (static_cast(msecs) * 10000ULL) + - 116444736000000000ULL; - ft.dwLowDateTime = static_cast(ticks & 0xFFFFFFFF); - ft.dwHighDateTime = static_cast(ticks >> 32); - } - filePtr->addOrigin(newOrigin.getID(), ft, L"", -1); - } - if (m_OrganizerCore.directoryStructure()->originExists( - ToWString(oldOriginName))) { - FilesOrigin const& oldOrigin = m_OrganizerCore.directoryStructure()->getOriginByName( - ToWString(oldOriginName)); - filePtr->removeOrigin(oldOrigin.getID()); - } - } catch (const std::exception& e) { - reportError(tr(R"(failed to move "%1" from mod "%2" to "%3": %4)") - .arg(filePath) - .arg(oldOriginName) - .arg(newOriginName) - .arg(e.what())); - } - - // Track files moved from Overwrite to a mod for in-place write-back. - ModInfo::Ptr const owInfo = ModInfo::getOverwrite(); - if (owInfo && oldOriginName == owInfo->name()) { - if (m_OrganizerCore.directoryStructure()->originExists( - ToWString(newOriginName))) { - FilesOrigin const& newOrigin = - m_OrganizerCore.directoryStructure()->getOriginByName( - ToWString(newOriginName)); - m_OrganizerCore.trackOverwriteMove(filePath, ToQString(newOrigin.getPath())); - } - } - } else { - // this is probably not an error, the specified path is likely a directory - } -} - -void MainWindow::modRemoved(const QString& fileName) -{ - if (!fileName.isEmpty() && !QFileInfo(fileName).isAbsolute()) { - m_OrganizerCore.downloadManager()->markUninstalled(fileName); - } -} - -void MainWindow::windowTutorialFinished(const QString& windowName) -{ - m_OrganizerCore.settings().interface().setTutorialCompleted(windowName); -} - -void MainWindow::displayModInformation(ModInfo::Ptr modInfo, unsigned int modIndex, - ModInfoTabIDs tabID) -{ - ui->modList->actions().displayModInformation(modInfo, modIndex, tabID); -} - -bool MainWindow::closeWindow() -{ - return close(); -} - -void MainWindow::setWindowEnabled(bool enabled) -{ - setEnabled(enabled); -} - -void MainWindow::refreshProfile_activated() -{ - m_OrganizerCore.refresh(); -} - -void MainWindow::saveArchiveList() -{ - if (m_OrganizerCore.isArchivesInit()) { - SafeWriteFile archiveFile(m_OrganizerCore.currentProfile()->getArchivesFileName()); - for (int i = 0; i < ui->bsaList->topLevelItemCount(); ++i) { - QTreeWidgetItem* tlItem = ui->bsaList->topLevelItem(i); - for (int j = 0; j < tlItem->childCount(); ++j) { - QTreeWidgetItem* item = tlItem->child(j); - if (item->checkState(0) == Qt::Checked) { - archiveFile->write(item->text(0).toUtf8().append("\r\n")); - } - } - } - archiveFile->commit(); - } else { - log::debug("archive list not initialised"); - } -} - -void MainWindow::openInstanceFolder() -{ - QString const dataPath = qApp->property("dataPath").toString(); - shell::Explore(dataPath); -} - -void MainWindow::openInstallFolder() -{ - shell::Explore(AppConfig::basePath()); -} - -void MainWindow::openPluginsFolder() -{ - shell::Explore(AppConfig::pluginsPath()); -} - -void MainWindow::openStylesheetsFolder() -{ - // Open the instance's stylesheets directory (where custom themes from - // modlists live), or the user data dir as fallback. - QString ssPath; - if (auto ci = InstanceManager::singleton().currentInstance()) { - ssPath = - ci->directory() + "/" + QString::fromStdWString(AppConfig::stylesheetsPath()); - } else { - ssPath = fluorineDataDir() + "/stylesheets"; - } - QDir().mkpath(ssPath); - shell::Explore(ssPath); -} - -void MainWindow::openProfileFolder() -{ - shell::Explore(m_OrganizerCore.currentProfile()->absolutePath()); -} - -void MainWindow::openIniFolder() -{ - if (m_OrganizerCore.currentProfile()->localSettingsEnabled()) { - shell::Explore(m_OrganizerCore.currentProfile()->absolutePath()); - } else { - shell::Explore(m_OrganizerCore.managedGame()->documentsDirectory()); - } -} - -void MainWindow::openDownloadsFolder() -{ - shell::Explore(m_OrganizerCore.settings().paths().downloads()); -} - -void MainWindow::openModsFolder() -{ - shell::Explore(m_OrganizerCore.settings().paths().mods()); -} - -void MainWindow::openGameFolder() -{ - shell::Explore(m_OrganizerCore.managedGame()->gameDirectory()); -} - -void MainWindow::openMyGamesFolder() -{ - shell::Explore(m_OrganizerCore.managedGame()->documentsDirectory()); -} - -QMenu* MainWindow::openFolderMenu() -{ - QMenu* FolderMenu = new QMenu(this); - - // game folders that are not necessarily MO-specific - FolderMenu->addAction(tr("Open Game folder"), this, SLOT(openGameFolder())); - FolderMenu->addAction(tr("Open MyGames folder"), this, SLOT(openMyGamesFolder())); - FolderMenu->addAction(tr("Open INIs folder"), this, SLOT(openIniFolder())); - - FolderMenu->addSeparator(); - - // MO-specific folders that are related to modding the game - FolderMenu->addAction(tr("Open Instance folder"), this, SLOT(openInstanceFolder())); - FolderMenu->addAction(tr("Open Mods folder"), this, SLOT(openModsFolder())); - FolderMenu->addAction(tr("Open Profile folder"), this, SLOT(openProfileFolder())); - FolderMenu->addAction(tr("Open Downloads folder"), this, SLOT(openDownloadsFolder())); - - FolderMenu->addSeparator(); - - // MO-specific folders that are not directly related to modding and are either - // in the installation folder or the instance - FolderMenu->addAction(tr("Open MO2 Install folder"), this, SLOT(openInstallFolder())); - FolderMenu->addAction(tr("Open MO2 Plugins folder"), this, SLOT(openPluginsFolder())); - FolderMenu->addAction(tr("Open MO2 Stylesheets folder"), this, - SLOT(openStylesheetsFolder())); - FolderMenu->addAction(tr("Open MO2 Logs folder"), [=, this] { - ui->logList->openLogsFolder(); - }); - - return FolderMenu; -} - -void MainWindow::linkToolbar() -{ - Executable* exe = getSelectedExecutable(); - if (!exe) { - return; - } - - exe->setShownOnToolbar(!exe->isShownOnToolbar()); - updatePinnedExecutables(); -} - -void MainWindow::linkDesktop() -{ - if (auto* exe = getSelectedExecutable()) { - env::Shortcut(*exe).toggle(env::Shortcut::Desktop); - } -} - -void MainWindow::linkMenu() -{ - if (auto* exe = getSelectedExecutable()) { - env::Shortcut(*exe).toggle(env::Shortcut::ApplicationMenu); - } -} - -void MainWindow::on_linkButton_pressed() -{ - const Executable* exe = getSelectedExecutable(); - if (!exe) { - return; - } - - const QIcon addIcon(":/MO/gui/link"); - const QIcon removeIcon(":/MO/gui/remove"); - - env::Shortcut const shortcut(*exe); - - m_LinkToolbar->setIcon(exe->isShownOnToolbar() ? removeIcon : addIcon); - - m_LinkDesktop->setIcon(shortcut.exists(env::Shortcut::Desktop) ? removeIcon - : addIcon); - - if (m_LinkStartMenu) { - m_LinkStartMenu->setIcon( - shortcut.exists(env::Shortcut::ApplicationMenu) ? removeIcon : addIcon); - } -} - -void MainWindow::on_actionSettings_triggered() -{ - Settings& settings = m_OrganizerCore.settings(); - - QString const oldModDirectory(settings.paths().mods()); - QString const oldCacheDirectory(settings.paths().cache()); - QString const oldProfilesDirectory(settings.paths().profiles()); - QString const oldManagedGameDirectory(settings.game().directory().value_or("")); - bool const oldDisplayForeign(settings.interface().displayForeign()); - bool const oldArchiveParsing(settings.archiveParsing()); - bool const proxy = settings.network().useProxy(); - DownloadManager* dlManager = m_OrganizerCore.downloadManager(); - const bool oldCheckForUpdates = settings.checkForUpdates(); - const int oldMaxDumps = settings.diagnostics().maxCoreDumps(); - - SettingsDialog dialog(&m_PluginContainer, settings, this); - dialog.exec(); - - auto e = dialog.exitNeeded(); - - if (oldManagedGameDirectory != settings.game().directory()) { - e |= Exit::Restart; - } - - if (e.testFlag(Exit::Restart)) { - const auto r = - MOBase::TaskDialog(this) - .title(tr("Restart Mod Organizer")) - .main("Restart Mod Organizer") - .content(tr("Mod Organizer must restart to finish configuration changes")) - .icon(QMessageBox::Question) - .button({tr("Restart"), QMessageBox::Yes}) - .button( - {tr("Continue"), tr("Some things might be weird."), QMessageBox::No}) - .exec(); - - if (r == QMessageBox::Yes) { - ExitModOrganizer(e); - } - } - - InstallationManager* instManager = m_OrganizerCore.installationManager(); - instManager->setModsDirectory(settings.paths().mods()); - instManager->setDownloadDirectory(settings.paths().downloads()); - - // Schedule a problem check since diagnose plugins may have been enabled / disabled. - scheduleCheckForProblems(); - - fixCategories(); - ui->modList->refreshFilters(); - ui->modList->refresh(); - - m_OrganizerCore.refreshLists(); - - if (settings.paths().profiles() != oldProfilesDirectory) { - refreshProfiles(); - } - - if (dlManager->getOutputDirectory() != settings.paths().downloads()) { - if (dlManager->downloadsInProgress()) { - MessageDialog::showMessage(tr("Can't change download directory while " - "downloads are in progress!"), - this); - } else { - dlManager->setOutputDirectory(settings.paths().downloads()); - } - } - - if ((settings.paths().mods() != oldModDirectory) || - (settings.interface().displayForeign() != oldDisplayForeign)) { - m_OrganizerCore.refresh(); - } - - const auto state = settings.archiveParsing(); - if (state != oldArchiveParsing) { - if (!state) { - ui->dataTabShowFromArchives->setCheckState(Qt::Unchecked); - ui->dataTabShowFromArchives->setEnabled(false); - } else { - ui->dataTabShowFromArchives->setCheckState(Qt::Checked); - ui->dataTabShowFromArchives->setEnabled(true); - } - m_OrganizerCore.refresh(); - } - - if (settings.paths().cache() != oldCacheDirectory) { - NexusInterface::instance().setCacheDirectory(settings.paths().cache()); - } - - if (proxy != settings.network().useProxy()) { - activateProxy(settings.network().useProxy()); - } - - ui->statusBar->checkSettings(m_OrganizerCore.settings()); - m_DownloadsTab->update(); - - m_OrganizerCore.setLogLevel(settings.diagnostics().logLevel()); - - if (settings.diagnostics().maxCoreDumps() != oldMaxDumps) { - m_OrganizerCore.cycleDiagnostics(); - } - - toggleMO2EndorseState(); - - if (oldCheckForUpdates != settings.checkForUpdates()) { - toggleUpdateAction(); - - if (settings.checkForUpdates()) { - m_OrganizerCore.checkForUpdates(); - } - } -} - -void MainWindow::onPluginRegistrationChanged() -{ - updateModPageMenu(); - scheduleCheckForProblems(); - m_DownloadsTab->update(); -} - -void MainWindow::refreshNexusCategories(CategoriesDialog* dialog) -{ - NexusInterface& nexus = NexusInterface::instance(); - nexus.setPluginContainer(&m_PluginContainer); - if (!Settings::instance().game().plugin()->primarySources().isEmpty()) { - nexus.requestGameInfo( - Settings::instance().game().plugin()->primarySources().first(), dialog, - QVariant(), QString()); - } else { - nexus.requestGameInfo(Settings::instance().game().plugin()->gameShortName(), dialog, - QVariant(), QString()); - } -} - -void MainWindow::categoriesSaved() -{ - for (const auto& modName : m_OrganizerCore.modList()->allMods()) { - auto mod = ModInfo::getByName(modName); - for (auto category : mod->getCategories()) { - if (!m_CategoryFactory.categoryExists(category)) - mod->setCategory(category, false); - } - } -} - -void MainWindow::on_actionNexus_triggered() -{ - const IPluginGame* game = m_OrganizerCore.managedGame(); - QString gameName = game->gameShortName(); - if (game->gameNexusName().isEmpty() && game->primarySources().count()) - gameName = game->primarySources()[0]; - shell::Open(QUrl(NexusInterface::instance().getGameURL(gameName))); -} - -void MainWindow::installTranslator(const QString& name) -{ - QTranslator* translator = new QTranslator(this); - QString fileName = name + "_" + m_CurrentLanguage; - if (!translator->load(fileName, qApp->applicationDirPath() + "/translations")) { - if (m_CurrentLanguage.contains(QRegularExpression("^.*_(EN|en)(-.*)?$"))) { - log::debug("localization file %s not found", fileName); - } // we don't actually expect localization files for English (en, en-us, en-uk, and - // any variation thereof) - } - - qApp->installTranslator(translator); - m_Translators.push_back(translator); -} - -void MainWindow::languageChange(const QString& newLanguage) -{ - for (QTranslator* trans : m_Translators) { - qApp->removeTranslator(trans); - } - m_Translators.clear(); - - m_CurrentLanguage = newLanguage; - - installTranslator("qt"); - installTranslator("qtbase"); - installTranslator(ToQString(AppConfig::translationPrefix())); - installTranslator("uibase"); - - // TODO: this will probably be changed once extension come out - installTranslator("game_gamebryo"); - installTranslator("game_creation"); - - for (const QString& fileName : m_PluginContainer.pluginFileNames()) { - installTranslator(QFileInfo(fileName).baseName()); - } - ui->retranslateUi(this); - log::debug("loaded language {}", newLanguage); - - ui->profileBox->setItemText(0, QObject::tr("")); - - createHelpMenu(); - - if (m_DownloadsTab) { - m_DownloadsTab->update(); - } - - ui->listOptionsBtn->setMenu( - new ModListGlobalContextMenu(m_OrganizerCore, ui->modList, this)); - ui->openFolderMenu->setMenu(openFolderMenu()); -} - -void MainWindow::originModified(int originID) -{ - FilesOrigin& origin = m_OrganizerCore.directoryStructure()->getOriginByID(originID); - origin.enable(false); - - DirectoryStats dummy; - QString path = QString::fromStdWString(origin.getPath()); - QString const modDataDir = m_OrganizerCore.managedGame()->modDataDirectory(); - path = modDataDir.isEmpty() ? path : path + "/" + modDataDir; - m_OrganizerCore.directoryStructure()->addFromOrigin( - origin.getName(), path.toStdWString(), origin.getPriority(), dummy); - - DirectoryRefresher::cleanStructure(m_OrganizerCore.directoryStructure()); -} - -void MainWindow::updateAvailable() -{ - ui->actionUpdate->setEnabled(true); - ui->actionUpdate->setToolTip(tr("Update available")); - ui->statusBar->setUpdateAvailable(true); -} - -void MainWindow::motdReceived(const QString& motd) -{ - // don't show motd after 5 seconds, may be annoying. Hopefully the user's - // internet connection is faster next time - if (m_StartTime.secsTo(QTime::currentTime()) < 5) { - uint const hash = qHash(motd); - if (hash != m_OrganizerCore.settings().motdHash()) { - MotDDialog dialog(motd); - dialog.exec(); - m_OrganizerCore.settings().setMotdHash(hash); - } - } -} - -void MainWindow::on_actionUpdate_triggered() -{ - m_OrganizerCore.startMOUpdate(); -} - -void MainWindow::on_actionExit_triggered() -{ - ExitModOrganizer(); -} - -void MainWindow::actionEndorseMO() -{ - // Normally this would be the managed game but MO2 is only uploaded to the Skyrim SE - // site right now - IPluginGame* game = m_OrganizerCore.getGame("skyrimse"); - if (!game) - return; - - if (QMessageBox::question( - this, tr("Endorse Mod Organizer"), - tr("Do you want to endorse Mod Organizer on %1 now?") - .arg(NexusInterface::instance().getGameURL(game->gameShortName())), - QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { - NexusInterface::instance().requestToggleEndorsement( - game->gameShortName(), game->nexusModOrganizerID(), - m_OrganizerCore.getVersion().string(), true, this, QVariant(), QString()); - } -} - -void MainWindow::actionWontEndorseMO() -{ - // Normally this would be the managed game but MO2 is only uploaded to the Skyrim SE - // site right now - IPluginGame* game = m_OrganizerCore.getGame("skyrimse"); - if (!game) - return; - - if (QMessageBox::question( - this, tr("Abstain from Endorsing Mod Organizer"), - tr("Are you sure you want to abstain from endorsing Mod Organizer 2?\n" - "You will have to visit the mod page on the %1 Nexus site to change your " - "mind.") - .arg(NexusInterface::instance().getGameURL(game->gameShortName())), - QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { - NexusInterface::instance().requestToggleEndorsement( - game->gameShortName(), game->nexusModOrganizerID(), - m_OrganizerCore.getVersion().string(), false, this, QVariant(), QString()); - } -} - -void MainWindow::toggleMO2EndorseState() -{ - const auto& s = m_OrganizerCore.settings(); - - if (!s.nexus().endorsementIntegration()) { - ui->actionEndorseMO->setVisible(false); - return; - } - - ui->actionEndorseMO->setVisible(true); - - bool enabled = false; - QString text; - - switch (s.nexus().endorsementState()) { - case EndorsementState::Accepted: { - text = tr("Thank you for endorsing MO2! :)"); - break; - } - - case EndorsementState::Refused: { - text = tr("Please reconsider endorsing MO2 on Nexus!"); - break; - } - - case EndorsementState::NoDecision: { - enabled = true; - break; - } - } - - ui->actionEndorseMO->menu()->setEnabled(enabled); - ui->actionEndorseMO->setToolTip(text); - ui->actionEndorseMO->setStatusTip(text); -} - -void MainWindow::toggleUpdateAction() -{ - const auto& s = m_OrganizerCore.settings(); - ui->actionUpdate->setVisible(s.checkForUpdates()); -} - -void MainWindow::nxmEndorsementsAvailable(QVariant userData, QVariant resultData, int) -{ - QVariantList const data = resultData.toList(); - std::multimap> sorted; - QStringList games = m_OrganizerCore.managedGame()->validShortNames(); - games += m_OrganizerCore.managedGame()->gameShortName(); - bool searchedMO2NexusGame = false; - for (const auto& endorsementData : data) { - QVariantMap endorsement = endorsementData.toMap(); - std::pair data = std::make_pair( - endorsement["mod_id"].toInt(), endorsement["status"].toString()); - sorted.insert(std::pair>( - endorsement["domain_name"].toString(), data)); - } - for (const auto& game : games) { - IPluginGame* gamePlugin = m_OrganizerCore.getGame(game); - if (gamePlugin != nullptr && - gamePlugin->gameShortName().compare("SkyrimSE", Qt::CaseInsensitive) == 0) - searchedMO2NexusGame = true; - auto iter = sorted.equal_range(gamePlugin->gameNexusName()); - for (auto result = iter.first; result != iter.second; ++result) { - std::vector const modsList = - ModInfo::getByModID(result->first, result->second.first); - - for (const auto& mod : modsList) { - if (mod->endorsedState() != EndorsedState::ENDORSED_NEVER) { - if (result->second.second == "Endorsed") - mod->setIsEndorsed(true); - else if (result->second.second == "Abstained") - mod->setNeverEndorse(); - else - mod->setIsEndorsed(false); - } - } - - if (Settings::instance().nexus().endorsementIntegration()) { - if (result->first == "skyrimspecialedition" && - result->second.first == gamePlugin->nexusModOrganizerID()) { - m_OrganizerCore.settings().nexus().setEndorsementState( - endorsementStateFromString(result->second.second)); - - toggleMO2EndorseState(); - } - } - } - } - - if (!searchedMO2NexusGame && Settings::instance().nexus().endorsementIntegration()) { - auto gamePlugin = m_OrganizerCore.getGame("SkyrimSE"); - if (gamePlugin) { - auto iter = sorted.equal_range(gamePlugin->gameNexusName()); - for (auto result = iter.first; result != iter.second; ++result) { - if (result->second.first == gamePlugin->nexusModOrganizerID()) { - m_OrganizerCore.settings().nexus().setEndorsementState( - endorsementStateFromString(result->second.second)); - - toggleMO2EndorseState(); - break; - } - } - } - } -} - -void MainWindow::nxmUpdateInfoAvailable(QString gameName, QVariant userData, - QVariant resultData, int) -{ - QString gameNameReal; - for (IPluginGame* game : m_PluginContainer.plugins()) { - if (game->gameNexusName() == gameName) { - gameNameReal = game->gameShortName(); - break; - } - } - QVariantList const resultList = resultData.toList(); - - auto* watcher = new QFutureWatcher(); - QObject::connect(watcher, &QFutureWatcher::finished, - [this, watcher]() { - finishUpdateInfo(watcher->result()); - watcher->deleteLater(); - }); - auto future = QtConcurrent::run([=]() { - return NxmUpdateInfoData{ - gameNameReal, - ModInfo::filteredMods(gameNameReal, resultList, userData.toBool(), true)}; - }); - watcher->setFuture(future); - ui->modList->invalidateFilter(); -} - -void MainWindow::finishUpdateInfo(const NxmUpdateInfoData& data) -{ - if (data.finalMods.empty()) { - log::info("{}", tr("None of your %1 mods appear to have had recent file updates.") - .arg(data.game)); - } - - std::set> organizedGames; - for (auto& mod : data.finalMods) { - if (mod->canBeUpdated()) { - organizedGames.insert( - std::make_pair(mod->gameName().toLower(), mod->nexusId())); - } - } - - if (!data.finalMods.empty() && organizedGames.empty()) - log::warn("{}", tr("All of your mods have been checked recently. We restrict " - "update checks to help preserve your available API requests.")); - - for (const auto& game : organizedGames) { - NexusInterface::instance().requestUpdates(game.second, this, QVariant(), game.first, - QString()); - } -} - -void MainWindow::nxmUpdatesAvailable(QString gameName, int modID, QVariant userData, - QVariant resultData, int requestID) -{ - QVariantMap resultInfo = resultData.toMap(); - QList const files = resultInfo["files"].toList(); - QList const fileUpdates = resultInfo["file_updates"].toList(); - QString gameNameReal; - - for (IPluginGame* game : m_PluginContainer.plugins()) { - if (game->gameNexusName() == gameName) { - gameNameReal = game->gameShortName(); - break; - } - } - - std::vector const modsList = ModInfo::getByModID(gameNameReal, modID); - - bool requiresInfo = false; - - for (const auto& mod : modsList) { - QString validNewVersion; - int newModStatus = -1; - QString const installedFile = QFileInfo(mod->installationFile()).fileName(); - - if (!installedFile.isEmpty()) { - QVariantMap foundFileData; - - // update the file status - for (auto& file : files) { - QVariantMap fileData = file.toMap(); - - if (fileData["file_name"].toString().compare(installedFile, - Qt::CaseInsensitive) == 0) { - foundFileData = fileData; - newModStatus = foundFileData["category_id"].toInt(); - - if (newModStatus != NexusInterface::FileStatus::OLD_VERSION && - newModStatus != NexusInterface::FileStatus::REMOVED && - newModStatus != NexusInterface::FileStatus::ARCHIVED) { - - // since the file is still active if there are no updates for it, use this - // as current version - validNewVersion = foundFileData["version"].toString(); - } - break; - } - } - - if (foundFileData.isEmpty()) { - // The file was not listed, the file is likely archived and archived files are - // being hidden on the mod - newModStatus = NexusInterface::FileStatus::ARCHIVED_HIDDEN; - } - - // look for updates of the file - int currentUpdateId = -1; - - // find installed file ID from the updates list since old filenames are not - // guaranteed to be unique - for (auto& updateEntry : fileUpdates) { - const QVariantMap& updateData = updateEntry.toMap(); - - if (installedFile.compare(updateData["old_file_name"].toString(), - Qt::CaseInsensitive) == 0) { - currentUpdateId = updateData["old_file_id"].toInt(); - break; - } - } - - bool foundActiveUpdate = false; - - // there is at least one update - if (currentUpdateId > 0) { - bool lookForMoreUpdates = true; - - // follow the update chain until there are no more updates - while (lookForMoreUpdates) { - lookForMoreUpdates = false; - - for (auto& updateEntry : fileUpdates) { - const QVariantMap& updateData = updateEntry.toMap(); - - if (currentUpdateId == updateData["old_file_id"].toInt()) { - currentUpdateId = updateData["new_file_id"].toInt(); - - // check if the new file is still active - for (auto& file : files) { - const QVariantMap& fileData = file.toMap(); - - if (currentUpdateId == fileData["file_id"].toInt()) { - int const updateStatus = fileData["category_id"].toInt(); - - if (updateStatus != NexusInterface::FileStatus::OLD_VERSION && - updateStatus != NexusInterface::FileStatus::REMOVED && - updateStatus != NexusInterface::FileStatus::ARCHIVED) { - - // new version is active, so record it - validNewVersion = fileData["version"].toString(); - foundActiveUpdate = true; - } - break; - } - } - - lookForMoreUpdates = true; - break; - } - } - } - } - - // if there were no active direct file updates for the installedFile - if (!foundActiveUpdate) { - // get the global mod version in case the file isn't an optional - if (newModStatus != NexusInterface::FileStatus::OPTIONAL_FILE && - newModStatus != NexusInterface::FileStatus::MISCELLANEOUS) { - requiresInfo = true; - } - } - } else { - // No installedFile means we don't know what to look at for a version so - // just get the global mod version - requiresInfo = true; - } - - if (newModStatus > 0) { - mod->setNexusFileStatus(newModStatus); - } - - if (!validNewVersion.isEmpty()) { - mod->setNewestVersion(validNewVersion); - mod->setLastNexusUpdate(QDateTime::currentDateTimeUtc()); - } - } - - // invalidate the filter to display mods with an update - ui->modList->invalidateFilter(); - - if (requiresInfo) { - NexusInterface::instance().requestModInfo(gameNameReal, modID, this, QVariant(), - QString()); - } -} - -void MainWindow::nxmModInfoAvailable(QString gameName, int modID, QVariant userData, - QVariant resultData, int requestID) -{ - QVariantMap result = resultData.toMap(); - QString gameNameReal; - bool foundUpdate = false; - - for (IPluginGame* game : m_PluginContainer.plugins()) { - if (game->gameNexusName() == gameName) { - gameNameReal = game->gameShortName(); - break; - } - } - - std::vector const modsList = ModInfo::getByModID(gameNameReal, modID); - - for (const auto& mod : modsList) { - QDateTime const now = QDateTime::currentDateTimeUtc(); - QDateTime const updateTarget = mod->getExpires(); - - // if file is still listed as optional or miscellaneous don't update the version as - // often optional files are left with an older version than the main mod version. - if (!result["version"].toString().isEmpty() && - mod->getNexusFileStatus() != NexusInterface::FileStatus::OPTIONAL_FILE && - mod->getNexusFileStatus() != NexusInterface::FileStatus::MISCELLANEOUS) { - - mod->setNewestVersion(result["version"].toString()); - foundUpdate = true; - } - - // update the LastNexusUpdate time in any case since we did perform the check. - mod->setLastNexusUpdate(QDateTime::currentDateTimeUtc()); - - mod->setNexusDescription(result["description"].toString()); - - mod->setNexusCategory(result["category_id"].toInt()); - - mod->setAuthor(result["author"].toString()); - - mod->setUploader(result["uploaded_by"].toString()); - - mod->setUploaderUrl(result["uploaded_users_profile_url"].toString()); - - if ((mod->endorsedState() != EndorsedState::ENDORSED_NEVER) && - (result.contains("endorsement"))) { - QVariantMap endorsement = result["endorsement"].toMap(); - QString const endorsementStatus = endorsement["endorse_status"].toString(); - - if (endorsementStatus.compare("Endorsed") == 00) - mod->setIsEndorsed(true); - else if (endorsementStatus.compare("Abstained") == 00) - mod->setNeverEndorse(); - else - mod->setIsEndorsed(false); - } - - mod->setLastNexusQuery(QDateTime::currentDateTimeUtc()); - mod->setNexusLastModified( - QDateTime::fromSecsSinceEpoch(result["updated_timestamp"].toInt(), QTimeZone::UTC)); - - m_OrganizerCore.modList()->notifyChange(ModInfo::getIndex(mod->name())); - } - - if (foundUpdate) { - // invalidate the filter to display mods with an update - ui->modList->invalidateFilter(); - } -} - -void MainWindow::nxmEndorsementToggled(QString, int, QVariant, QVariant resultData, int) -{ - const QMap results = resultData.toMap(); - - auto itor = results.find("status"); - if (itor == results.end()) { - log::error("endorsement response has no status"); - return; - } - - const auto s = endorsementStateFromString(itor->toString()); - - switch (s) { - case EndorsementState::Accepted: { - QMessageBox::information(this, tr("Thank you!"), - tr("Thank you for your endorsement!")); - break; - } - - case EndorsementState::Refused: { - // don't spam message boxes if the user doesn't want to endorse - log::info( - "Mod Organizer will not be endorsed and will no longer ask you to endorse."); - break; - } - - case EndorsementState::NoDecision: { - log::error("bad status '{}' in endorsement response", itor->toString()); - return; - } - } - - m_OrganizerCore.settings().nexus().setEndorsementState(s); - toggleMO2EndorseState(); - - if (!disconnect(sender(), - SIGNAL(nxmEndorsementToggled(QString, int, QVariant, QVariant, int)), - this, - SLOT(nxmEndorsementToggled(QString, int, QVariant, QVariant, int)))) { - log::error("failed to disconnect endorsement slot"); - } -} - -void MainWindow::nxmTrackedModsAvailable(QVariant userData, QVariant resultData, int) -{ - QMap gameNames; - for (auto game : m_PluginContainer.plugins()) { - gameNames[game->gameNexusName()] = game->gameShortName(); - } - - for (unsigned int i = 0; i < ModInfo::getNumMods(); i++) { - auto modInfo = ModInfo::getByIndex(i); - if (modInfo->nexusId() <= 0) - continue; - - bool found = false; - auto resultsList = resultData.toList(); - for (const auto& item : resultsList) { - auto results = item.toMap(); - if ((gameNames[results["domain_name"].toString()].compare( - modInfo->gameName(), Qt::CaseInsensitive) == 0) && - (results["mod_id"].toInt() == modInfo->nexusId())) { - found = true; - break; - } - } - - modInfo->setIsTracked(found); - modInfo->saveMeta(); - } -} - -void MainWindow::nxmDownloadURLs(QString, int, int, QVariant, QVariant resultData, int) -{ - auto servers = m_OrganizerCore.settings().network().servers(); - - for (const QVariant& var : resultData.toList()) { - const QVariantMap map = var.toMap(); - - const auto name = map["short_name"].toString(); - const auto isPremium = - map["name"].toString().contains("Premium", Qt::CaseInsensitive); - const auto isCDN = - map["short_name"].toString().contains("CDN", Qt::CaseInsensitive); - - bool found = false; - - for (auto& server : servers) { - if (server.name() == name) { - // already exists, update - server.setPremium(isPremium); - server.updateLastSeen(); - found = true; - break; - } - } - - if (!found) { - // new server - ServerInfo server(name, isPremium, QDate::currentDate(), isCDN ? 1 : 0, {}); - servers.add(std::move(server)); - } - } - - m_OrganizerCore.settings().network().updateServers(servers); -} - -void MainWindow::nxmGameInfoAvailable(QString gameName, QVariant, QVariant resultData, - int) -{ - QVariantMap result = resultData.toMap(); - QVariantList const categories = result["categories"].toList(); - CategoryFactory& catFactory = CategoryFactory::instance(); - catFactory.reset(); - for (const auto& category : categories) { - auto catMap = category.toMap(); - std::vector nexusCat; - nexusCat.push_back(CategoryFactory::NexusCategory(catMap["name"].toString(), - catMap["category_id"].toInt())); - catFactory.addCategory(catMap["name"].toString(), nexusCat, 0); - } -} - -void MainWindow::nxmRequestFailed(QString gameName, int modID, int, QVariant, int, - int errorCode, const QString& errorString) -{ - if (errorCode == QNetworkReply::ContentAccessDenied || - errorCode == QNetworkReply::ContentNotFoundError) { - log::debug("{}", - tr("Mod ID %1 no longer seems to be available on Nexus.").arg(modID)); - - // update last checked timestamp on orphaned mods as well to avoid repeating - // requests - QString gameNameReal; - for (IPluginGame* game : m_PluginContainer.plugins()) { - if (game->gameNexusName() == gameName) { - gameNameReal = game->gameShortName(); - break; - } - } - auto orphanedMods = ModInfo::getByModID(gameNameReal, modID); - for (const auto& mod : orphanedMods) { - mod->setLastNexusUpdate(QDateTime::currentDateTimeUtc()); - mod->setLastNexusQuery(QDateTime::currentDateTimeUtc()); - } - } else { - MessageDialog::showMessage( - tr("Error %1: Request to Nexus failed: %2").arg(errorCode).arg(errorString), - this); - } -} - -BSA::EErrorCode MainWindow::extractBSA(BSA::Archive& archive, BSA::Folder::Ptr folder, - const QString& destination, - QProgressDialog& progress) -{ - QDir().mkdir(destination); - BSA::EErrorCode result = BSA::ERROR_NONE; - QString const errorFile; - - for (unsigned int i = 0; i < folder->getNumFiles(); ++i) { - BSA::File::Ptr const file = folder->getFile(i); - BSA::EErrorCode const res = archive.extract(file, qUtf8Printable(destination)); - if (res != BSA::ERROR_NONE) { - reportError(tr("failed to read %1: %2").arg(file->getName().c_str()).arg(res)); - result = res; - } - progress.setLabelText(file->getName().c_str()); - progress.setValue(progress.value() + 1); - QCoreApplication::processEvents(); - if (progress.wasCanceled()) { - result = BSA::ERROR_CANCELED; - } - } - - if (result != BSA::ERROR_NONE) { - if (QMessageBox::critical( - this, tr("Error"), - tr("failed to extract %1 (errorcode %2)").arg(errorFile).arg(result), - QMessageBox::Ok | QMessageBox::Cancel) == QMessageBox::Cancel) { - return result; - } - } - - for (unsigned int i = 0; i < folder->getNumSubFolders(); ++i) { - BSA::Folder::Ptr const subFolder = folder->getSubFolder(i); - BSA::EErrorCode const res = extractBSA( - archive, subFolder, - destination.mid(0).append("/").append(subFolder->getName().c_str()), progress); - if (res != BSA::ERROR_NONE) { - return res; - } - } - return BSA::ERROR_NONE; -} - -bool MainWindow::extractProgress(QProgressDialog& progress, int percentage, - std::string fileName) -{ - progress.setLabelText(fileName.c_str()); - progress.setValue(percentage); - QCoreApplication::processEvents(); - return !progress.wasCanceled(); -} - -void MainWindow::extractBSATriggered(QTreeWidgetItem* item) -{ - using namespace boost::placeholders; - - QString origin; - - QString const targetFolder = - FileDialogMemory::getExistingDirectory("extractBSA", this, tr("Extract BSA")); - QStringList archives = {}; - if (!targetFolder.isEmpty()) { - if (!item->parent()) { - for (int i = 0; i < item->childCount(); ++i) { - archives.append(item->child(i)->text(0)); - } - origin = QDir::fromNativeSeparators( - ToQString(m_OrganizerCore.directoryStructure() - ->getOriginByName(ToWString(item->text(0))) - .getPath())); - } else { - origin = QDir::fromNativeSeparators( - ToQString(m_OrganizerCore.directoryStructure() - ->getOriginByName(ToWString(item->text(1))) - .getPath())); - archives = QStringList({item->text(0)}); - } - - for (const auto& archiveName : archives) { - BSA::Archive archive; - QString const archivePath = QDir(origin).filePath(archiveName); - BSA::EErrorCode const result = - archive.read(archivePath.toLocal8Bit().constData(), true); - if ((result != BSA::ERROR_NONE) && (result != BSA::ERROR_INVALIDHASHES)) { - reportError(tr("failed to read %1: %2").arg(archivePath).arg(result)); - return; - } - - QProgressDialog progress(this); - progress.setMaximum(100); - progress.setValue(0); - progress.show(); - archive.extractAll( - QDir::toNativeSeparators(targetFolder).toLocal8Bit().constData(), - boost::bind(&MainWindow::extractProgress, this, boost::ref(progress), _1, - _2)); - if (result == BSA::ERROR_INVALIDHASHES) { - reportError( - tr("This archive contains invalid hashes. Some files may be broken.")); - } - archive.close(); - } - } -} - -void MainWindow::on_bsaList_customContextMenuRequested(const QPoint& pos) -{ - QMenu menu; - menu.addAction(tr("Extract..."), [=, this, item = ui->bsaList->itemAt(pos)]() { - extractBSATriggered(item); - }); - - menu.exec(ui->bsaList->viewport()->mapToGlobal(pos)); -} - -void MainWindow::on_bsaList_itemChanged(QTreeWidgetItem*, int) -{ - m_ArchiveListWriter.write(); - m_CheckBSATimer.start(500); -} - -void MainWindow::on_actionNotifications_triggered() -{ - auto future = checkForProblemsAsync(); - - future.waitForFinished(); - - ProblemsDialog problems(m_PluginContainer, this); - problems.exec(); - - scheduleCheckForProblems(); -} - -void MainWindow::on_actionChange_Game_triggered() -{ - InstanceManagerDialog dlg(m_PluginContainer, this); - dlg.exec(); -} - -void MainWindow::setCategoryListVisible(bool visible) -{ - if (visible) { - ui->categoriesGroup->show(); - ui->displayCategoriesBtn->setText(ToQString(L"\u00ab")); - } else { - ui->categoriesGroup->hide(); - ui->displayCategoriesBtn->setText(ToQString(L"\u00bb")); - } -} - -void MainWindow::on_displayCategoriesBtn_toggled(bool checked) -{ - setCategoryListVisible(checked); -} - -void MainWindow::removeFromToolbar(QAction* action) -{ - const auto& title = action->text(); - auto& list = *m_OrganizerCore.executablesList(); - - auto itor = list.find(title); - if (itor == list.end()) { - log::warn("removeFromToolbar(): executable '{}' not found", title); - return; - } - - itor->setShownOnToolbar(false); - updatePinnedExecutables(); -} - -void MainWindow::toolBar_customContextMenuRequested(const QPoint& point) -{ - QAction* action = ui->toolBar->actionAt(point); - - if (action != nullptr) { - if (action->objectName().startsWith("custom_")) { - QMenu menu; - menu.addAction(tr("Remove '%1' from the toolbar").arg(action->text()), - [&, action]() { - removeFromToolbar(action); - }); - menu.exec(ui->toolBar->mapToGlobal(point)); - return; - } - } - - // did not click a link button, show the default context menu - auto* m = createPopupMenu(); - m->exec(ui->toolBar->mapToGlobal(point)); -} - -Executable* MainWindow::getSelectedExecutable() -{ - const QString name = - ui->executablesListBox->itemText(ui->executablesListBox->currentIndex()); - - try { - return &m_OrganizerCore.executablesList()->get(name); - } catch (std::runtime_error&) { - return nullptr; - } -} - -void MainWindow::on_showHiddenBox_toggled(bool checked) -{ - m_OrganizerCore.downloadManager()->setShowHidden(checked); -} - -const char* MainWindow::PATTERN_BACKUP_GLOB = ".????_??_??_??_??_??"; -const char* MainWindow::PATTERN_BACKUP_REGEX = - R"(\.(\d\d\d\d_\d\d_\d\d_\d\d_\d\d_\d\d))"; -const char* MainWindow::PATTERN_BACKUP_DATE = "yyyy_MM_dd_hh_mm_ss"; - -bool MainWindow::createBackup(const QString& filePath, const QDateTime& time) -{ - QString const outPath = filePath + "." + time.toString(PATTERN_BACKUP_DATE); - if (shellCopy(QStringList(filePath), QStringList(outPath), this)) { - QFileInfo const fileInfo(filePath); - removeOldFiles(fileInfo.absolutePath(), fileInfo.fileName() + PATTERN_BACKUP_GLOB, - 10, QDir::Name); - return true; - } else { - return false; - } -} - -void MainWindow::on_saveButton_clicked() -{ - m_OrganizerCore.savePluginList(); - QDateTime const now = QDateTime::currentDateTime(); - if (createBackup(m_OrganizerCore.currentProfile()->getPluginsFileName(), now) && - createBackup(m_OrganizerCore.currentProfile()->getLoadOrderFileName(), now) && - createBackup(m_OrganizerCore.currentProfile()->getLockedOrderFileName(), now)) { - MessageDialog::showMessage(tr("Backup of load order created"), this); - } -} - -QString MainWindow::queryRestore(const QString& filePath) -{ - QFileInfo const pluginFileInfo(filePath); - QString const pattern = pluginFileInfo.fileName() + ".*"; - QFileInfoList files = pluginFileInfo.absoluteDir().entryInfoList( - QStringList(pattern), QDir::Files, QDir::Name); - - SelectionDialog dialog(tr("Choose backup to restore"), this); - QRegularExpression const exp(QRegularExpression::anchoredPattern(pluginFileInfo.fileName() + - PATTERN_BACKUP_REGEX)); - // match orphaned SafeWriteFile temporaries - QRegularExpression const exp2(QRegularExpression::anchoredPattern( - pluginFileInfo.fileName() + "\\.([A-Za-z]{6})")); - QRegularExpression const exp3( - QRegularExpression::anchoredPattern(pluginFileInfo.fileName() + "\\.(.*)")); - for (const QFileInfo& info : boost::adaptors::reverse(files)) { - auto match = exp.match(info.fileName()); - auto match2 = exp2.match(info.fileName()); - auto match3 = exp3.match(info.fileName()); - if (match.hasMatch()) { - QDateTime const time = QDateTime::fromString(match.captured(1), PATTERN_BACKUP_DATE); - dialog.addChoice(time.toString(), "", match.captured(1)); - } else if (match2.hasMatch()) { - dialog.addChoice(match2.captured(1), - tr("This file might be left over following a crash or power " - "loss event. Check its contents before restoring."), - match2.captured(1)); - } else if (match3.hasMatch()) { - dialog.addChoice(match3.captured(1), "", match3.captured(1)); - } - } - - if (dialog.numChoices() == 0) { - QMessageBox::information(this, tr("No Backups"), - tr("There are no backups to restore")); - return {}; - } - - if (dialog.exec() == QDialog::Accepted) { - return dialog.getChoiceData().toString(); - } else { - return {}; - } -} - -void MainWindow::on_restoreButton_clicked() -{ - QString const pluginName = m_OrganizerCore.currentProfile()->getPluginsFileName(); - QString const choice = queryRestore(pluginName); - if (!choice.isEmpty()) { - QString const loadOrderName = m_OrganizerCore.currentProfile()->getLoadOrderFileName(); - QString const lockedName = m_OrganizerCore.currentProfile()->getLockedOrderFileName(); - if (!shellCopy(pluginName + "." + choice, pluginName, true, this) || - !shellCopy(loadOrderName + "." + choice, loadOrderName, true, this) || - !shellCopy(lockedName + "." + choice, lockedName, true, this)) { - - const auto e = GetLastError(); - - QMessageBox::critical(this, tr("Restore failed"), - tr("Failed to restore the backup. Errorcode: %1") - .arg(QString::fromStdWString(formatSystemMessage(e)))); - } - m_OrganizerCore.refreshESPList(true); - } -} - -void MainWindow::on_saveModsButton_clicked() -{ - m_OrganizerCore.currentProfile()->writeModlistNow(true); - QDateTime const now = QDateTime::currentDateTime(); - if (createBackup(m_OrganizerCore.currentProfile()->getModlistFileName(), now)) { - MessageDialog::showMessage(tr("Backup of mod list created"), this); - } -} - -void MainWindow::on_restoreModsButton_clicked() -{ - QString const modlistName = m_OrganizerCore.currentProfile()->getModlistFileName(); - QString const choice = queryRestore(modlistName); - if (!choice.isEmpty()) { - if (!shellCopy(modlistName + "." + choice, modlistName, true, this)) { - const auto e = GetLastError(); - QMessageBox::critical(this, tr("Restore failed"), - tr("Failed to restore the backup. Errorcode: %1") - .arg(QString::fromStdWString(formatSystemMessage(e)))); - } - m_OrganizerCore.refresh(false); - } -} - -void MainWindow::on_managedArchiveLabel_linkHovered(const QString&) -{ - QToolTip::showText(QCursor::pos(), ui->managedArchiveLabel->toolTip()); -} - -void MainWindow::dragEnterEvent(QDragEnterEvent* event) -{ - // Accept copy or move drags to the download window. Link drags are not - // meaningful (Well, they are - we could drop a link in the download folder, - // but you need privileges to do that). - if (ui->downloadTab->isVisible() && - (event->proposedAction() == Qt::CopyAction || - event->proposedAction() == Qt::MoveAction) && - event->answerRect().intersects(ui->downloadTab->rect())) { - - // If I read the documentation right, this won't work under a motif windows - // manager and the check needs to be done at the drop. However, that means - // the user might be allowed to drop things which we can't sanely process - QMimeData const* data = event->mimeData(); - - if (data->hasUrls()) { - QStringList const extensions = - m_OrganizerCore.installationManager()->getSupportedExtensions(); - - // This is probably OK - scan to see if these are moderately sane archive - // types - QList const urls = data->urls(); - bool ok = true; - for (const QUrl& url : urls) { - if (url.isLocalFile()) { - QString const local = url.toLocalFile(); - bool fok = false; - for (const auto& ext : extensions) { - if (local.endsWith(ext, Qt::CaseInsensitive)) { - fok = true; - break; - } - } - if (!fok) { - ok = false; - break; - } - } - } - if (ok) { - event->accept(); - } - } - } -} - -void MainWindow::dropLocalFile(const QUrl& url, const QString& outputDir, bool move) -{ - QFileInfo const file(url.toLocalFile()); - if (!file.exists()) { - log::warn("invalid source file: {}", file.absoluteFilePath()); - return; - } - QString target = outputDir + "/" + file.fileName(); - if (QFile::exists(target)) { - QMessageBox box(QMessageBox::Question, file.fileName(), - tr("A file with the same name has already been downloaded. " - "What would you like to do?")); - box.addButton(tr("Overwrite"), QMessageBox::ActionRole); - box.addButton(tr("Rename new file"), QMessageBox::YesRole); - box.addButton(tr("Ignore file"), QMessageBox::RejectRole); - - box.exec(); - switch (box.buttonRole(box.clickedButton())) { - case QMessageBox::RejectRole: - return; - case QMessageBox::ActionRole: - break; - default: - case QMessageBox::YesRole: - target = m_OrganizerCore.downloadManager()->getDownloadFileName(file.fileName()); - break; - } - } - - bool success = false; - if (move) { - success = shellMove(file.absoluteFilePath(), target, true, this); - } else { - success = shellCopy(file.absoluteFilePath(), target, true, this); - } - if (!success) { - const auto e = GetLastError(); - log::error("file operation failed: {}", formatSystemMessage(e)); - } -} - -void MainWindow::dropEvent(QDropEvent* event) -{ - Qt::DropAction const action = event->proposedAction(); - QString const outputDir = m_OrganizerCore.downloadManager()->getOutputDirectory(); - if (action == Qt::MoveAction) { - // Tell windows I'm taking control and will delete the source of a move. - event->setDropAction(Qt::TargetMoveAction); - } - for (const QUrl& url : event->mimeData()->urls()) { - if (url.isLocalFile()) { - dropLocalFile(url, outputDir, action == Qt::MoveAction); - } else { - m_OrganizerCore.downloadManager()->startDownloadURLs(QStringList() << url.url()); - } - } - event->accept(); -} - -void MainWindow::keyReleaseEvent(QKeyEvent* event) -{ - // if the ui is locked, ignore the ALT key event - // alt-tabbing out of a game triggers this - auto& uilocker = UILocker::instance(); - auto& settings = Settings::instance(); - if (!uilocker.locked()) { - // if the menubar is hidden and showMenuBarOnAlt is true, - // pressing Alt will make it visible - if (event->key() == Qt::Key_Alt) { - bool const showMenubarOnAlt = settings.interface().showMenubarOnAlt(); - if (showMenubarOnAlt && !ui->menuBar->isVisible()) { - ui->menuBar->show(); - } - } - } - - QMainWindow::keyReleaseEvent(event); -} +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "mainwindow.h" +#include "ui_mainwindow.h" + +#include + +#include "aboutdialog.h" +#ifdef MO2_WEBENGINE +#include "browserdialog.h" +#endif +#include "categories.h" +#include "categoriesdialog.h" +#include "datatab.h" +#include "filetree.h" +#include "downloadlist.h" +#include "downloadstab.h" +#include "editexecutablesdialog.h" +#include "envshortcut.h" +#include "eventfilter.h" +#include "executableinfo.h" +#include "executableslist.h" +#include "filedialogmemory.h" +#include "filterlist.h" +#include "guessedvalue.h" +#include "imodinterface.h" +#include "installationmanager.h" +#include "instancemanager.h" +#include "instancemanagerdialog.h" +#include "iplugindiagnose.h" +#include "iplugingame.h" +#include "isavegame.h" +#include "isavegameinfowidget.h" +#include "listdialog.h" +#include "localsavegames.h" +#include "messagedialog.h" +#include "modlist.h" +#include "modlistcontextmenu.h" +#include "modlistviewactions.h" +#include "motddialog.h" +#include "nexusinterface.h" +#include "nxmaccessmanager.h" +#include "organizercore.h" +#include "overwriteinfodialog.h" +#include "pluginlist.h" +#include "previewdialog.h" +#include "previewgenerator.h" +#include "problemsdialog.h" +#include "profile.h" +#include "profilesdialog.h" +#include "savestab.h" +#include "selectiondialog.h" +#include "serverinfo.h" +#include "settingsdialog.h" +#include "shared/appconfig.h" +#include "spawn.h" +#include "statusbar.h" +#include "systemtraymanager.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "fluorinepaths.h" + +#include "directoryrefresher.h" +#include "shared/directoryentry.h" +#include "shared/fileentry.h" +#include "shared/filesorigin.h" + +#include "slrmanager.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef MO2_WEBENGINE +#include +#endif +#include +#include + +#include +#include + +#ifndef Q_MOC_RUN +#include +#include +#include +#include +#include +#endif + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "gameplugins.h" + +#ifdef TEST_MODELS +#include "modeltest.h" +#endif // TEST_MODELS + +#pragma warning(disable : 4428) + +using namespace MOBase; +using namespace MOShared; + +const QSize SmallToolbarSize(24, 24); +const QSize MediumToolbarSize(32, 32); +const QSize LargeToolbarSize(42, 36); + +QString UnmanagedModName() +{ + return QObject::tr(""); +} + +void setFilterShortcuts(QWidget* widget, QLineEdit* edit) +{ + auto activate = [=] { + edit->setFocus(); + edit->selectAll(); + }; + + auto reset = [=] { + edit->clear(); + widget->setFocus(); + }; + + auto hookActivate = [activate](auto* w) { + auto* s = new QShortcut(QKeySequence::Find, w); + s->setAutoRepeat(false); + s->setContext(Qt::WidgetWithChildrenShortcut); + QObject::connect(s, &QShortcut::activated, activate); + }; + + auto hookReset = [reset](auto* w) { + auto* s = new QShortcut(QKeySequence(Qt::Key_Escape), w); + s->setAutoRepeat(false); + s->setContext(Qt::WidgetWithChildrenShortcut); + QObject::connect(s, &QShortcut::activated, reset); + }; + + hookActivate(widget); + hookReset(widget); + + hookActivate(edit); + hookReset(edit); +} + +MainWindow::MainWindow(Settings& settings, OrganizerCore& organizerCore, + PluginContainer& pluginContainer, QWidget* parent) + : QMainWindow(parent), ui(new Ui::MainWindow), m_Tutorial(this, "MainWindow"), + + m_CategoryFactory(CategoryFactory::instance()), m_OrganizerCore(organizerCore), + m_PluginContainer(pluginContainer), + m_ArchiveListWriter(std::bind(&MainWindow::saveArchiveList, this)), + m_NumberOfProblems(0), + m_ProblemsCheckRequired(false) +{ + // disables incredibly slow menu fade in effect that looks and feels like crap. + // this was only happening to users with the windows + // "Fade or slide menus into view" effect enabled. + // maybe in the future the effects will be better at this moment they aren't. + QApplication::setEffectEnabled(Qt::UI_FadeMenu, false); + QApplication::setEffectEnabled(Qt::UI_AnimateMenu, false); + QApplication::setEffectEnabled(Qt::UI_AnimateCombo, false); + QApplication::setEffectEnabled(Qt::UI_AnimateTooltip, false); + QApplication::setEffectEnabled(Qt::UI_FadeTooltip, false); + // setDesktopFileName moved to MOApplication constructor (must be set before + // first window surface for Wayland app_id to take effect) + +#ifdef MO2_WEBENGINE + QWebEngineProfile::defaultProfile()->setPersistentCookiesPolicy( + QWebEngineProfile::NoPersistentCookies); + QWebEngineProfile::defaultProfile()->setHttpCacheMaximumSize(52428800); + QWebEngineProfile::defaultProfile()->setCachePath(settings.paths().cache()); + QWebEngineProfile::defaultProfile()->setPersistentStoragePath( + settings.paths().cache()); + + // qt resets the thread name somewhere within the QWebEngineProfile calls + // above + MOShared::SetThisThreadName("main"); +#endif + + ui->setupUi(this); + languageChange(settings.interface().language()); + ui->statusBar->setup(ui, settings); + + m_SystemTrayManager = new SystemTrayManager(this, ui->logDock); + + { + auto& ni = NexusInterface::instance(); + + // there are two ways to get here: + // 1) the user just started MO, and + // 2) the user has changed some setting that required a restart + // + // "restarting" MO doesn't actually re-execute the binary, it just basically + // executes most of main() again, so a bunch of things are actually not + // reset + // + // one of these things is the api status, which will have fired its events + // long before the execution gets here because stuff is still cached and no + // real request to nexus is actually done + // + // therefore, when the user starts MO normally, the user account and stats + // will be empty (which is fine) and populated later on when the api key + // check has finished + // + // in the rare case where the user restarts MO through the settings, this + // will correctly pick up the previous values + updateWindowTitle(ni.getAPIUserAccount()); + ui->statusBar->setAPI(ni.getAPIStats(), ni.getAPIUserAccount()); + } + + m_CategoryFactory.loadCategories(); + + ui->logList->setCore(m_OrganizerCore); + + setupToolbar(); + toggleMO2EndorseState(); + toggleUpdateAction(); + + TaskProgressManager::instance().tryCreateTaskbar(); + + setupModList(); + ui->espList->setup(m_OrganizerCore, this, ui); + ui->bsaList->setLocalMoveOnly(true); + ui->bsaList->setHeaderHidden(true); + + const bool pluginListAdjusted = + settings.geometry().restoreState(ui->espList->header()); + + // data tab + m_DataTab.reset(new DataTab(m_OrganizerCore, m_PluginContainer, this, ui)); + m_DataTab->restoreState(settings); + + connect(m_DataTab.get(), &DataTab::executablesChanged, [&] { + refreshExecutablesList(); + }); + + connect(m_DataTab.get(), &DataTab::originModified, [&](int id) { + originModified(id); + }); + + connect(m_DataTab.get(), &DataTab::displayModInformation, + [&](auto&& m, auto&& i, auto&& tab) { + displayModInformation(m, i, tab); + }); + + // downloads tab + m_DownloadsTab.reset(new DownloadsTab(m_OrganizerCore, ui)); + + // saves tab + m_SavesTab.reset(new SavesTab(this, m_OrganizerCore, ui)); + + // Hide stuff we do not need: + auto& features = m_OrganizerCore.gameFeatures(); + if (!features.gameFeature()) { + ui->tabWidget->removeTab(ui->tabWidget->indexOf(ui->espTab)); + } + if (!features.gameFeature()) { + ui->tabWidget->removeTab(ui->tabWidget->indexOf(ui->bsaTab)); + } + + settings.geometry().restoreState(ui->downloadView->header()); + settings.geometry().restoreState(ui->savegameList->header()); + + ui->splitter->setStretchFactor(0, 3); + ui->splitter->setStretchFactor(1, 2); + + resizeLists(pluginListAdjusted); + + QMenu* linkMenu = new QMenu(this); + m_LinkToolbar = linkMenu->addAction(QIcon(":/MO/gui/link"), tr("Toolbar and Menu"), + this, SLOT(linkToolbar())); + m_LinkDesktop = linkMenu->addAction(QIcon(":/MO/gui/link"), tr("Desktop"), this, + SLOT(linkDesktop())); + m_LinkStartMenu = linkMenu->addAction(QIcon(":/MO/gui/link"), + tr("Application Launcher"), this, + SLOT(linkMenu())); + ui->linkButton->setMenu(linkMenu); + + ui->listOptionsBtn->setMenu( + new ModListGlobalContextMenu(m_OrganizerCore, ui->modList, this)); + + ui->openFolderMenu->setMenu(openFolderMenu()); + + // don't allow mouse wheel to switch grouping, too many people accidentally + // turn on grouping and then don't understand what happened + EventFilter* noWheel = new EventFilter(this, [](QObject*, QEvent* event) -> bool { + return event->type() == QEvent::Wheel; + }); + + ui->groupCombo->installEventFilter(noWheel); + ui->profileBox->installEventFilter(noWheel); + + connect(&m_PluginContainer, SIGNAL(diagnosisUpdate()), this, + SLOT(scheduleCheckForProblems())); + + connect(&m_OrganizerCore, &OrganizerCore::directoryStructureReady, this, + &MainWindow::onDirectoryStructureChanged); + + // OrganizerCore::modStatusChanged updates the DirectoryStructure in place + // but never emits directoryStructureReady, so without this the Data tab + // stays stale until the user clicks Refresh. Route the ModList signal to + // DataTab::updateTree directly. + connect(m_OrganizerCore.modList(), &ModList::modStatesChanged, this, + [this] { m_DataTab->updateTree(); }); + connect(m_OrganizerCore.directoryRefresher(), + SIGNAL(progress(const DirectoryRefreshProgress*)), this, + SLOT(refresherProgress(const DirectoryRefreshProgress*))); + connect(m_OrganizerCore.directoryRefresher(), SIGNAL(error(QString)), this, + SLOT(showError(QString))); + + connect(&m_OrganizerCore.settings(), SIGNAL(languageChanged(QString)), this, + SLOT(languageChange(QString))); + connect(&m_OrganizerCore.settings(), SIGNAL(styleChanged(QString)), this, + SIGNAL(styleChanged(QString))); + + connect(m_OrganizerCore.updater(), SIGNAL(restart()), this, SLOT(close())); + connect(m_OrganizerCore.updater(), SIGNAL(updateAvailable()), this, + SLOT(updateAvailable())); + connect(m_OrganizerCore.updater(), SIGNAL(motdAvailable(QString)), this, + SLOT(motdReceived(QString))); + connect(&NexusInterface::instance(), SIGNAL(requestNXMDownload(QString)), + &m_OrganizerCore, SLOT(downloadRequestedNXM(QString))); + connect(&NexusInterface::instance(), + SIGNAL(nxmDownloadURLsAvailable(QString, int, int, QVariant, QVariant, int)), + this, SLOT(nxmDownloadURLs(QString, int, int, QVariant, QVariant, int))); + connect(&NexusInterface::instance(), SIGNAL(needLogin()), &m_OrganizerCore, + SLOT(nexusApi())); + + connect(NexusInterface::instance().getAccessManager(), + &NXMAccessManager::credentialsReceived, this, &MainWindow::updateWindowTitle); + connect(&NexusInterface::instance(), &NexusInterface::requestsChanged, ui->statusBar, + &StatusBar::setAPI); + + connect(&TutorialManager::instance(), SIGNAL(windowTutorialFinished(QString)), this, + SLOT(windowTutorialFinished(QString))); + connect(ui->tabWidget, SIGNAL(currentChanged(int)), &TutorialManager::instance(), + SIGNAL(tabChanged(int))); + connect(ui->toolBar, SIGNAL(customContextMenuRequested(QPoint)), this, + SLOT(toolBar_customContextMenuRequested(QPoint))); + connect(ui->menuToolbars, &QMenu::aboutToShow, [&] { + updateToolbarMenu(); + }); + connect(ui->menuView, &QMenu::aboutToShow, [&] { + updateViewMenu(); + }); + connect(ui->actionTool->menu(), &QMenu::aboutToShow, [&] { + updateToolMenu(); + }); + connect(&m_PluginContainer, &PluginContainer::pluginEnabled, this, + [this](IPlugin* plugin) { + if (m_PluginContainer.implementInterface(plugin)) { + updateModPageMenu(); + } + }); + connect(&m_PluginContainer, &PluginContainer::pluginDisabled, this, + [this](IPlugin* plugin) { + if (m_PluginContainer.implementInterface(plugin)) { + updateModPageMenu(); + } + }); + connect(&m_PluginContainer, &PluginContainer::pluginRegistered, this, + &MainWindow::onPluginRegistrationChanged); + connect(&m_PluginContainer, &PluginContainer::pluginUnregistered, this, + &MainWindow::onPluginRegistrationChanged); + + connect(&m_OrganizerCore, &OrganizerCore::modInstalled, this, + &MainWindow::modInstalled); + + connect(&m_CategoryFactory, SIGNAL(nexusCategoryRefresh(CategoriesDialog*)), this, + SLOT(refreshNexusCategories(CategoriesDialog*))); + connect(&m_CategoryFactory, SIGNAL(categoriesSaved()), this, SLOT(categoriesSaved())); + + m_CheckBSATimer.setSingleShot(true); + connect(&m_CheckBSATimer, SIGNAL(timeout()), this, SLOT(checkBSAList())); + + setFilterShortcuts(ui->modList, ui->modFilterEdit); + setFilterShortcuts(ui->espList, ui->espFilterEdit); + setFilterShortcuts(ui->downloadView, ui->downloadFilterEdit); + + m_UpdateProblemsTimer.setSingleShot(true); + connect(&m_UpdateProblemsTimer, &QTimer::timeout, this, + &MainWindow::checkForProblemsAsync); + connect(this, &MainWindow::checkForProblemsDone, this, + &MainWindow::updateProblemsButton, Qt::ConnectionType::QueuedConnection); + + m_SaveMetaTimer.setSingleShot(false); + connect(&m_SaveMetaTimer, SIGNAL(timeout()), this, SLOT(saveModMetas())); + m_SaveMetaTimer.start(5000); + + FileDialogMemory::restore(settings); + + fixCategories(); + + m_StartTime = QTime::currentTime(); + + m_Tutorial.expose("modList", m_OrganizerCore.modList()); + m_Tutorial.expose("espList", m_OrganizerCore.pluginList()); + + m_OrganizerCore.setUserInterface(this); + m_OrganizerCore.onFinishedRun([=, this](const QString, unsigned int) { + if (isHidden()) { + m_SystemTrayManager->restoreFromSystemTray(); + } + }); + + connect(m_OrganizerCore.modList(), &ModList::showMessage, [=, this](auto&& message) { + showMessage(message); + }); + connect(m_OrganizerCore.modList(), &ModList::modRenamed, + [=, this](auto&& oldName, auto&& newName) { + modRenamed(oldName, newName); + }); + connect(m_OrganizerCore.modList(), &ModList::modUninstalled, [=, this](auto&& name) { + modRemoved(name); + }); + connect(m_OrganizerCore.modList(), &ModList::fileMoved, [=, this](auto&&... args) { + fileMoved(args...); + }); + connect(m_OrganizerCore.installationManager(), &InstallationManager::modReplaced, + [=, this](auto&& name) { + modRemoved(name); + }); + connect(m_OrganizerCore.downloadManager(), &DownloadManager::showMessage, + [=, this](auto&& message) { + showMessage(message); + }); + for (const QString& fileName : m_PluginContainer.pluginFileNames()) { + installTranslator(QFileInfo(fileName).baseName()); + } + + updateModPageMenu(); + + // refresh profiles so the current profile can be activated + refreshProfiles(false); + + ui->profileBox->setCurrentText(m_OrganizerCore.currentProfile()->name()); + + if (settings.archiveParsing()) { + ui->dataTabShowFromArchives->setCheckState(Qt::Checked); + ui->dataTabShowFromArchives->setEnabled(true); + } else { + ui->dataTabShowFromArchives->setCheckState(Qt::Unchecked); + ui->dataTabShowFromArchives->setEnabled(false); + } + + QApplication::instance()->installEventFilter(this); + + scheduleCheckForProblems(); + refreshExecutablesList(); + updatePinnedExecutables(); + resetActionIcons(); + resetButtonIcons(); + processUpdates(); + + ui->modList->updateModCount(); + ui->espList->updatePluginCount(); + ui->statusBar->updateNormalMessage(m_OrganizerCore); +} + +void MainWindow::setupModList() +{ + ui->modList->setup(m_OrganizerCore, m_CategoryFactory, this, ui); + + connect(&ui->modList->actions(), &ModListViewActions::overwriteCleared, [=, this]() { + scheduleCheckForProblems(); + }); + connect(&ui->modList->actions(), &ModListViewActions::originModified, this, + &MainWindow::originModified); + connect(&ui->modList->actions(), &ModListViewActions::modInfoDisplayed, this, + &MainWindow::modInfoDisplayed); + + connect(m_OrganizerCore.modList(), &ModList::modPrioritiesChanged, [&]() { + m_ArchiveListWriter.write(); + }); +} + +void MainWindow::resetActionIcons() +{ + // this is a bit of a hack + // + // the .qss files have historically set qproperty-icon by id and these ids + // correspond to the QActions created in the .ui file + // + // the problem is that QActions do not support having their icon property + // set from a .qss because they're not widgets (they don't inherit from + // QWidget), and styling only works on widget + // + // a QAction _does_ have an associated icon, it just can't be set from a .qss + // file + // + // so here, a dummy QToolButton widget is created for each QAction and is + // given the same name as the action, which makes it pick up the icon + // specified in the .qss file + // + // that icon is then given to the widget used by the QAction (if it's some + // sort of button, which typically happens on the toolbar) _and_ to the + // QAction itself, which is used in the menu bar + + // clearing the notification, will be set below if the stylesheet has set + // anything for it + m_originalNotificationIcon = {}; + + // QActions created from the .ui file are children of the main window + for (QAction* action : findChildren()) { + // creating a dummy button + auto dummy = std::make_unique(); + + // reusing the action name + dummy->setObjectName(action->objectName()); + + // styling the button, this has to be done manually because the button is + // never added anywhere + style()->polish(dummy.get()); + + // the button's icon may be null if it wasn't specified in the .qss file, + // which can happen if the stylesheet just doesn't override icons, or for + // other actions like the pinned custom executables + const auto icon = dummy->icon(); + if (icon.isNull()) { + continue; + } + + // button associated with the action on the toolbar + QWidget* actionWidget = ui->toolBar->widgetForAction(action); + + if (auto* actionButton = dynamic_cast(actionWidget)) { + actionButton->setIcon(icon); + } + + // the action's icon is used by the menu bar + action->setIcon(icon); + + if (action == ui->actionNotifications) { + // if the stylesheet has set a notification icon, remember it here so it + // can be used in updateProblemsButton() + m_originalNotificationIcon = icon; + } + } + + // update the button for the potentially new icon + updateProblemsButton(); +} + +void MainWindow::resetButtonIcons() +{ + // Some icon-only QPushButtons can lose their icons after stylesheet repolish on + // Linux/AppImage. Re-apply explicit resource icons to keep these controls visible. + ui->listOptionsBtn->setIcon(QIcon::fromTheme( + "preferences-system", QIcon(":/MO/gui/settings"))); + ui->openFolderMenu->setIcon( + QIcon::fromTheme("folder-open", QIcon(":/MO/gui/open_folder"))); + ui->restoreModsButton->setIcon( + QIcon::fromTheme("edit-undo", QIcon(":/MO/gui/restore"))); + ui->saveModsButton->setIcon( + QIcon::fromTheme("document-save", QIcon(":/MO/gui/backup"))); + ui->restoreButton->setIcon( + QIcon::fromTheme("edit-undo", QIcon(":/MO/gui/restore"))); + ui->saveButton->setIcon(QIcon::fromTheme("document-save", QIcon(":/MO/gui/backup"))); + + ui->listOptionsBtn->setIconSize(QSize(16, 16)); + ui->openFolderMenu->setIconSize(QSize(16, 16)); + ui->restoreModsButton->setIconSize(QSize(16, 16)); + ui->saveModsButton->setIconSize(QSize(16, 16)); + ui->restoreButton->setIconSize(QSize(16, 16)); + ui->saveButton->setIconSize(QSize(16, 16)); +} + +MainWindow::~MainWindow() +{ + try { + cleanup(); + + m_OrganizerCore.setUserInterface(nullptr); + +#ifdef MO2_WEBENGINE + if (m_IntegratedBrowser) { + m_IntegratedBrowser->close(); + m_IntegratedBrowser.reset(); + } +#endif + + delete ui; + } catch (std::exception& e) { + QMessageBox::critical( + nullptr, tr("Crash on exit"), + tr("MO crashed while exiting. Some settings may not be saved.\n\nError: %1") + .arg(e.what()), + QMessageBox::Ok); + } +} + +void MainWindow::updateWindowTitle(const APIUserAccount& user) +{ + //"\xe2\x80\x93" is an "em dash", a longer "-" +#if FLUORINE_IS_BETA_BUILD + // Beta builds mutate on every CI run, so a fixed semver is misleading — + // show the short commit hash instead so users can tell exactly which + // build they're on at a glance. + const QString commitShort = QStringLiteral(FLUORINE_BUILD_COMMIT); + const QString versionLabel = + commitShort.isEmpty() + ? QStringLiteral("beta") + : (QStringLiteral("beta @ ") + commitShort); +#else + const QString versionLabel = + m_OrganizerCore.getVersion().string(Version::FormatCondensed); +#endif + + QString title = + QString("%1 \xe2\x80\x93 Fluorine Manager %2") + .arg(m_OrganizerCore.managedGame()->displayGameName(), versionLabel); + + if (!user.name().isEmpty()) { + const QString premium = (user.type() == APIUserAccountTypes::Premium ? "*" : ""); + title.append(QString(" (%1%2)").arg(user.name(), premium)); + } + + this->setWindowTitle(title); +} + +void MainWindow::resizeLists(bool pluginListCustom) +{ + // ensure the columns aren't so small you can't see them any more + for (int i = 0; i < ui->modList->header()->count(); ++i) { + if (ui->modList->header()->sectionSize(i) < 10) { + ui->modList->header()->resizeSection(i, 10); + } + } + + if (!pluginListCustom) { + // resize plugin list to fit content + for (int i = 0; i < ui->espList->header()->count(); ++i) { + ui->espList->header()->setSectionResizeMode(i, QHeaderView::ResizeToContents); + } + ui->espList->header()->setSectionResizeMode(0, QHeaderView::Stretch); + } +} + +void MainWindow::allowListResize() +{ + // allow resize on mod list + for (int i = 0; i < ui->modList->header()->count(); ++i) { + ui->modList->header()->setSectionResizeMode(i, QHeaderView::Interactive); + } + ui->modList->header()->setStretchLastSection(true); + + // allow resize on plugin list + for (int i = 0; i < ui->espList->header()->count(); ++i) { + ui->espList->header()->setSectionResizeMode(i, QHeaderView::Interactive); + } + ui->espList->header()->setStretchLastSection(true); +} + +void MainWindow::updateStyle(const QString&) +{ + resetActionIcons(); + resetButtonIcons(); +} + +void MainWindow::resizeEvent(QResizeEvent* event) +{ + m_Tutorial.resize(event->size()); + QMainWindow::resizeEvent(event); +} + +void MainWindow::setupToolbar() +{ + setupActionMenu(ui->actionModPage); + setupActionMenu(ui->actionTool); + setupActionMenu(ui->actionHelp); + setupActionMenu(ui->actionEndorseMO); + + createHelpMenu(); + createEndorseMenu(); + + // find last separator, add a spacer just before it so the icons are + // right-aligned + m_linksSeparator = nullptr; + for (auto* a : ui->toolBar->actions()) { + if (a->isSeparator()) { + m_linksSeparator = a; + } + } + + if (m_linksSeparator) { + auto* spacer = new QWidget(ui->toolBar); + spacer->setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Preferred); + ui->toolBar->insertWidget(m_linksSeparator, spacer); + + } else { + log::warn("no separator found on the toolbar, icons won't be right-aligned"); + } + + if (!InstanceManager::singleton().allowedToChangeInstance()) { + ui->actionChange_Game->setVisible(false); + } +} + +void MainWindow::setupActionMenu(QAction* a) +{ + a->setMenu(new QMenu(this)); + + auto* w = ui->toolBar->widgetForAction(a); + if (auto* tb = dynamic_cast(w)) + tb->setPopupMode(QToolButton::InstantPopup); +} + +void MainWindow::updatePinnedExecutables() +{ + for (auto* a : ui->toolBar->actions()) { + if (a->objectName().startsWith("custom__")) { + ui->toolBar->removeAction(a); + a->deleteLater(); + } + } + + ui->menuRun->clear(); + + bool hasLinks = false; + + for (const auto& exe : *m_OrganizerCore.executablesList()) { + if (!exe.hide() && exe.isShownOnToolbar()) { + hasLinks = true; + + QAction* exeAction = + new QAction(iconForExecutable(exe.binaryInfo().filePath()), exe.title()); + + exeAction->setObjectName(QString("custom__") + exe.title()); + exeAction->setStatusTip(exe.binaryInfo().filePath()); + + if (!connect(exeAction, SIGNAL(triggered()), this, SLOT(startExeAction()))) { + log::debug("failed to connect trigger?"); + } + + if (m_linksSeparator) { + ui->toolBar->insertAction(m_linksSeparator, exeAction); + } else { + // separator wasn't found, add it to the end + ui->toolBar->addAction(exeAction); + } + + ui->menuRun->addAction(exeAction); + } + } + + // don't show the menu if there are no links + ui->menuRun->menuAction()->setVisible(hasLinks); +} + +void MainWindow::updateToolbarMenu() +{ + ui->actionMainMenuToggle->setChecked(ui->menuBar->isVisible()); + ui->actionToolBarMainToggle->setChecked(ui->toolBar->isVisible()); + ui->actionStatusBarToggle->setChecked(ui->statusBar->isVisible()); + + ui->actionToolBarSmallIcons->setChecked(ui->toolBar->iconSize() == SmallToolbarSize); + ui->actionToolBarMediumIcons->setChecked(ui->toolBar->iconSize() == + MediumToolbarSize); + ui->actionToolBarLargeIcons->setChecked(ui->toolBar->iconSize() == LargeToolbarSize); + + ui->actionToolBarIconsOnly->setChecked(ui->toolBar->toolButtonStyle() == + Qt::ToolButtonIconOnly); + ui->actionToolBarTextOnly->setChecked(ui->toolBar->toolButtonStyle() == + Qt::ToolButtonTextOnly); + ui->actionToolBarIconsAndText->setChecked(ui->toolBar->toolButtonStyle() == + Qt::ToolButtonTextUnderIcon); +} + +void MainWindow::updateViewMenu() +{ + ui->actionViewLog->setChecked(ui->logDock->isVisible()); +} + +QMenu* MainWindow::createPopupMenu() +{ + auto* m = new QMenu; + + // add all the actions from the toolbars menu + for (auto* a : ui->menuToolbars->actions()) { + m->addAction(a); + } + + m->addSeparator(); + + // other actions + m->addAction(ui->actionViewLog); + + // make sure the actions are updated + updateToolbarMenu(); + updateViewMenu(); + + return m; +} + +void MainWindow::on_actionMainMenuToggle_triggered() +{ + ui->menuBar->setVisible(!ui->menuBar->isVisible()); +} + +void MainWindow::on_actionToolBarMainToggle_triggered() +{ + ui->toolBar->setVisible(!ui->toolBar->isVisible()); +} + +void MainWindow::on_actionStatusBarToggle_triggered() +{ + ui->statusBar->setVisible(!ui->statusBar->isVisible()); +} + +void MainWindow::on_actionToolBarSmallIcons_triggered() +{ + setToolbarSize(SmallToolbarSize); +} + +void MainWindow::on_actionToolBarMediumIcons_triggered() +{ + setToolbarSize(MediumToolbarSize); +} + +void MainWindow::on_actionToolBarLargeIcons_triggered() +{ + setToolbarSize(LargeToolbarSize); +} + +void MainWindow::on_actionToolBarIconsOnly_triggered() +{ + setToolbarButtonStyle(Qt::ToolButtonIconOnly); +} + +void MainWindow::on_actionToolBarTextOnly_triggered() +{ + setToolbarButtonStyle(Qt::ToolButtonTextOnly); +} + +void MainWindow::on_actionToolBarIconsAndText_triggered() +{ + setToolbarButtonStyle(Qt::ToolButtonTextUnderIcon); +} + +void MainWindow::on_actionViewLog_triggered() +{ + ui->logDock->setVisible(!ui->logDock->isVisible()); +} + +void MainWindow::setToolbarSize(const QSize& s) +{ + for (auto* tb : findChildren()) { + tb->setIconSize(s); + } +} + +void MainWindow::setToolbarButtonStyle(Qt::ToolButtonStyle s) +{ + for (auto* tb : findChildren()) { + tb->setToolButtonStyle(s); + } +} + +void MainWindow::on_centralWidget_customContextMenuRequested(const QPoint& pos) +{ + // this allows for getting the context menu even if both the menubar and all + // the toolbars are hidden; an alternative is the Alt key handled in + // keyPressEvent() below + + // the custom context menu event bubbles up to here if widgets don't actually + // process this, which would show the menu when right-clicking button, labels, + // etc. + // + // only show the context menu when right-clicking on the central widget + // itself, which is basically just the outer edges of the main window + auto* w = childAt(pos); + if (w != ui->centralWidget) { + return; + } + + createPopupMenu()->exec(ui->centralWidget->mapToGlobal(pos)); +} + +void MainWindow::scheduleCheckForProblems() +{ + if (!m_UpdateProblemsTimer.isActive()) { + m_UpdateProblemsTimer.start(500); + } +} + +void MainWindow::updateProblemsButton() +{ + // if the current stylesheet doesn't provide an icon, this is used instead + const char* DefaultIconName = ":/MO/gui/warning"; + + const std::size_t numProblems = m_NumberOfProblems; + + // original icon without a count painted on it + const QIcon original = m_originalNotificationIcon.isNull() + ? QIcon(DefaultIconName) + : m_originalNotificationIcon; + + // final icon + QIcon final; + + if (numProblems > 0) { + ui->actionNotifications->setToolTip(tr("There are notifications to read")); + + // will contain the original icon, plus a notification count; this also + // makes sure the pixmap is exactly 64x64 by requesting the icon that's + // as close to 64x64 as possible, and then scaling it up if it's too small + QPixmap merged = original.pixmap(64, 64).scaled(64, 64); + + { + QPainter painter(&merged); + + const std::string badgeName = + std::string(":/MO/gui/badge_") + + (numProblems < 10 ? std::to_string(static_cast(numProblems)) + : "more"); + + painter.drawPixmap(32, 32, 32, 32, QPixmap(badgeName.c_str())); + } + + final = QIcon(merged); + } else { + ui->actionNotifications->setToolTip(tr("There are no notifications")); + + // no change + final = original; + } + + ui->actionNotifications->setEnabled(numProblems > 0); + + // setting the icon on the action (shown on the menu) + ui->actionNotifications->setIcon(final); + + // setting the icon on the toolbar button + if (auto* actionWidget = ui->toolBar->widgetForAction(ui->actionNotifications)) { + if (auto* button = dynamic_cast(actionWidget)) { + button->setIcon(final); + } + } + + // updating the status bar, may be null very early when MO is starting + if (ui->statusBar) { + ui->statusBar->setNotifications(numProblems > 0); + } +} + +bool MainWindow::errorReported(QString& logFile) +{ + QDir const dir(qApp->property("dataPath").toString() + "/" + + QString::fromStdWString(AppConfig::logPath())); + QFileInfoList const files = + dir.entryInfoList(QStringList("ModOrganizer_??_??_??_??_??.log"), QDir::Files, + QDir::Name | QDir::Reversed); + + if (files.count() > 0) { + logFile = files.at(0).absoluteFilePath(); + QFile file(logFile); + if (file.open(QIODevice::ReadOnly | QIODevice::Text)) { + char buffer[1024]; + int line = 0; + while (!file.atEnd()) { + file.readLine(buffer, 1024); + if (strncmp(buffer, "ERROR", 5) == 0) { + return true; + } + + // prevent this function from taking forever + if (line++ >= 50000) { + break; + } + } + } + } + + return false; +} + +QFuture MainWindow::checkForProblemsAsync() +{ + return QtConcurrent::run([this]() { + checkForProblemsImpl(); + }); +} + +void MainWindow::checkForProblemsImpl() +{ + m_ProblemsCheckRequired = true; + + std::scoped_lock const lk(m_CheckForProblemsMutex); + + // another thread might already have checked while this one was waiting on the lock + if (m_ProblemsCheckRequired) { + m_ProblemsCheckRequired = false; + TimeThis const tt("MainWindow::checkForProblemsImpl()"); + size_t numProblems = 0; + for (QObject* pluginObj : m_PluginContainer.plugins()) { + IPlugin* plugin = qobject_cast(pluginObj); + if (plugin == nullptr || m_PluginContainer.isEnabled(plugin)) { + IPluginDiagnose* diagnose = qobject_cast(pluginObj); + if (diagnose != nullptr) + numProblems += diagnose->activeProblems().size(); + } + } + m_NumberOfProblems = numProblems; + emit checkForProblemsDone(); + } +} + +void MainWindow::about() +{ + AboutDialog(m_OrganizerCore.getVersion().string(Version::FormatCondensed), this) + .exec(); +} + +void MainWindow::createEndorseMenu() +{ + auto* menu = ui->actionEndorseMO->menu(); + if (!menu) { + // shouldn't happen + return; + } + + menu->clear(); + + QAction* endorseAction = new QAction(tr("Endorse"), menu); + connect(endorseAction, SIGNAL(triggered()), this, SLOT(actionEndorseMO())); + menu->addAction(endorseAction); + + QAction* wontEndorseAction = new QAction(tr("Won't Endorse"), menu); + connect(wontEndorseAction, SIGNAL(triggered()), this, SLOT(actionWontEndorseMO())); + menu->addAction(wontEndorseAction); +} + +void MainWindow::createHelpMenu() +{ + //: Translation strings for tutorial names + static std::map translate = { + {"First Steps", QT_TR_NOOP("First Steps")}, + {"Conflict Resolution", QT_TR_NOOP("Conflict Resolution")}, + {"Overview", QT_TR_NOOP("Overview")}}; + + auto* menu = ui->actionHelp->menu(); + if (!menu) { + // this happens on startup because languageChanged() (which calls this) is + // called before the menus are actually created + return; + } + + menu->clear(); + + QAction* helpAction = new QAction(tr("Help on UI"), menu); + connect(helpAction, SIGNAL(triggered()), this, SLOT(helpTriggered())); + menu->addAction(helpAction); + + QAction* wikiAction = new QAction(tr("Documentation"), menu); + connect(wikiAction, SIGNAL(triggered()), this, SLOT(wikiTriggered())); + menu->addAction(wikiAction); + + if (!m_OrganizerCore.managedGame()->getSupportURL().isEmpty()) { + QAction* gameSupportAction = new QAction(tr("Game Support Wiki"), menu); + connect(gameSupportAction, SIGNAL(triggered()), this, SLOT(gameSupportTriggered())); + menu->addAction(gameSupportAction); + } + + QAction* discordAction = new QAction(tr("Chat on Discord"), menu); + connect(discordAction, SIGNAL(triggered()), this, SLOT(discordTriggered())); + menu->addAction(discordAction); + + QAction* issueAction = new QAction(tr("Report Issue"), menu); + connect(issueAction, SIGNAL(triggered()), this, SLOT(issueTriggered())); + menu->addAction(issueAction); + + QMenu* tutorialMenu = new QMenu(tr("Tutorials"), menu); + + using ActionList = std::vector>; + + ActionList tutorials; + + QDirIterator dirIter(QApplication::applicationDirPath() + "/tutorials", + QStringList("*.js"), QDir::Files); + while (dirIter.hasNext()) { + dirIter.next(); + QString fileName = dirIter.fileName(); + + QFile file(dirIter.filePath()); + if (!file.open(QIODevice::ReadOnly)) { + log::error("Failed to open {}", fileName); + continue; + } + QString const firstLine = QString::fromUtf8(file.readLine()); + if (firstLine.startsWith("//TL")) { + QStringList const params = firstLine.mid(4).trimmed().split('#'); + if (params.size() != 2) { + log::error("invalid header line for tutorial {}, expected 2 parameters", + fileName); + continue; + } + QAction* tutAction = new QAction(tr(translate[params.at(0)]), tutorialMenu); + tutAction->setData(fileName); + tutorials.emplace_back(params.at(1).toInt(), tutAction); + } + } + + std::sort(tutorials.begin(), tutorials.end(), + [](const ActionList::value_type& LHS, const ActionList::value_type& RHS) { + return LHS.first < RHS.first; + }); + + for (auto iter = tutorials.begin(); iter != tutorials.end(); ++iter) { + connect(iter->second, SIGNAL(triggered()), this, SLOT(tutorialTriggered())); + tutorialMenu->addAction(iter->second); + } + + menu->addMenu(tutorialMenu); + menu->addAction(tr("About"), this, SLOT(about())); + menu->addAction(tr("About Qt"), qApp, SLOT(aboutQt())); +} + +bool MainWindow::addProfile() +{ + QComboBox* profileBox = findChild("profileBox"); + bool okClicked = false; + + QString const name = QInputDialog::getText(this, tr("Name"), + tr("Please enter a name for the new profile"), + QLineEdit::Normal, QString(), &okClicked); + if (okClicked && (name.size() > 0)) { + try { + profileBox->addItem(name); + profileBox->setCurrentIndex(profileBox->count() - 1); + return true; + } catch (const std::exception& e) { + reportError(tr("failed to create profile: %1").arg(e.what())); + return false; + } + } else { + return false; + } +} + +void MainWindow::hookUpWindowTutorials() +{ + QDirIterator dirIter(QApplication::applicationDirPath() + "/tutorials", + QStringList("*.js"), QDir::Files); + while (dirIter.hasNext()) { + dirIter.next(); + QString fileName = dirIter.fileName(); + QFile file(dirIter.filePath()); + if (!file.open(QIODevice::ReadOnly)) { + log::error("Failed to open {}", fileName); + continue; + } + QString const firstLine = QString::fromUtf8(file.readLine()); + if (firstLine.startsWith("//WIN")) { + QString const windowName = firstLine.mid(6).trimmed(); + if (!m_OrganizerCore.settings().interface().isTutorialCompleted(windowName)) { + TutorialManager::instance().activateTutorial(windowName, fileName); + } + } + } +} + +bool MainWindow::shouldStartTutorial() +{ + if (GlobalSettings::hideTutorialQuestion()) { + return false; + } + + QMessageBox dlg( + QMessageBox::Question, tr("Show tutorial?"), + tr("You are starting Mod Organizer for the first time. " + "Do you want to show a tutorial of its basic features? If you choose " + "no you can always start the tutorial from the \"Help\" menu."), + QMessageBox::Yes | QMessageBox::No); + + dlg.setCheckBox(new QCheckBox(tr("Never ask to show tutorials"))); + + const auto r = dlg.exec(); + + if (dlg.checkBox()->isChecked()) { + GlobalSettings::setHideTutorialQuestion(true); + } + + return (r == QMessageBox::Yes); +} + +void MainWindow::showEvent(QShowEvent* event) +{ + QMainWindow::showEvent(event); + + if (!m_WasVisible) { + ui->modList->refreshFilters(); + readSettings(); + + // this needs to be connected here instead of in the constructor because the + // actual changing of the stylesheet is done by MOApplication, which + // connects its signal in runApplication() (in main.cpp), and that happens + // _after_ the MainWindow is constructed, but _before_ it is shown + // + // by connecting the event here, changing the style setting will first be + // handled by MOApplication, and then in updateStyle(), at which point the + // stylesheet has already been set correctly + connect(this, SIGNAL(styleChanged(QString)), this, SLOT(updateStyle(QString))); + + // only the first time the window becomes visible + m_Tutorial.registerControl(); + + hookUpWindowTutorials(); + + if (m_OrganizerCore.settings().firstStart()) { + QString firstStepsTutorial = ToQString(AppConfig::firstStepsTutorial()); + if (TutorialManager::instance().hasTutorial(firstStepsTutorial)) { + if (shouldStartTutorial()) { + TutorialManager::instance().activateTutorial("MainWindow", + firstStepsTutorial); + } + } else { + log::error("{} missing", firstStepsTutorial); + QPoint pos = ui->toolBar->mapToGlobal(QPoint()); + pos.rx() += ui->toolBar->width() / 2; + pos.ry() += ui->toolBar->height(); + QWhatsThis::showText(pos, + QObject::tr("Please use \"Help\" from the toolbar to get " + "usage instructions to all elements")); + } + + if (!m_OrganizerCore.managedGame()->getSupportURL().isEmpty()) { + QMessageBox::information(this, tr("Game Support Wiki"), + tr("Do you know how to mod this game? Do you need to " + "learn? There's a game support wiki available! " + "Click OK to open the wiki. In the future, you can " + "access this link from the \"Help\" menu."), + QMessageBox::Ok); + gameSupportTriggered(); + } + + QMessageBox newCatDialog; + newCatDialog.setWindowTitle(tr("Category Setup")); + newCatDialog.setText( + tr("Please choose how to handle the default category setup.\n\n" + "If you've already connected to Nexus, you can automatically import Nexus " + "categories for this game (if applicable). Otherwise, use the old Mod " + "Organizer default category structure, or leave the categories blank (for " + "manual setup).")); + QPushButton importBtn(tr("&Import Nexus Categories")); + QPushButton defaultBtn(tr("Use &Old Category Defaults")); + QPushButton cancelBtn(tr("Do &Nothing")); + if (NexusInterface::instance().getAccessManager()->validated()) { + newCatDialog.addButton(&importBtn, QMessageBox::ButtonRole::AcceptRole); + } + newCatDialog.addButton(&defaultBtn, QMessageBox::ButtonRole::AcceptRole); + newCatDialog.addButton(&cancelBtn, QMessageBox::ButtonRole::RejectRole); + newCatDialog.exec(); + if (newCatDialog.clickedButton() == &importBtn) { + importCategories(false); + } else if (newCatDialog.clickedButton() == &cancelBtn) { + m_CategoryFactory.reset(); + } else if (newCatDialog.clickedButton() == &defaultBtn) { + m_CategoryFactory.loadCategories(); + } + m_CategoryFactory.saveCategories(); + + m_OrganizerCore.settings().setFirstStart(false); + } else { + auto& settings = m_OrganizerCore.settings(); + // The category migration dialog was meant to fire once per upgrade + // across the upstream MO2 2.4 -> 2.5 cut. Fluorine now stores its own + // 0.x.y version in the same slot, so lastVersion < 2.5 is true on + // every Fluorine launch and the dialog would nag forever. Skip it + // when lastVersion looks like a Fluorine build. + const bool fluorineVersionScheme = + m_LastVersion.majorVersion() == 0; + if (!fluorineVersionScheme && + m_LastVersion < QVersionNumber(2, 5) && + !GlobalSettings::hideCategoryReminder()) { + QMessageBox migrateCatDialog; + migrateCatDialog.setWindowTitle("Category Migration"); + migrateCatDialog.setText( + tr("This is your first time running version 2.5 or higher with an old MO2 " + "instance. The category system now relies on an updated system to map " + "Nexus categories.\n\n" + "In order to assign Nexus categories automatically, you will need to " + "import the Nexus categories for the currently managed game and map " + "them to your preferred category structure.\n\n" + "You can either manually open the category editor, via the Settings " + "dialog or the category filter sidebar, and set up the mappings as you " + "see fit, or you can automatically import and map the categories as " + "defined on Nexus.\n\n" + "As a final option, you can disable Nexus category mapping altogether, " + "which can be changed at any time in the Settings dialog.")); + QPushButton importBtn(tr("&Import Nexus Categories")); + QPushButton openSettingsBtn(tr("&Open Categories Dialog")); + QPushButton disableBtn(tr("&Disable Nexus Mappings")); + QPushButton closeBtn(tr("&Close")); + QCheckBox dontShow(tr("&Don't show this again")); + if (NexusInterface::instance().getAccessManager()->validated()) { + migrateCatDialog.addButton(&importBtn, QMessageBox::ButtonRole::AcceptRole); + } + migrateCatDialog.addButton(&openSettingsBtn, + QMessageBox::ButtonRole::ActionRole); + migrateCatDialog.addButton(&disableBtn, + QMessageBox::ButtonRole::DestructiveRole); + migrateCatDialog.addButton(&closeBtn, QMessageBox::ButtonRole::RejectRole); + migrateCatDialog.setCheckBox(&dontShow); + migrateCatDialog.exec(); + if (migrateCatDialog.clickedButton() == &importBtn) { + importCategories(dontShow.isChecked()); + } else if (migrateCatDialog.clickedButton() == &openSettingsBtn) { + this->ui->filtersEdit->click(); + } else if (migrateCatDialog.clickedButton() == &disableBtn) { + Settings::instance().nexus().setCategoryMappings(false); + } + if (dontShow.isChecked()) { + GlobalSettings::setHideCategoryReminder(true); + } + } + } + + m_OrganizerCore.settings().widgets().restoreIndex(ui->groupCombo); + + m_OrganizerCore.settings().nexus().registerAsNXMHandler(false); + m_WasVisible = true; + updateProblemsButton(); + + // notify plugins that the MO2 is ready + m_PluginContainer.startPlugins(this); + + // forces a log list refresh to display startup logs + // + // since the log list is not visible until this point, the automatic + // resize of columns seems to break the log list (since Qt 5.15.1 or + // 5.15.2), an make the list empty on startup (in debug the list is not + // empty because some logs are added after the log list becomes visible) + // + // the reset() forces a re-computation of the column size, thus properly + // the logs that are already in the log model + // + ui->logList->reset(); + ui->logList->scrollToBottom(); + } +} + +void MainWindow::paintEvent(QPaintEvent* event) +{ + if (m_FirstPaint) { + allowListResize(); + m_FirstPaint = false; + } + + QMainWindow::paintEvent(event); +} + +void MainWindow::onBeforeClose() +{ + storeSettings(); +} + +void MainWindow::closeEvent(QCloseEvent* event) +{ + if (isVisible()) { + // this is messy + // + // the main problem this is solving is when closing MO, then getting the + // lock overlay because processes are still running, then pressing the X + // again + // + // in this case, closeEvent() is _not_ called for the second event and the + // window is immediately hidden + // + // this always saves the settings here; in the event where a lock overlay + // is then shown, it might save settings multiple times, but it's harmless + onBeforeClose(); + } + + // this happens for two reasons: + // 1) the user requested to close the window, such as clicking the X + // 2) close() is called in runApplication() after application.exec() + // returns, which happens when qApp->exit() is called + // + // the window must never actually close for 1), because settings haven't been + // saved yet: the state of many widgets is saved to the ini, which relies on + // the window still being onscreen (or else everything is considered hidden) + // + // for 2), the settings have been saved and the window can just close + + if (ModOrganizerCanCloseNow()) { + // the user has confirmed if necessary and all settings have been saved, + // just close it + QMainWindow::closeEvent(event); + return; + } + + if (UILocker::instance().locked()) { + // don't bother asking the user to confirm if the ui is already locked + event->ignore(); + ExitModOrganizer(Exit::Force); + return; + } + + if (ModOrganizerExiting()) { + // ignore repeated attempts + event->ignore(); + return; + } + + // never close the window because settings might need to be changed + event->ignore(); + + // start the process of exiting, which may require confirmation by calling + // canExit(), among other things + ExitModOrganizer(); +} + +bool MainWindow::canExit() +{ + if (m_OrganizerCore.downloadManager()->downloadsInProgressNoPause()) { + if (QMessageBox::question( + this, tr("Downloads in progress"), + tr("There are still downloads in progress, do you really want to quit?"), + QMessageBox::Yes | QMessageBox::Cancel) == QMessageBox::Cancel) { + return false; + } else { + m_OrganizerCore.downloadManager()->pauseAll(); + } + } + + const auto r = OrganizerCore::waitForAllUSVFSProcesses(); + if (r == ProcessRunner::Cancelled) { + return false; + } + + setCursor(Qt::WaitCursor); + return true; +} + +void MainWindow::cleanup() +{ +#ifdef MO2_WEBENGINE + QWebEngineProfile::defaultProfile()->clearAllVisitedLinks(); +#endif + +#ifdef MO2_WEBENGINE + if (m_IntegratedBrowser) { + m_IntegratedBrowser->close(); + m_IntegratedBrowser = {}; + } +#endif + + m_SaveMetaTimer.stop(); + m_MetaSave.waitForFinished(); +} + +bool MainWindow::eventFilter(QObject* object, QEvent* event) +{ + if (event->type() == QEvent::StatusTip && object != this) { + QMainWindow::event(event); + return true; + } + + return false; +} + +void MainWindow::registerPluginTool(IPluginTool* tool, QString name, QMenu* menu) +{ + if (!menu) { + menu = ui->actionTool->menu(); + } + + if (name.isEmpty()) + name = tool->displayName(); + + QAction* action = new QAction(tool->icon(), name, menu); + action->setToolTip(tool->tooltip()); + tool->setParentWidget(this); + connect( + action, &QAction::triggered, this, + [this, tool]() { + try { + tool->display(); + } catch (const std::exception& e) { + reportError( + tr("Plugin \"%1\" failed: %2").arg(tool->localizedName()).arg(e.what())); + } catch (...) { + reportError(tr("Plugin \"%1\" failed").arg(tool->localizedName())); + } + }, + Qt::QueuedConnection); + + menu->addAction(action); +} + +void MainWindow::updateToolMenu() +{ + // Clear the menu: + ui->actionTool->menu()->clear(); + + std::vector toolPlugins = m_PluginContainer.plugins(); + + // Sort the plugins by display name + std::sort(std::begin(toolPlugins), std::end(toolPlugins), + [](IPluginTool* left, IPluginTool* right) { + return left->displayName().toLower() < right->displayName().toLower(); + }); + + // Remove disabled plugins: + toolPlugins.erase(std::remove_if(std::begin(toolPlugins), std::end(toolPlugins), + [&](auto* tool) { + return !m_PluginContainer.isEnabled(tool); + }), + toolPlugins.end()); + + // Group the plugins into submenus + QMap>> submenuMap; + for (auto *toolPlugin : toolPlugins) { + QStringList toolName = toolPlugin->displayName().split("/"); + QString const submenu = toolName[0]; + toolName.pop_front(); + submenuMap[submenu].append( + QPair(toolName.join("/"), toolPlugin)); + } + + // Start registering plugins + for (const auto& submenuKey : submenuMap.keys()) { + if (submenuMap[submenuKey].length() > 1) { + QMenu* submenu = new QMenu(submenuKey, this); + for (const auto& info : submenuMap[submenuKey]) { + registerPluginTool(info.second, info.first, submenu); + } + ui->actionTool->menu()->addMenu(submenu); + } else { + registerPluginTool(submenuMap[submenuKey].front().second); + } + } +} + +void MainWindow::registerModPage(IPluginModPage* modPage) +{ + QAction* action = new QAction(modPage->icon(), modPage->displayName(), this); + connect( + action, &QAction::triggered, this, + [this, modPage]() { +#ifdef MO2_WEBENGINE + if (modPage->useIntegratedBrowser()) { + + if (!m_IntegratedBrowser) { + m_IntegratedBrowser.reset(new BrowserDialog); + + connect(m_IntegratedBrowser.get(), + SIGNAL(requestDownload(QUrl, QNetworkReply*)), &m_OrganizerCore, + SLOT(requestDownload(QUrl, QNetworkReply*))); + } + + m_IntegratedBrowser->setWindowTitle(modPage->displayName()); + m_IntegratedBrowser->openUrl(modPage->pageURL()); + } else +#endif + { + shell::Open(QUrl(modPage->pageURL())); + } + }, + Qt::QueuedConnection); + + ui->actionModPage->menu()->addAction(action); +} + +bool MainWindow::registerNexusPage(const QString& gameName) +{ + // Get the plugin + IPluginGame* plugin = m_OrganizerCore.getGame(gameName); + if (plugin == nullptr) + return false; + + // Get the gameURL + QString const gameURL = NexusInterface::instance().getGameURL(gameName); + if (gameURL.isEmpty()) + return false; + + // Create an action + QAction* action = new QAction(plugin->gameIcon(), + QObject::tr("Visit %1 on Nexus").arg(gameName), this); + + // Bind the action + connect( + action, &QAction::triggered, this, + [this, gameURL]() { + shell::Open(QUrl(gameURL)); + }, + Qt::QueuedConnection); + + // Add the action + ui->actionModPage->menu()->addAction(action); + + return true; +} + +void MainWindow::updateModPageMenu() +{ + // Clear the menu: + ui->actionModPage->menu()->clear(); + + // Determine the loaded mod page plugins + std::vector modPagePlugins = + m_PluginContainer.plugins(); + + // Sort the plugins by display name + std::sort(std::begin(modPagePlugins), std::end(modPagePlugins), + [](IPluginModPage* left, IPluginModPage* right) { + return left->displayName().toLower() < right->displayName().toLower(); + }); + + // Remove disabled plugins + modPagePlugins.erase(std::remove_if(std::begin(modPagePlugins), + std::end(modPagePlugins), + [&](auto* tool) { + return !m_PluginContainer.isEnabled(tool); + }), + modPagePlugins.end()); + + for (auto* modPagePlugin : modPagePlugins) { + registerModPage(modPagePlugin); + } + + QStringList registeredSources; + + // Add the primary game + QString const gameShortName = m_OrganizerCore.managedGame()->gameShortName(); + if (registerNexusPage(gameShortName)) + registeredSources << gameShortName; + + // Add the primary sources + for (const auto& gameName : m_OrganizerCore.managedGame()->primarySources()) { + if (!registeredSources.contains(gameName) && registerNexusPage(gameName)) + registeredSources << gameName; + } + + // Add a separator if needed + if (!registeredSources.empty()) + ui->actionModPage->menu()->addSeparator(); + + // Add the secondary games (sorted) + QStringList secondaryGames = m_OrganizerCore.managedGame()->validShortNames(); + secondaryGames.sort(Qt::CaseInsensitive); + for (const auto& gameName : secondaryGames) { + if (!registeredSources.contains(gameName) && registerNexusPage(gameName)) + registeredSources << gameName; + } + + // No mod page plugin and the menu was visible + bool const keepOriginalAction = + modPagePlugins.empty() && registeredSources.length() <= 1; + if (keepOriginalAction) { + ui->toolBar->insertAction(ui->actionAdd_Profile, ui->actionNexus); + } else { + ui->toolBar->removeAction(ui->actionNexus); + } + ui->actionModPage->setVisible(!keepOriginalAction); +} + +void MainWindow::startExeAction() +{ + QAction* action = qobject_cast(sender()); + + if (action == nullptr) { + log::error("not an action?"); + return; + } + + const auto& list = *m_OrganizerCore.executablesList(); + + const auto title = action->text(); + auto itor = list.find(title); + + if (itor == list.end()) { + log::warn("startExeAction(): executable '{}' not found", title); + return; + } + + action->setEnabled(false); + Guard const g([&] { + action->setEnabled(true); + }); + + if (itor->minimizeToSystemTray()) { + m_SystemTrayManager->minimizeToSystemTray(); + } + + m_OrganizerCore.processRunner() + .setFromExecutable(*itor) + .setWaitForCompletion(ProcessRunner::TriggerRefresh) + .run(); +} + +void MainWindow::activateSelectedProfile() +{ + m_OrganizerCore.setCurrentProfile(ui->profileBox->currentText()); + + m_SavesTab->refreshSaveList(); + m_OrganizerCore.refresh(); + ui->modList->updateModCount(); + ui->espList->updatePluginCount(); + ui->statusBar->updateNormalMessage(m_OrganizerCore); +} + +void MainWindow::on_profileBox_currentIndexChanged(int index) +{ + if (!ui->profileBox->isEnabled()) { + return; + } + + int const previousIndex = m_OldProfileIndex; + m_OldProfileIndex = index; + + // select has changed, save stuff + if ((previousIndex != -1) && (m_OrganizerCore.currentProfile() != nullptr) && + m_OrganizerCore.currentProfile()->exists()) { + m_OrganizerCore.saveCurrentLists(); + } + + // Avoid doing any refresh if currentProfile is already set but previous + // index was -1 as it means that this is happening during initialization so + // everything has already been set. + if (previousIndex == -1 && m_OrganizerCore.currentProfile() != nullptr && + m_OrganizerCore.currentProfile()->exists() && + ui->profileBox->currentText() == m_OrganizerCore.currentProfile()->name()) { + return; + } + + // ensure the new index is valid + if (index < 0 || index >= ui->profileBox->count()) { + log::debug("invalid profile index, using last profile"); + ui->profileBox->setCurrentIndex(ui->profileBox->count() - 1); + } + + // handle item + if (ui->profileBox->currentIndex() == 0) { + // remember the profile name that was selected before, previousIndex can't + // be used again because adding/deleting profiles will change the order + // in the list + const QString previousName = ui->profileBox->itemText(previousIndex); + + // show the dialog + ProfilesDialog dlg(previousName, m_OrganizerCore, this); + dlg.exec(); + + // check if the user clicked 'select' to select another profile + std::optional newSelection = dlg.selectedProfile(); + + // refresh the profile box; this loops until there is at least one profile + // available, which shouldn't really happen because the dialog won't allow + // it + // + // the `false` to refreshProfiles() is so it doesn't try to select the + // profile in the list because 1) it's done just below, and 2) it might be + // wrong profile if there's something in newSelection + while (!refreshProfiles(false)) { + ProfilesDialog dlg(previousName, m_OrganizerCore, this); + dlg.exec(); + newSelection = dlg.selectedProfile(); + } + + // note that setCurrentText() is recursive, it will re-execute this function + if (newSelection) { + ui->profileBox->setCurrentText(*newSelection); + } else { + ui->profileBox->setCurrentText(previousName); + } + + // nothing else to do because setCurrentText() is recursive and will + // have re-executed on_profileBox_currentIndexChanged() again, doing all + // the stuff below for the new selection + return; + } + + activateSelectedProfile(); + + auto saveGames = m_OrganizerCore.gameFeatures().gameFeature(); + if (saveGames != nullptr) { + if (saveGames->prepareProfile(m_OrganizerCore.currentProfile().get())) { + m_SavesTab->refreshSaveList(); + } + } + + auto invalidation = m_OrganizerCore.gameFeatures().gameFeature(); + if (invalidation != nullptr) { + if (invalidation->prepareProfile(m_OrganizerCore.currentProfile().get())) { + QTimer::singleShot(5, [this] { + m_OrganizerCore.refresh(); + }); + } + } +} + +bool MainWindow::refreshProfiles(bool selectProfile, QString newProfile) +{ + QComboBox* profileBox = findChild("profileBox"); + + QString const currentProfileName = profileBox->currentText(); + + profileBox->blockSignals(true); + profileBox->clear(); + profileBox->addItem(QObject::tr("")); + + QDir profilesDir(Settings::instance().paths().profiles()); + profilesDir.setFilter(QDir::AllDirs | QDir::NoDotAndDotDot); + + QDirIterator profileIter(profilesDir); + + while (profileIter.hasNext()) { + profileIter.next(); + try { + profileBox->addItem(profileIter.fileName()); + } catch (const std::runtime_error& error) { + reportError(QObject::tr("failed to parse profile %1: %2") + .arg(profileIter.fileName()) + .arg(error.what())); + } + } + + // now select one of the profiles, preferably the one that was selected before + profileBox->blockSignals(false); + + if (selectProfile) { + if (profileBox->count() > 1) { + if (newProfile.isEmpty()) { + profileBox->setCurrentText(currentProfileName); + } else { + profileBox->setCurrentText(newProfile); + } + if (profileBox->currentIndex() == 0) { + profileBox->setCurrentIndex(1); + } + } + } + return profileBox->count() > 1; +} + +void MainWindow::refreshExecutablesList() +{ + QAbstractItemModel* model = ui->executablesListBox->model(); + + auto add = [&](const QString& title, const QFileInfo& binary) { + QIcon icon; + if (!binary.fileName().isEmpty()) { + icon = iconForExecutable(binary.filePath()); + } + + ui->executablesListBox->addItem(icon, title); + + const auto i = ui->executablesListBox->count() - 1; + + model->setData(model->index(i, 0), + QSize(0, ui->executablesListBox->iconSize().height() + 4), + Qt::SizeHintRole); + }; + + ui->executablesListBox->setEnabled(false); + ui->executablesListBox->clear(); + + add(tr(""), {}); + + auto shouldSkipInLaunchDropdown = [](const Executable& exe) { + const QString title = exe.title().trimmed(); + const QString lower = title.toLower(); + return lower == QStringLiteral("proton") || lower == QStringLiteral("prefix") || + lower.startsWith(QStringLiteral("proton ")) || + lower.startsWith(QStringLiteral("prefix ")); + }; + + for (const auto& exe : *m_OrganizerCore.executablesList()) { + if (exe.hide()) { + continue; + } + + if (shouldSkipInLaunchDropdown(exe)) { + log::debug("Skipping internal executable entry '{}' from launch dropdown", + exe.title()); + continue; + } + + add(exe.title(), exe.binaryInfo()); + } + + if (ui->executablesListBox->count() == 1) { + // all executables are hidden, add an empty one to at least be able to + // switch to edit + add(tr("(no executables)"), QFileInfo(":badfile")); + } + + ui->executablesListBox->setCurrentIndex(1); + ui->executablesListBox->setEnabled(true); +} + +static bool BySortValue(const std::pair& LHS, + const std::pair& RHS) +{ + return LHS.first < RHS.first; +} + +template +static QStringList toStringList(InputIterator current, InputIterator end) +{ + QStringList result; + for (; current != end; ++current) { + result.append(*current); + } + return result; +} + +void MainWindow::updateBSAList(const QStringList& defaultArchives, + const QStringList& activeArchives) +{ + m_DefaultArchives = defaultArchives; + ui->bsaList->clear(); + ui->bsaList->header()->setSectionResizeMode(QHeaderView::ResizeToContents); + std::vector> items; + + auto invalidation = m_OrganizerCore.gameFeatures().gameFeature(); + std::vector const files = m_OrganizerCore.directoryStructure()->getFiles(); + + QStringList const plugins = + m_OrganizerCore.findFiles("", [](const QString& fileName) -> bool { + return fileName.endsWith(".esp", Qt::CaseInsensitive) || + fileName.endsWith(".esm", Qt::CaseInsensitive) || + fileName.endsWith(".esl", Qt::CaseInsensitive); + }); + + QList> pluginNamePairs; + pluginNamePairs.reserve(plugins.size()); + for (const QString& pluginName : plugins) { + QFileInfo const pluginInfo(pluginName); + pluginNamePairs.append( + std::make_pair(pluginInfo.completeBaseName(), pluginInfo.fileName())); + } + + auto hasAssociatedPlugin = [&](const QString& bsaName) -> bool { + for (const auto& [completeBaseName, fileName] : pluginNamePairs) { + if (bsaName.startsWith(completeBaseName, Qt::CaseInsensitive) && + (m_OrganizerCore.pluginList()->state(fileName) == + IPluginList::STATE_ACTIVE)) { + return true; + } + } + return false; + }; + + for (const FileEntryPtr& current : files) { + QFileInfo const fileInfo(ToQString(current->getName())); + + if (fileInfo.suffix().toLower() == "bsa" || fileInfo.suffix().toLower() == "ba2") { + int index = activeArchives.indexOf(fileInfo.fileName()); + if (index == -1) { + index = 0xFFFF; + } else { + index += 2; + } + + if ((invalidation != nullptr) && + invalidation->isInvalidationBSA(fileInfo.fileName())) { + index = 1; + } + + int const originId = current->getOrigin(); + FilesOrigin const& origin = + m_OrganizerCore.directoryStructure()->getOriginByID(originId); + + QTreeWidgetItem* newItem = new QTreeWidgetItem( + QStringList() << fileInfo.fileName() << ToQString(origin.getName())); + newItem->setData(0, Qt::UserRole, index); + newItem->setData(1, Qt::UserRole, originId); + newItem->setFlags(newItem->flags() & + ~(Qt::ItemIsDropEnabled | Qt::ItemIsUserCheckable)); + newItem->setCheckState(0, (index != -1) ? Qt::Checked : Qt::Unchecked); + newItem->setData(0, Qt::UserRole, false); + if (m_OrganizerCore.settings().game().forceEnableCoreFiles() && + defaultArchives.contains(fileInfo.fileName())) { + newItem->setCheckState(0, Qt::Checked); + newItem->setDisabled(true); + newItem->setData(0, Qt::UserRole, true); + } else if (fileInfo.fileName().compare("update.bsa", Qt::CaseInsensitive) == 0) { + newItem->setCheckState(0, Qt::Checked); + newItem->setDisabled(true); + } else if (hasAssociatedPlugin(fileInfo.fileName())) { + newItem->setCheckState(0, Qt::Checked); + newItem->setDisabled(true); + } else { + newItem->setCheckState(0, Qt::Unchecked); + newItem->setDisabled(true); + } + if (index < 0) + index = 0; + + UINT32 const sortValue = ((origin.getPriority() & 0xFFFF) << 16) | (index & 0xFFFF); + items.emplace_back(sortValue, newItem); + } + } + std::sort(items.begin(), items.end(), BySortValue); + + for (auto iter = items.begin(); iter != items.end(); ++iter) { + int const originID = iter->second->data(1, Qt::UserRole).toInt(); + + const FilesOrigin& origin = + m_OrganizerCore.directoryStructure()->getOriginByID(originID); + + QString modName; + const unsigned int modIndex = ModInfo::getIndex(ToQString(origin.getName())); + + if (modIndex == UINT_MAX) { + modName = UnmanagedModName(); + } else { + ModInfo::Ptr const modInfo = ModInfo::getByIndex(modIndex); + modName = modInfo->name(); + } + + QList const items = + ui->bsaList->findItems(modName, Qt::MatchFixedString); + QTreeWidgetItem* subItem = nullptr; + if (!items.empty()) { + subItem = items.at(0); + } else { + subItem = new QTreeWidgetItem(QStringList(modName)); + subItem->setFlags(subItem->flags() & ~Qt::ItemIsDragEnabled); + ui->bsaList->addTopLevelItem(subItem); + } + subItem->addChild(iter->second); + subItem->setExpanded(true); + } + checkBSAList(); +} + +void MainWindow::checkBSAList() +{ + auto archives = m_OrganizerCore.gameFeatures().gameFeature(); + + if (archives != nullptr) { + ui->bsaList->blockSignals(true); + ON_BLOCK_EXIT([&]() { + ui->bsaList->blockSignals(false); + }); + + QStringList const defaultArchives = + archives->archives(m_OrganizerCore.currentProfile().get()); + + bool warning = false; + + for (int i = 0; i < ui->bsaList->topLevelItemCount(); ++i) { + bool modWarning = false; + QTreeWidgetItem* tlItem = ui->bsaList->topLevelItem(i); + for (int j = 0; j < tlItem->childCount(); ++j) { + QTreeWidgetItem* item = tlItem->child(j); + QString const filename = item->text(0); + item->setIcon(0, QIcon()); + item->setToolTip(0, QString()); + + if (item->checkState(0) == Qt::Unchecked) { + if (defaultArchives.contains(filename)) { + item->setIcon(0, QIcon(":/MO/gui/warning")); + item->setToolTip( + 0, tr("This bsa is enabled in the ini file so it may be required!")); + modWarning = true; + } + } + } + if (modWarning) { + ui->bsaList->expandItem(ui->bsaList->topLevelItem(i)); + warning = true; + } + } + if (warning) { + ui->tabWidget->setTabIcon(1, QIcon(":/MO/gui/warning")); + } else { + ui->tabWidget->setTabIcon(1, QIcon()); + } + } +} + +void MainWindow::saveModMetas() +{ + if (m_MetaSave.isFinished()) { + m_MetaSave = QtConcurrent::run([this]() { + for (unsigned int i = 0; i < ModInfo::getNumMods(); ++i) { + ModInfo::Ptr const modInfo = ModInfo::getByIndex(i); + modInfo->saveMeta(); + } + }); + } +} + +void MainWindow::fixCategories() +{ + for (unsigned int i = 0; i < ModInfo::getNumMods(); ++i) { + ModInfo::Ptr const modInfo = ModInfo::getByIndex(i); + std::set const categories = modInfo->getCategories(); + for (std::set::iterator iter = categories.begin(); iter != categories.end(); + ++iter) { + if (!m_CategoryFactory.categoryExists(*iter)) { + modInfo->setCategory(*iter, false); + } + } + } +} + +void MainWindow::setupNetworkProxy(bool activate) +{ + QNetworkProxyFactory::setUseSystemConfiguration(activate); +} + +void MainWindow::activateProxy(bool activate) +{ + QProgressDialog busyDialog(tr("Activating Network Proxy"), QString(), 0, 0, + parentWidget()); + busyDialog.setWindowFlags(busyDialog.windowFlags() & + ~Qt::WindowContextHelpButtonHint); + busyDialog.setWindowModality(Qt::WindowModal); + busyDialog.show(); + + QFutureWatcher futureWatcher; + QEventLoop loop; + connect(&futureWatcher, &QFutureWatcher::finished, &loop, &QEventLoop::quit, + Qt::QueuedConnection); + + futureWatcher.setFuture(QtConcurrent::run(MainWindow::setupNetworkProxy, activate)); + + // wait for setupNetworkProxy while keeping ui responsive + loop.exec(); + + busyDialog.hide(); +} + +void MainWindow::readSettings() +{ + const auto& s = m_OrganizerCore.settings(); + + if (!s.geometry().restoreGeometry(this)) { + resize(1300, 800); + } + + s.geometry().restoreState(this); + s.geometry().restoreDocks(this); + s.geometry().restoreToolbars(this); + s.geometry().restoreState(ui->splitter); + s.geometry().restoreState(ui->categoriesSplitter); + s.geometry().restoreVisibility(ui->menuBar); + s.geometry().restoreVisibility(ui->statusBar); + + FilterWidget::setOptions(s.interface().filterOptions()); + + { + // special case in case someone puts 0 in the INI + auto v = s.widgets().index(ui->executablesListBox); + if (!v || v == 0) { + v = 1; + } + + ui->executablesListBox->setCurrentIndex(*v); + } + + s.widgets().restoreIndex(ui->tabWidget); + + ui->modList->restoreState(s); + + { + s.geometry().restoreVisibility(ui->categoriesGroup, false); + const auto v = ui->categoriesGroup->isVisible(); + setCategoryListVisible(v); + ui->displayCategoriesBtn->setChecked(v); + } + + if (s.network().useProxy()) { + activateProxy(true); + } +} + +void MainWindow::processUpdates() +{ + auto& settings = m_OrganizerCore.settings(); + const auto earliest = QVersionNumber::fromString("2.1.2").normalized(); + + const auto lastVersion = settings.version().value_or(earliest); + const auto currentVersion = + QVersionNumber::fromString(m_OrganizerCore.getVersion().string()).normalized(); + + m_LastVersion = lastVersion; + + settings.processUpdates(currentVersion, lastVersion); + + if (!settings.firstStart()) { + if (lastVersion < QVersionNumber(2, 1, 6)) { + ui->modList->header()->setSectionHidden(ModList::COL_NOTES, true); + } + + if (lastVersion < QVersionNumber(2, 2, 1)) { + // hide new columns by default + for (int i = DownloadList::COL_MODNAME; i < DownloadList::COL_COUNT; ++i) { + ui->downloadView->header()->hideSection(i); + } + } + + if (lastVersion < QVersionNumber(2, 3)) { + for (int i = 1; i < ui->dataTree->header()->count(); ++i) + ui->dataTree->setColumnWidth(i, 150); + } + } + + if (currentVersion < lastVersion) { + // Fluorine Manager versions its own 0.x.y releases separately from the + // embedded MO2 engine. Instances created under upstream MO2 (or an + // earlier Fluorine build that echoed the engine version) will have + // a stored 2.x.y — the "2 → 0" drop isn't a downgrade, it's a schema + // change. Detect that and suppress the warning. + const bool schemaTransition = + lastVersion.majorVersion() >= 2 && currentVersion.majorVersion() == 0; + if (!schemaTransition) { + const auto text = + tr("Notice: Your current Fluorine Manager version (%1) is lower than " + "the previously used one (%2). The GUI may not downgrade " + "gracefully, so you may experience oddities. However, there " + "should be no serious issues.") + .arg(currentVersion.toString()) + .arg(lastVersion.toString()); + + log::warn("{}", text); + } else { + log::debug( + "skipping downgrade warning: {} -> {} is MO2->Fluorine schema change", + lastVersion.toString(), currentVersion.toString()); + } + } +} + +void MainWindow::storeSettings() +{ + auto& s = m_OrganizerCore.settings(); + + s.geometry().saveState(this); + s.geometry().saveGeometry(this); + s.geometry().saveDocks(this); + + s.geometry().saveVisibility(ui->menuBar); + s.geometry().saveVisibility(ui->statusBar); + s.geometry().saveToolbars(this); + s.geometry().saveState(ui->splitter); + s.geometry().saveState(ui->categoriesSplitter); + s.geometry().saveMainWindowMonitor(this); + s.geometry().saveVisibility(ui->categoriesGroup); + + s.geometry().saveState(ui->espList->header()); + s.geometry().saveState(ui->downloadView->header()); + s.geometry().saveState(ui->savegameList->header()); + + s.widgets().saveIndex(ui->executablesListBox); + s.widgets().saveIndex(ui->tabWidget); + + m_DataTab->saveState(s); + ui->modList->saveState(s); + + s.interface().setFilterOptions(FilterWidget::options()); +} + +QMainWindow* MainWindow::mainWindow() +{ + return this; +} + +void MainWindow::on_tabWidget_currentChanged(int index) +{ + QWidget* currentWidget = ui->tabWidget->widget(index); + if (currentWidget == ui->espTab) { + m_OrganizerCore.refreshESPList(); + ui->espList->activated(); + } else if (currentWidget == ui->bsaTab) { + m_OrganizerCore.refreshBSAList(); + } else if (currentWidget == ui->dataTab) { + m_DataTab->activated(); + } else if (currentWidget == ui->savesTab) { + m_SavesTab->refreshSaveList(); + } +} + +void MainWindow::on_startButton_clicked() +{ + const Executable* selectedExecutable = getSelectedExecutable(); + if (!selectedExecutable) { + return; + } + + ui->startButton->setEnabled(false); + Guard const g([&] { + ui->startButton->setEnabled(true); + }); + + // Pre-check: if this executable uses Proton and the current instance has SLR + // enabled, download SLR before launching if it isn't installed yet. + if (selectedExecutable->useProton()) { + const auto* s = Settings::maybeInstance(); + bool useSLR = true; + if (s) { + QSettings const instanceIni(s->filename(), QSettings::IniFormat); + useSLR = instanceIni.value("fluorine/use_slr", true).toBool(); + } + if (useSLR && !isSlrInstalled()) { + auto* progress = new QProgressDialog( + tr("Downloading Steam Linux Runtime (~200 MB)...\n" + "This is required to launch games. Check the MO2 log for details."), + tr("Cancel"), 0, 0, this); + progress->setWindowTitle(tr("Steam Linux Runtime")); + progress->setWindowModality(Qt::WindowModal); + progress->setAttribute(Qt::WA_ShowWithoutActivating); + progress->setMinimumDuration(0); + + int cancelFlag = 0; + connect(progress, &QProgressDialog::canceled, this, [&cancelFlag] { + cancelFlag = 1; + }); + + // Run download synchronously using an event loop so the dialog stays responsive. + QFutureWatcher watcher; + QEventLoop loop; + connect(&watcher, &QFutureWatcher::finished, &loop, &QEventLoop::quit); + watcher.setFuture(QtConcurrent::run([&cancelFlag]() -> QString { + return downloadSlr(nullptr, nullptr, &cancelFlag); + })); + progress->show(); + loop.exec(); + progress->close(); + progress->deleteLater(); + + const QString err = watcher.result(); + if (cancelFlag) { + return; // user cancelled, don't launch + } + if (!err.isEmpty()) { + log::error("[SLR] Download failed: {}", err); + QMessageBox::warning(this, tr("Steam Linux Runtime"), + tr("Steam Linux Runtime download failed:\n%1\n\n" + "You can disable SLR in the Instance Manager and try again.").arg(err)); + return; + } + } + } + + if (selectedExecutable->minimizeToSystemTray()) { + m_SystemTrayManager->minimizeToSystemTray(); + } + + m_OrganizerCore.processRunner() + .setFromExecutable(*selectedExecutable) + .setWaitForCompletion(ProcessRunner::TriggerRefresh) + .run(); +} + +bool MainWindow::modifyExecutablesDialog(int selection) +{ + bool result = false; + + try { + EditExecutablesDialog dialog(m_OrganizerCore, selection, this); + + result = (dialog.exec() == QDialog::Accepted); + + refreshExecutablesList(); + updatePinnedExecutables(); + } catch (const std::exception& e) { + reportError(e.what()); + } + + return result; +} + +void MainWindow::on_executablesListBox_currentIndexChanged(int index) +{ + if (!ui->executablesListBox->isEnabled()) { + return; + } + + const int previousIndex = (m_OldExecutableIndex > 0 ? m_OldExecutableIndex : 1); + + m_OldExecutableIndex = index; + + if (index == 0) { + modifyExecutablesDialog(previousIndex - 1); + const auto newCount = ui->executablesListBox->count(); + + if (previousIndex >= 0 && previousIndex < newCount) { + ui->executablesListBox->setCurrentIndex(previousIndex); + } else { + ui->executablesListBox->setCurrentIndex(newCount - 1); + } + } +} + +void MainWindow::helpTriggered() +{ + QWhatsThis::enterWhatsThisMode(); +} + +void MainWindow::wikiTriggered() +{ + shell::Open(QUrl("https://modorganizer2.github.io/")); +} + +void MainWindow::gameSupportTriggered() +{ + shell::Open(QUrl(m_OrganizerCore.managedGame()->getSupportURL())); +} + +void MainWindow::discordTriggered() +{ + shell::Open(QUrl("https://discord.gg/ewUVAqyrQX")); +} + +void MainWindow::issueTriggered() +{ + shell::Open(QUrl("https://github.com/Modorganizer2/modorganizer/issues")); +} + +void MainWindow::tutorialTriggered() +{ + QAction* tutorialAction = qobject_cast(sender()); + if (tutorialAction != nullptr) { + TutorialManager::instance().activateTutorial("MainWindow", + tutorialAction->data().toString()); + } +} + +void MainWindow::on_actionInstallMod_triggered() +{ + ui->modList->actions().installMod(); +} + +void MainWindow::on_action_Refresh_triggered() +{ + refreshProfile_activated(); +} + +void MainWindow::on_actionAdd_Profile_triggered() +{ + for (;;) { + ProfilesDialog profilesDialog(m_OrganizerCore.currentProfile()->name(), + m_OrganizerCore, this); + + // workaround: need to disable monitoring of the saves directory, otherwise the + // active profile directory is locked + m_SavesTab->stopMonitorSaves(); + profilesDialog.exec(); + m_SavesTab->refreshSaveList(); // since the save list may now be outdated we have + // to refresh it completely + + if (refreshProfiles(true, profilesDialog.selectedProfile().value_or("")) && + !profilesDialog.failed()) { + break; + } + } + + auto saveGames = m_OrganizerCore.gameFeatures().gameFeature(); + if (saveGames != nullptr) { + if (saveGames->prepareProfile(m_OrganizerCore.currentProfile().get())) { + m_SavesTab->refreshSaveList(); + } + } + + auto invalidation = m_OrganizerCore.gameFeatures().gameFeature(); + if (invalidation != nullptr) { + if (invalidation->prepareProfile(m_OrganizerCore.currentProfile().get())) { + QTimer::singleShot(5, [this] { + m_OrganizerCore.refresh(); + }); + } + } +} + +void MainWindow::on_actionModify_Executables_triggered() +{ + const auto sel = (m_OldExecutableIndex > 0 ? m_OldExecutableIndex - 1 : 0); + + if (modifyExecutablesDialog(sel)) { + const auto newCount = ui->executablesListBox->count(); + if (m_OldExecutableIndex >= 0 && m_OldExecutableIndex < newCount) { + ui->executablesListBox->setCurrentIndex(m_OldExecutableIndex); + } else { + ui->executablesListBox->setCurrentIndex(newCount - 1); + } + } +} + +void MainWindow::refresherProgress(const DirectoryRefreshProgress* p) +{ + if (p->finished()) { + setEnabled(true); + ui->statusBar->setProgress(100); + } else { + setEnabled(false); + ui->statusBar->setProgress(p->percentDone()); + } +} + +void MainWindow::onDirectoryStructureChanged() +{ + // some problem-reports may rely on the virtual directory tree so they need to be + // updated now + scheduleCheckForProblems(); + m_DataTab->updateTree(); +} + +void MainWindow::modInstalled(const QString& modName) +{ + if (!m_OrganizerCore.settings().interface().checkUpdateAfterInstallation()) { + return; + } + + unsigned int const index = ModInfo::getIndex(modName); + + if (index == UINT_MAX) { + return; + } + + // force an update to happen + ui->modList->actions().checkModsForUpdates( + {m_OrganizerCore.modList()->index(index, 0)}); +} + +void MainWindow::importCategories(bool) +{ + NexusInterface& nexus = NexusInterface::instance(); + nexus.setPluginContainer(&m_OrganizerCore.pluginContainer()); + nexus.requestGameInfo(Settings::instance().game().plugin()->gameShortName(), this, + QVariant(), QString()); +} + +void MainWindow::showMessage(const QString& message) +{ + MessageDialog::showMessage(message, this); +} + +void MainWindow::showError(const QString& message) +{ + reportError(message); +} + +void MainWindow::modRenamed(const QString& oldName, const QString& newName) +{ + Profile::renameModInAllProfiles(oldName, newName); + + // immediately refresh the active profile because the data in memory is invalid + m_OrganizerCore.currentProfile()->refreshModStatus(); + + // also fix the directory structure + try { + if (m_OrganizerCore.directoryStructure()->originExists(ToWString(oldName))) { + FilesOrigin& origin = + m_OrganizerCore.directoryStructure()->getOriginByName(ToWString(oldName)); + origin.setName(ToWString(newName)); + } else { + } + } catch (const std::exception& e) { + reportError(tr("failed to change origin name: %1").arg(e.what())); + } +} + +void MainWindow::fileMoved(const QString& filePath, const QString& oldOriginName, + const QString& newOriginName) +{ + const FileEntryPtr filePtr = + m_OrganizerCore.directoryStructure()->findFile(ToWString(filePath)); + if (filePtr.get() != nullptr) { + try { + if (m_OrganizerCore.directoryStructure()->originExists( + ToWString(newOriginName))) { + FilesOrigin const& newOrigin = m_OrganizerCore.directoryStructure()->getOriginByName( + ToWString(newOriginName)); + + QString const fullNewPath = ToQString(newOrigin.getPath()) + "/" + filePath; + FILETIME ft = {}; + QFileInfo const fi(fullNewPath); + if (fi.exists()) { + // Win32 FILETIME = 100-ns intervals since 1601-01-01. + qint64 const msecs = fi.birthTime().toMSecsSinceEpoch(); + uint64_t const ticks = (static_cast(msecs) * 10000ULL) + + 116444736000000000ULL; + ft.dwLowDateTime = static_cast(ticks & 0xFFFFFFFF); + ft.dwHighDateTime = static_cast(ticks >> 32); + } + filePtr->addOrigin(newOrigin.getID(), ft, L"", -1); + } + if (m_OrganizerCore.directoryStructure()->originExists( + ToWString(oldOriginName))) { + FilesOrigin const& oldOrigin = m_OrganizerCore.directoryStructure()->getOriginByName( + ToWString(oldOriginName)); + filePtr->removeOrigin(oldOrigin.getID()); + } + } catch (const std::exception& e) { + reportError(tr(R"(failed to move "%1" from mod "%2" to "%3": %4)") + .arg(filePath) + .arg(oldOriginName) + .arg(newOriginName) + .arg(e.what())); + } + + // Track files moved from Overwrite to a mod for in-place write-back. + ModInfo::Ptr const owInfo = ModInfo::getOverwrite(); + if (owInfo && oldOriginName == owInfo->name()) { + if (m_OrganizerCore.directoryStructure()->originExists( + ToWString(newOriginName))) { + FilesOrigin const& newOrigin = + m_OrganizerCore.directoryStructure()->getOriginByName( + ToWString(newOriginName)); + m_OrganizerCore.trackOverwriteMove(filePath, ToQString(newOrigin.getPath())); + } + } + } else { + // this is probably not an error, the specified path is likely a directory + } +} + +void MainWindow::modRemoved(const QString& fileName) +{ + if (!fileName.isEmpty() && !QFileInfo(fileName).isAbsolute()) { + m_OrganizerCore.downloadManager()->markUninstalled(fileName); + } +} + +void MainWindow::windowTutorialFinished(const QString& windowName) +{ + m_OrganizerCore.settings().interface().setTutorialCompleted(windowName); +} + +void MainWindow::displayModInformation(ModInfo::Ptr modInfo, unsigned int modIndex, + ModInfoTabIDs tabID) +{ + ui->modList->actions().displayModInformation(modInfo, modIndex, tabID); +} + +bool MainWindow::closeWindow() +{ + return close(); +} + +void MainWindow::setWindowEnabled(bool enabled) +{ + setEnabled(enabled); +} + +void MainWindow::refreshProfile_activated() +{ + m_OrganizerCore.refresh(); +} + +void MainWindow::saveArchiveList() +{ + if (m_OrganizerCore.isArchivesInit()) { + SafeWriteFile archiveFile(m_OrganizerCore.currentProfile()->getArchivesFileName()); + for (int i = 0; i < ui->bsaList->topLevelItemCount(); ++i) { + QTreeWidgetItem* tlItem = ui->bsaList->topLevelItem(i); + for (int j = 0; j < tlItem->childCount(); ++j) { + QTreeWidgetItem* item = tlItem->child(j); + if (item->checkState(0) == Qt::Checked) { + archiveFile->write(item->text(0).toUtf8().append("\r\n")); + } + } + } + archiveFile->commit(); + } else { + log::debug("archive list not initialised"); + } +} + +void MainWindow::openInstanceFolder() +{ + QString const dataPath = qApp->property("dataPath").toString(); + shell::Explore(dataPath); +} + +void MainWindow::openInstallFolder() +{ + shell::Explore(AppConfig::basePath()); +} + +void MainWindow::openPluginsFolder() +{ + shell::Explore(AppConfig::pluginsPath()); +} + +void MainWindow::openStylesheetsFolder() +{ + // Open the instance's stylesheets directory (where custom themes from + // modlists live), or the user data dir as fallback. + QString ssPath; + if (auto ci = InstanceManager::singleton().currentInstance()) { + ssPath = + ci->directory() + "/" + QString::fromStdWString(AppConfig::stylesheetsPath()); + } else { + ssPath = fluorineDataDir() + "/stylesheets"; + } + QDir().mkpath(ssPath); + shell::Explore(ssPath); +} + +void MainWindow::openProfileFolder() +{ + shell::Explore(m_OrganizerCore.currentProfile()->absolutePath()); +} + +void MainWindow::openIniFolder() +{ + if (m_OrganizerCore.currentProfile()->localSettingsEnabled()) { + shell::Explore(m_OrganizerCore.currentProfile()->absolutePath()); + } else { + shell::Explore(m_OrganizerCore.managedGame()->documentsDirectory()); + } +} + +void MainWindow::openDownloadsFolder() +{ + shell::Explore(m_OrganizerCore.settings().paths().downloads()); +} + +void MainWindow::openModsFolder() +{ + shell::Explore(m_OrganizerCore.settings().paths().mods()); +} + +void MainWindow::openGameFolder() +{ + shell::Explore(m_OrganizerCore.managedGame()->gameDirectory()); +} + +void MainWindow::openMyGamesFolder() +{ + shell::Explore(m_OrganizerCore.managedGame()->documentsDirectory()); +} + +QMenu* MainWindow::openFolderMenu() +{ + QMenu* FolderMenu = new QMenu(this); + + // game folders that are not necessarily MO-specific + FolderMenu->addAction(tr("Open Game folder"), this, SLOT(openGameFolder())); + FolderMenu->addAction(tr("Open MyGames folder"), this, SLOT(openMyGamesFolder())); + FolderMenu->addAction(tr("Open INIs folder"), this, SLOT(openIniFolder())); + + FolderMenu->addSeparator(); + + // MO-specific folders that are related to modding the game + FolderMenu->addAction(tr("Open Instance folder"), this, SLOT(openInstanceFolder())); + FolderMenu->addAction(tr("Open Mods folder"), this, SLOT(openModsFolder())); + FolderMenu->addAction(tr("Open Profile folder"), this, SLOT(openProfileFolder())); + FolderMenu->addAction(tr("Open Downloads folder"), this, SLOT(openDownloadsFolder())); + + FolderMenu->addSeparator(); + + // MO-specific folders that are not directly related to modding and are either + // in the installation folder or the instance + FolderMenu->addAction(tr("Open MO2 Install folder"), this, SLOT(openInstallFolder())); + FolderMenu->addAction(tr("Open MO2 Plugins folder"), this, SLOT(openPluginsFolder())); + FolderMenu->addAction(tr("Open MO2 Stylesheets folder"), this, + SLOT(openStylesheetsFolder())); + FolderMenu->addAction(tr("Open MO2 Logs folder"), [=, this] { + LogList::openLogsFolder(); + }); + + return FolderMenu; +} + +void MainWindow::linkToolbar() +{ + Executable* exe = getSelectedExecutable(); + if (!exe) { + return; + } + + exe->setShownOnToolbar(!exe->isShownOnToolbar()); + updatePinnedExecutables(); +} + +void MainWindow::linkDesktop() +{ + if (auto* exe = getSelectedExecutable()) { + env::Shortcut(*exe).toggle(env::Shortcut::Desktop); + } +} + +void MainWindow::linkMenu() +{ + if (auto* exe = getSelectedExecutable()) { + env::Shortcut(*exe).toggle(env::Shortcut::ApplicationMenu); + } +} + +void MainWindow::on_linkButton_pressed() +{ + const Executable* exe = getSelectedExecutable(); + if (!exe) { + return; + } + + const QIcon addIcon(":/MO/gui/link"); + const QIcon removeIcon(":/MO/gui/remove"); + + env::Shortcut const shortcut(*exe); + + m_LinkToolbar->setIcon(exe->isShownOnToolbar() ? removeIcon : addIcon); + + m_LinkDesktop->setIcon(shortcut.exists(env::Shortcut::Desktop) ? removeIcon + : addIcon); + + if (m_LinkStartMenu) { + m_LinkStartMenu->setIcon( + shortcut.exists(env::Shortcut::ApplicationMenu) ? removeIcon : addIcon); + } +} + +void MainWindow::on_actionSettings_triggered() +{ + Settings& settings = m_OrganizerCore.settings(); + + QString const oldModDirectory(settings.paths().mods()); + QString const oldCacheDirectory(settings.paths().cache()); + QString const oldProfilesDirectory(settings.paths().profiles()); + QString const oldManagedGameDirectory(settings.game().directory().value_or("")); + bool const oldDisplayForeign(settings.interface().displayForeign()); + bool const oldArchiveParsing(settings.archiveParsing()); + bool const proxy = settings.network().useProxy(); + DownloadManager* dlManager = m_OrganizerCore.downloadManager(); + const bool oldCheckForUpdates = settings.checkForUpdates(); + const int oldMaxDumps = settings.diagnostics().maxCoreDumps(); + + SettingsDialog dialog(&m_PluginContainer, settings, this); + dialog.exec(); + + auto e = dialog.exitNeeded(); + + if (oldManagedGameDirectory != settings.game().directory()) { + e |= Exit::Restart; + } + + if (e.testFlag(Exit::Restart)) { + const auto r = + MOBase::TaskDialog(this) + .title(tr("Restart Mod Organizer")) + .main("Restart Mod Organizer") + .content(tr("Mod Organizer must restart to finish configuration changes")) + .icon(QMessageBox::Question) + .button({tr("Restart"), QMessageBox::Yes}) + .button( + {tr("Continue"), tr("Some things might be weird."), QMessageBox::No}) + .exec(); + + if (r == QMessageBox::Yes) { + ExitModOrganizer(e); + } + } + + InstallationManager* instManager = m_OrganizerCore.installationManager(); + instManager->setModsDirectory(settings.paths().mods()); + instManager->setDownloadDirectory(settings.paths().downloads()); + + // Schedule a problem check since diagnose plugins may have been enabled / disabled. + scheduleCheckForProblems(); + + fixCategories(); + ui->modList->refreshFilters(); + ui->modList->refresh(); + + m_OrganizerCore.refreshLists(); + + if (settings.paths().profiles() != oldProfilesDirectory) { + refreshProfiles(); + } + + if (dlManager->getOutputDirectory() != settings.paths().downloads()) { + if (dlManager->downloadsInProgress()) { + MessageDialog::showMessage(tr("Can't change download directory while " + "downloads are in progress!"), + this); + } else { + dlManager->setOutputDirectory(settings.paths().downloads()); + } + } + + if ((settings.paths().mods() != oldModDirectory) || + (settings.interface().displayForeign() != oldDisplayForeign)) { + m_OrganizerCore.refresh(); + } + + const auto state = settings.archiveParsing(); + if (state != oldArchiveParsing) { + if (!state) { + ui->dataTabShowFromArchives->setCheckState(Qt::Unchecked); + ui->dataTabShowFromArchives->setEnabled(false); + } else { + ui->dataTabShowFromArchives->setCheckState(Qt::Checked); + ui->dataTabShowFromArchives->setEnabled(true); + } + m_OrganizerCore.refresh(); + } + + if (settings.paths().cache() != oldCacheDirectory) { + NexusInterface::instance().setCacheDirectory(settings.paths().cache()); + } + + if (proxy != settings.network().useProxy()) { + activateProxy(settings.network().useProxy()); + } + + ui->statusBar->checkSettings(m_OrganizerCore.settings()); + m_DownloadsTab->update(); + + m_OrganizerCore.setLogLevel(settings.diagnostics().logLevel()); + + if (settings.diagnostics().maxCoreDumps() != oldMaxDumps) { + m_OrganizerCore.cycleDiagnostics(); + } + + toggleMO2EndorseState(); + + if (oldCheckForUpdates != settings.checkForUpdates()) { + toggleUpdateAction(); + + if (settings.checkForUpdates()) { + m_OrganizerCore.checkForUpdates(); + } + } +} + +void MainWindow::onPluginRegistrationChanged() +{ + updateModPageMenu(); + scheduleCheckForProblems(); + m_DownloadsTab->update(); +} + +void MainWindow::refreshNexusCategories(CategoriesDialog* dialog) +{ + NexusInterface& nexus = NexusInterface::instance(); + nexus.setPluginContainer(&m_PluginContainer); + if (!Settings::instance().game().plugin()->primarySources().isEmpty()) { + nexus.requestGameInfo( + Settings::instance().game().plugin()->primarySources().first(), dialog, + QVariant(), QString()); + } else { + nexus.requestGameInfo(Settings::instance().game().plugin()->gameShortName(), dialog, + QVariant(), QString()); + } +} + +void MainWindow::categoriesSaved() +{ + for (const auto& modName : m_OrganizerCore.modList()->allMods()) { + auto mod = ModInfo::getByName(modName); + for (auto category : mod->getCategories()) { + if (!m_CategoryFactory.categoryExists(category)) + mod->setCategory(category, false); + } + } +} + +void MainWindow::on_actionNexus_triggered() +{ + const IPluginGame* game = m_OrganizerCore.managedGame(); + QString gameName = game->gameShortName(); + if (game->gameNexusName().isEmpty() && game->primarySources().count()) + gameName = game->primarySources()[0]; + shell::Open(QUrl(NexusInterface::instance().getGameURL(gameName))); +} + +void MainWindow::installTranslator(const QString& name) +{ + QTranslator* translator = new QTranslator(this); + QString fileName = name + "_" + m_CurrentLanguage; + if (!translator->load(fileName, qApp->applicationDirPath() + "/translations")) { + if (m_CurrentLanguage.contains(QRegularExpression("^.*_(EN|en)(-.*)?$"))) { + log::debug("localization file %s not found", fileName); + } // we don't actually expect localization files for English (en, en-us, en-uk, and + // any variation thereof) + } + + qApp->installTranslator(translator); + m_Translators.push_back(translator); +} + +void MainWindow::languageChange(const QString& newLanguage) +{ + for (QTranslator* trans : m_Translators) { + qApp->removeTranslator(trans); + } + m_Translators.clear(); + + m_CurrentLanguage = newLanguage; + + installTranslator("qt"); + installTranslator("qtbase"); + installTranslator(ToQString(AppConfig::translationPrefix())); + installTranslator("uibase"); + + // TODO: this will probably be changed once extension come out + installTranslator("game_gamebryo"); + installTranslator("game_creation"); + + for (const QString& fileName : m_PluginContainer.pluginFileNames()) { + installTranslator(QFileInfo(fileName).baseName()); + } + ui->retranslateUi(this); + log::debug("loaded language {}", newLanguage); + + ui->profileBox->setItemText(0, QObject::tr("")); + + createHelpMenu(); + + if (m_DownloadsTab) { + m_DownloadsTab->update(); + } + + ui->listOptionsBtn->setMenu( + new ModListGlobalContextMenu(m_OrganizerCore, ui->modList, this)); + ui->openFolderMenu->setMenu(openFolderMenu()); +} + +void MainWindow::originModified(int originID) +{ + FilesOrigin& origin = m_OrganizerCore.directoryStructure()->getOriginByID(originID); + origin.enable(false); + + DirectoryStats dummy; + QString path = QString::fromStdWString(origin.getPath()); + QString const modDataDir = m_OrganizerCore.managedGame()->modDataDirectory(); + path = modDataDir.isEmpty() ? path : path + "/" + modDataDir; + m_OrganizerCore.directoryStructure()->addFromOrigin( + origin.getName(), path.toStdWString(), origin.getPriority(), dummy); + + DirectoryRefresher::cleanStructure(m_OrganizerCore.directoryStructure()); +} + +void MainWindow::updateAvailable() +{ + ui->actionUpdate->setEnabled(true); + ui->actionUpdate->setToolTip(tr("Update available")); + ui->statusBar->setUpdateAvailable(true); +} + +void MainWindow::motdReceived(const QString& motd) +{ + // don't show motd after 5 seconds, may be annoying. Hopefully the user's + // internet connection is faster next time + if (m_StartTime.secsTo(QTime::currentTime()) < 5) { + uint const hash = qHash(motd); + if (hash != m_OrganizerCore.settings().motdHash()) { + MotDDialog dialog(motd); + dialog.exec(); + m_OrganizerCore.settings().setMotdHash(hash); + } + } +} + +void MainWindow::on_actionUpdate_triggered() +{ + m_OrganizerCore.startMOUpdate(); +} + +void MainWindow::on_actionExit_triggered() +{ + ExitModOrganizer(); +} + +void MainWindow::actionEndorseMO() +{ + // Normally this would be the managed game but MO2 is only uploaded to the Skyrim SE + // site right now + IPluginGame* game = m_OrganizerCore.getGame("skyrimse"); + if (!game) + return; + + if (QMessageBox::question( + this, tr("Endorse Mod Organizer"), + tr("Do you want to endorse Mod Organizer on %1 now?") + .arg(NexusInterface::instance().getGameURL(game->gameShortName())), + QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { + NexusInterface::instance().requestToggleEndorsement( + game->gameShortName(), game->nexusModOrganizerID(), + m_OrganizerCore.getVersion().string(), true, this, QVariant(), QString()); + } +} + +void MainWindow::actionWontEndorseMO() +{ + // Normally this would be the managed game but MO2 is only uploaded to the Skyrim SE + // site right now + IPluginGame* game = m_OrganizerCore.getGame("skyrimse"); + if (!game) + return; + + if (QMessageBox::question( + this, tr("Abstain from Endorsing Mod Organizer"), + tr("Are you sure you want to abstain from endorsing Mod Organizer 2?\n" + "You will have to visit the mod page on the %1 Nexus site to change your " + "mind.") + .arg(NexusInterface::instance().getGameURL(game->gameShortName())), + QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { + NexusInterface::instance().requestToggleEndorsement( + game->gameShortName(), game->nexusModOrganizerID(), + m_OrganizerCore.getVersion().string(), false, this, QVariant(), QString()); + } +} + +void MainWindow::toggleMO2EndorseState() +{ + const auto& s = m_OrganizerCore.settings(); + + if (!s.nexus().endorsementIntegration()) { + ui->actionEndorseMO->setVisible(false); + return; + } + + ui->actionEndorseMO->setVisible(true); + + bool enabled = false; + QString text; + + switch (s.nexus().endorsementState()) { + case EndorsementState::Accepted: { + text = tr("Thank you for endorsing MO2! :)"); + break; + } + + case EndorsementState::Refused: { + text = tr("Please reconsider endorsing MO2 on Nexus!"); + break; + } + + case EndorsementState::NoDecision: { + enabled = true; + break; + } + } + + ui->actionEndorseMO->menu()->setEnabled(enabled); + ui->actionEndorseMO->setToolTip(text); + ui->actionEndorseMO->setStatusTip(text); +} + +void MainWindow::toggleUpdateAction() +{ + const auto& s = m_OrganizerCore.settings(); + ui->actionUpdate->setVisible(s.checkForUpdates()); +} + +void MainWindow::nxmEndorsementsAvailable(QVariant userData, QVariant resultData, int) +{ + QVariantList const data = resultData.toList(); + std::multimap> sorted; + QStringList games = m_OrganizerCore.managedGame()->validShortNames(); + games += m_OrganizerCore.managedGame()->gameShortName(); + bool searchedMO2NexusGame = false; + for (const auto& endorsementData : data) { + QVariantMap endorsement = endorsementData.toMap(); + std::pair data = std::make_pair( + endorsement["mod_id"].toInt(), endorsement["status"].toString()); + sorted.insert(std::pair>( + endorsement["domain_name"].toString(), data)); + } + for (const auto& game : games) { + IPluginGame* gamePlugin = m_OrganizerCore.getGame(game); + if (gamePlugin != nullptr && + gamePlugin->gameShortName().compare("SkyrimSE", Qt::CaseInsensitive) == 0) + searchedMO2NexusGame = true; + auto iter = sorted.equal_range(gamePlugin->gameNexusName()); + for (auto result = iter.first; result != iter.second; ++result) { + std::vector const modsList = + ModInfo::getByModID(result->first, result->second.first); + + for (const auto& mod : modsList) { + if (mod->endorsedState() != EndorsedState::ENDORSED_NEVER) { + if (result->second.second == "Endorsed") + mod->setIsEndorsed(true); + else if (result->second.second == "Abstained") + mod->setNeverEndorse(); + else + mod->setIsEndorsed(false); + } + } + + if (Settings::instance().nexus().endorsementIntegration()) { + if (result->first == "skyrimspecialedition" && + result->second.first == gamePlugin->nexusModOrganizerID()) { + m_OrganizerCore.settings().nexus().setEndorsementState( + endorsementStateFromString(result->second.second)); + + toggleMO2EndorseState(); + } + } + } + } + + if (!searchedMO2NexusGame && Settings::instance().nexus().endorsementIntegration()) { + auto *gamePlugin = m_OrganizerCore.getGame("SkyrimSE"); + if (gamePlugin) { + auto iter = sorted.equal_range(gamePlugin->gameNexusName()); + for (auto result = iter.first; result != iter.second; ++result) { + if (result->second.first == gamePlugin->nexusModOrganizerID()) { + m_OrganizerCore.settings().nexus().setEndorsementState( + endorsementStateFromString(result->second.second)); + + toggleMO2EndorseState(); + break; + } + } + } + } +} + +void MainWindow::nxmUpdateInfoAvailable(QString gameName, QVariant userData, + QVariant resultData, int) +{ + QString gameNameReal; + for (IPluginGame* game : m_PluginContainer.plugins()) { + if (game->gameNexusName() == gameName) { + gameNameReal = game->gameShortName(); + break; + } + } + QVariantList const resultList = resultData.toList(); + + auto* watcher = new QFutureWatcher(); + QObject::connect(watcher, &QFutureWatcher::finished, + [this, watcher]() { + finishUpdateInfo(watcher->result()); + watcher->deleteLater(); + }); + auto future = QtConcurrent::run([=]() { + return NxmUpdateInfoData{ + .game=gameNameReal, + .finalMods=ModInfo::filteredMods(gameNameReal, resultList, userData.toBool(), true)}; + }); + watcher->setFuture(future); + ui->modList->invalidateFilter(); +} + +void MainWindow::finishUpdateInfo(const NxmUpdateInfoData& data) +{ + if (data.finalMods.empty()) { + log::info("{}", tr("None of your %1 mods appear to have had recent file updates.") + .arg(data.game)); + } + + std::set> organizedGames; + for (const auto& mod : data.finalMods) { + if (mod->canBeUpdated()) { + organizedGames.insert( + std::make_pair(mod->gameName().toLower(), mod->nexusId())); + } + } + + if (!data.finalMods.empty() && organizedGames.empty()) + log::warn("{}", tr("All of your mods have been checked recently. We restrict " + "update checks to help preserve your available API requests.")); + + for (const auto& game : organizedGames) { + NexusInterface::instance().requestUpdates(game.second, this, QVariant(), game.first, + QString()); + } +} + +void MainWindow::nxmUpdatesAvailable(QString gameName, int modID, QVariant userData, + QVariant resultData, int requestID) +{ + QVariantMap resultInfo = resultData.toMap(); + QList const files = resultInfo["files"].toList(); + QList const fileUpdates = resultInfo["file_updates"].toList(); + QString gameNameReal; + + for (IPluginGame* game : m_PluginContainer.plugins()) { + if (game->gameNexusName() == gameName) { + gameNameReal = game->gameShortName(); + break; + } + } + + std::vector const modsList = ModInfo::getByModID(gameNameReal, modID); + + bool requiresInfo = false; + + for (const auto& mod : modsList) { + QString validNewVersion; + int newModStatus = -1; + QString const installedFile = QFileInfo(mod->installationFile()).fileName(); + + if (!installedFile.isEmpty()) { + QVariantMap foundFileData; + + // update the file status + for (const auto& file : files) { + QVariantMap fileData = file.toMap(); + + if (fileData["file_name"].toString().compare(installedFile, + Qt::CaseInsensitive) == 0) { + foundFileData = fileData; + newModStatus = foundFileData["category_id"].toInt(); + + if (newModStatus != NexusInterface::FileStatus::OLD_VERSION && + newModStatus != NexusInterface::FileStatus::REMOVED && + newModStatus != NexusInterface::FileStatus::ARCHIVED) { + + // since the file is still active if there are no updates for it, use this + // as current version + validNewVersion = foundFileData["version"].toString(); + } + break; + } + } + + if (foundFileData.isEmpty()) { + // The file was not listed, the file is likely archived and archived files are + // being hidden on the mod + newModStatus = NexusInterface::FileStatus::ARCHIVED_HIDDEN; + } + + // look for updates of the file + int currentUpdateId = -1; + + // find installed file ID from the updates list since old filenames are not + // guaranteed to be unique + for (const auto& updateEntry : fileUpdates) { + const QVariantMap& updateData = updateEntry.toMap(); + + if (installedFile.compare(updateData["old_file_name"].toString(), + Qt::CaseInsensitive) == 0) { + currentUpdateId = updateData["old_file_id"].toInt(); + break; + } + } + + bool foundActiveUpdate = false; + + // there is at least one update + if (currentUpdateId > 0) { + bool lookForMoreUpdates = true; + + // follow the update chain until there are no more updates + while (lookForMoreUpdates) { + lookForMoreUpdates = false; + + for (const auto& updateEntry : fileUpdates) { + const QVariantMap& updateData = updateEntry.toMap(); + + if (currentUpdateId == updateData["old_file_id"].toInt()) { + currentUpdateId = updateData["new_file_id"].toInt(); + + // check if the new file is still active + for (const auto& file : files) { + const QVariantMap& fileData = file.toMap(); + + if (currentUpdateId == fileData["file_id"].toInt()) { + int const updateStatus = fileData["category_id"].toInt(); + + if (updateStatus != NexusInterface::FileStatus::OLD_VERSION && + updateStatus != NexusInterface::FileStatus::REMOVED && + updateStatus != NexusInterface::FileStatus::ARCHIVED) { + + // new version is active, so record it + validNewVersion = fileData["version"].toString(); + foundActiveUpdate = true; + } + break; + } + } + + lookForMoreUpdates = true; + break; + } + } + } + } + + // if there were no active direct file updates for the installedFile + if (!foundActiveUpdate) { + // get the global mod version in case the file isn't an optional + if (newModStatus != NexusInterface::FileStatus::OPTIONAL_FILE && + newModStatus != NexusInterface::FileStatus::MISCELLANEOUS) { + requiresInfo = true; + } + } + } else { + // No installedFile means we don't know what to look at for a version so + // just get the global mod version + requiresInfo = true; + } + + if (newModStatus > 0) { + mod->setNexusFileStatus(newModStatus); + } + + if (!validNewVersion.isEmpty()) { + mod->setNewestVersion(validNewVersion); + mod->setLastNexusUpdate(QDateTime::currentDateTimeUtc()); + } + } + + // invalidate the filter to display mods with an update + ui->modList->invalidateFilter(); + + if (requiresInfo) { + NexusInterface::instance().requestModInfo(gameNameReal, modID, this, QVariant(), + QString()); + } +} + +void MainWindow::nxmModInfoAvailable(QString gameName, int modID, QVariant userData, + QVariant resultData, int requestID) +{ + QVariantMap result = resultData.toMap(); + QString gameNameReal; + bool foundUpdate = false; + + for (IPluginGame* game : m_PluginContainer.plugins()) { + if (game->gameNexusName() == gameName) { + gameNameReal = game->gameShortName(); + break; + } + } + + std::vector const modsList = ModInfo::getByModID(gameNameReal, modID); + + for (const auto& mod : modsList) { + QDateTime const now = QDateTime::currentDateTimeUtc(); + QDateTime const updateTarget = mod->getExpires(); + + // if file is still listed as optional or miscellaneous don't update the version as + // often optional files are left with an older version than the main mod version. + if (!result["version"].toString().isEmpty() && + mod->getNexusFileStatus() != NexusInterface::FileStatus::OPTIONAL_FILE && + mod->getNexusFileStatus() != NexusInterface::FileStatus::MISCELLANEOUS) { + + mod->setNewestVersion(result["version"].toString()); + foundUpdate = true; + } + + // update the LastNexusUpdate time in any case since we did perform the check. + mod->setLastNexusUpdate(QDateTime::currentDateTimeUtc()); + + mod->setNexusDescription(result["description"].toString()); + + mod->setNexusCategory(result["category_id"].toInt()); + + mod->setAuthor(result["author"].toString()); + + mod->setUploader(result["uploaded_by"].toString()); + + mod->setUploaderUrl(result["uploaded_users_profile_url"].toString()); + + if ((mod->endorsedState() != EndorsedState::ENDORSED_NEVER) && + (result.contains("endorsement"))) { + QVariantMap endorsement = result["endorsement"].toMap(); + QString const endorsementStatus = endorsement["endorse_status"].toString(); + + if (endorsementStatus.compare("Endorsed") == 00) + mod->setIsEndorsed(true); + else if (endorsementStatus.compare("Abstained") == 00) + mod->setNeverEndorse(); + else + mod->setIsEndorsed(false); + } + + mod->setLastNexusQuery(QDateTime::currentDateTimeUtc()); + mod->setNexusLastModified( + QDateTime::fromSecsSinceEpoch(result["updated_timestamp"].toInt(), QTimeZone::UTC)); + + m_OrganizerCore.modList()->notifyChange(ModInfo::getIndex(mod->name())); + } + + if (foundUpdate) { + // invalidate the filter to display mods with an update + ui->modList->invalidateFilter(); + } +} + +void MainWindow::nxmEndorsementToggled(QString, int, QVariant, QVariant resultData, int) +{ + const QMap results = resultData.toMap(); + + auto itor = results.find("status"); + if (itor == results.end()) { + log::error("endorsement response has no status"); + return; + } + + const auto s = endorsementStateFromString(itor->toString()); + + switch (s) { + case EndorsementState::Accepted: { + QMessageBox::information(this, tr("Thank you!"), + tr("Thank you for your endorsement!")); + break; + } + + case EndorsementState::Refused: { + // don't spam message boxes if the user doesn't want to endorse + log::info( + "Mod Organizer will not be endorsed and will no longer ask you to endorse."); + break; + } + + case EndorsementState::NoDecision: { + log::error("bad status '{}' in endorsement response", itor->toString()); + return; + } + } + + m_OrganizerCore.settings().nexus().setEndorsementState(s); + toggleMO2EndorseState(); + + if (!disconnect(sender(), + SIGNAL(nxmEndorsementToggled(QString, int, QVariant, QVariant, int)), + this, + SLOT(nxmEndorsementToggled(QString, int, QVariant, QVariant, int)))) { + log::error("failed to disconnect endorsement slot"); + } +} + +void MainWindow::nxmTrackedModsAvailable(QVariant userData, QVariant resultData, int) +{ + QMap gameNames; + for (auto *game : m_PluginContainer.plugins()) { + gameNames[game->gameNexusName()] = game->gameShortName(); + } + + for (unsigned int i = 0; i < ModInfo::getNumMods(); i++) { + auto modInfo = ModInfo::getByIndex(i); + if (modInfo->nexusId() <= 0) + continue; + + bool found = false; + auto resultsList = resultData.toList(); + for (const auto& item : resultsList) { + auto results = item.toMap(); + if ((gameNames[results["domain_name"].toString()].compare( + modInfo->gameName(), Qt::CaseInsensitive) == 0) && + (results["mod_id"].toInt() == modInfo->nexusId())) { + found = true; + break; + } + } + + modInfo->setIsTracked(found); + modInfo->saveMeta(); + } +} + +void MainWindow::nxmDownloadURLs(QString, int, int, QVariant, QVariant resultData, int) +{ + auto servers = m_OrganizerCore.settings().network().servers(); + + for (const QVariant& var : resultData.toList()) { + const QVariantMap map = var.toMap(); + + const auto name = map["short_name"].toString(); + const auto isPremium = + map["name"].toString().contains("Premium", Qt::CaseInsensitive); + const auto isCDN = + map["short_name"].toString().contains("CDN", Qt::CaseInsensitive); + + bool found = false; + + for (auto& server : servers) { + if (server.name() == name) { + // already exists, update + server.setPremium(isPremium); + server.updateLastSeen(); + found = true; + break; + } + } + + if (!found) { + // new server + ServerInfo server(name, isPremium, QDate::currentDate(), isCDN ? 1 : 0, {}); + servers.add(std::move(server)); + } + } + + m_OrganizerCore.settings().network().updateServers(servers); +} + +void MainWindow::nxmGameInfoAvailable(QString gameName, QVariant, QVariant resultData, + int) +{ + QVariantMap result = resultData.toMap(); + QVariantList const categories = result["categories"].toList(); + CategoryFactory& catFactory = CategoryFactory::instance(); + catFactory.reset(); + for (const auto& category : categories) { + auto catMap = category.toMap(); + std::vector nexusCat; + nexusCat.emplace_back(catMap["name"].toString(), + catMap["category_id"].toInt()); + catFactory.addCategory(catMap["name"].toString(), nexusCat, 0); + } +} + +void MainWindow::nxmRequestFailed(QString gameName, int modID, int, QVariant, int, + int errorCode, const QString& errorString) +{ + if (errorCode == QNetworkReply::ContentAccessDenied || + errorCode == QNetworkReply::ContentNotFoundError) { + log::debug("{}", + tr("Mod ID %1 no longer seems to be available on Nexus.").arg(modID)); + + // update last checked timestamp on orphaned mods as well to avoid repeating + // requests + QString gameNameReal; + for (IPluginGame* game : m_PluginContainer.plugins()) { + if (game->gameNexusName() == gameName) { + gameNameReal = game->gameShortName(); + break; + } + } + auto orphanedMods = ModInfo::getByModID(gameNameReal, modID); + for (const auto& mod : orphanedMods) { + mod->setLastNexusUpdate(QDateTime::currentDateTimeUtc()); + mod->setLastNexusQuery(QDateTime::currentDateTimeUtc()); + } + } else { + MessageDialog::showMessage( + tr("Error %1: Request to Nexus failed: %2").arg(errorCode).arg(errorString), + this); + } +} + +BSA::EErrorCode MainWindow::extractBSA(BSA::Archive& archive, BSA::Folder::Ptr folder, + const QString& destination, + QProgressDialog& progress) +{ + QDir().mkdir(destination); + BSA::EErrorCode result = BSA::ERROR_NONE; + QString const errorFile; + + for (unsigned int i = 0; i < folder->getNumFiles(); ++i) { + BSA::File::Ptr const file = folder->getFile(i); + BSA::EErrorCode const res = archive.extract(file, qUtf8Printable(destination)); + if (res != BSA::ERROR_NONE) { + reportError(tr("failed to read %1: %2").arg(file->getName().c_str()).arg(res)); + result = res; + } + progress.setLabelText(file->getName().c_str()); + progress.setValue(progress.value() + 1); + QCoreApplication::processEvents(); + if (progress.wasCanceled()) { + result = BSA::ERROR_CANCELED; + } + } + + if (result != BSA::ERROR_NONE) { + if (QMessageBox::critical( + this, tr("Error"), + tr("failed to extract %1 (errorcode %2)").arg(errorFile).arg(result), + QMessageBox::Ok | QMessageBox::Cancel) == QMessageBox::Cancel) { + return result; + } + } + + for (unsigned int i = 0; i < folder->getNumSubFolders(); ++i) { + BSA::Folder::Ptr const subFolder = folder->getSubFolder(i); + BSA::EErrorCode const res = extractBSA( + archive, subFolder, + destination.mid(0).append("/").append(subFolder->getName().c_str()), progress); + if (res != BSA::ERROR_NONE) { + return res; + } + } + return BSA::ERROR_NONE; +} + +bool MainWindow::extractProgress(QProgressDialog& progress, int percentage, + std::string fileName) +{ + progress.setLabelText(fileName.c_str()); + progress.setValue(percentage); + QCoreApplication::processEvents(); + return !progress.wasCanceled(); +} + +void MainWindow::extractBSATriggered(QTreeWidgetItem* item) +{ + using namespace boost::placeholders; + + QString origin; + + QString const targetFolder = + FileDialogMemory::getExistingDirectory("extractBSA", this, tr("Extract BSA")); + QStringList archives = {}; + if (!targetFolder.isEmpty()) { + if (!item->parent()) { + for (int i = 0; i < item->childCount(); ++i) { + archives.append(item->child(i)->text(0)); + } + origin = QDir::fromNativeSeparators( + ToQString(m_OrganizerCore.directoryStructure() + ->getOriginByName(ToWString(item->text(0))) + .getPath())); + } else { + origin = QDir::fromNativeSeparators( + ToQString(m_OrganizerCore.directoryStructure() + ->getOriginByName(ToWString(item->text(1))) + .getPath())); + archives = QStringList({item->text(0)}); + } + + for (const auto& archiveName : archives) { + BSA::Archive archive; + QString const archivePath = QDir(origin).filePath(archiveName); + BSA::EErrorCode const result = + archive.read(archivePath.toLocal8Bit().constData(), true); + if ((result != BSA::ERROR_NONE) && (result != BSA::ERROR_INVALIDHASHES)) { + reportError(tr("failed to read %1: %2").arg(archivePath).arg(result)); + return; + } + + QProgressDialog progress(this); + progress.setMaximum(100); + progress.setValue(0); + progress.show(); + archive.extractAll( + QDir::toNativeSeparators(targetFolder).toLocal8Bit().constData(), + boost::bind(&MainWindow::extractProgress, this, boost::ref(progress), _1, + _2)); + if (result == BSA::ERROR_INVALIDHASHES) { + reportError( + tr("This archive contains invalid hashes. Some files may be broken.")); + } + archive.close(); + } + } +} + +void MainWindow::on_bsaList_customContextMenuRequested(const QPoint& pos) +{ + QMenu menu; + menu.addAction(tr("Extract..."), [=, this, item = ui->bsaList->itemAt(pos)]() { + extractBSATriggered(item); + }); + + menu.exec(ui->bsaList->viewport()->mapToGlobal(pos)); +} + +void MainWindow::on_bsaList_itemChanged(QTreeWidgetItem*, int) +{ + m_ArchiveListWriter.write(); + m_CheckBSATimer.start(500); +} + +void MainWindow::on_actionNotifications_triggered() +{ + auto future = checkForProblemsAsync(); + + future.waitForFinished(); + + ProblemsDialog problems(m_PluginContainer, this); + problems.exec(); + + scheduleCheckForProblems(); +} + +void MainWindow::on_actionChange_Game_triggered() +{ + InstanceManagerDialog dlg(m_PluginContainer, this); + dlg.exec(); +} + +void MainWindow::setCategoryListVisible(bool visible) +{ + if (visible) { + ui->categoriesGroup->show(); + ui->displayCategoriesBtn->setText(ToQString(L"\u00ab")); + } else { + ui->categoriesGroup->hide(); + ui->displayCategoriesBtn->setText(ToQString(L"\u00bb")); + } +} + +void MainWindow::on_displayCategoriesBtn_toggled(bool checked) +{ + setCategoryListVisible(checked); +} + +void MainWindow::removeFromToolbar(QAction* action) +{ + const auto& title = action->text(); + auto& list = *m_OrganizerCore.executablesList(); + + auto itor = list.find(title); + if (itor == list.end()) { + log::warn("removeFromToolbar(): executable '{}' not found", title); + return; + } + + itor->setShownOnToolbar(false); + updatePinnedExecutables(); +} + +void MainWindow::toolBar_customContextMenuRequested(const QPoint& point) +{ + QAction* action = ui->toolBar->actionAt(point); + + if (action != nullptr) { + if (action->objectName().startsWith("custom_")) { + QMenu menu; + menu.addAction(tr("Remove '%1' from the toolbar").arg(action->text()), + [&, action]() { + removeFromToolbar(action); + }); + menu.exec(ui->toolBar->mapToGlobal(point)); + return; + } + } + + // did not click a link button, show the default context menu + auto* m = createPopupMenu(); + m->exec(ui->toolBar->mapToGlobal(point)); +} + +Executable* MainWindow::getSelectedExecutable() +{ + const QString name = + ui->executablesListBox->itemText(ui->executablesListBox->currentIndex()); + + try { + return &m_OrganizerCore.executablesList()->get(name); + } catch (std::runtime_error&) { + return nullptr; + } +} + +void MainWindow::on_showHiddenBox_toggled(bool checked) +{ + m_OrganizerCore.downloadManager()->setShowHidden(checked); +} + +const char* MainWindow::PATTERN_BACKUP_GLOB = ".????_??_??_??_??_??"; +const char* MainWindow::PATTERN_BACKUP_REGEX = + R"(\.(\d\d\d\d_\d\d_\d\d_\d\d_\d\d_\d\d))"; +const char* MainWindow::PATTERN_BACKUP_DATE = "yyyy_MM_dd_hh_mm_ss"; + +bool MainWindow::createBackup(const QString& filePath, const QDateTime& time) +{ + QString const outPath = filePath + "." + time.toString(PATTERN_BACKUP_DATE); + if (shellCopy(QStringList(filePath), QStringList(outPath), this)) { + QFileInfo const fileInfo(filePath); + removeOldFiles(fileInfo.absolutePath(), fileInfo.fileName() + PATTERN_BACKUP_GLOB, + 10, QDir::Name); + return true; + } else { + return false; + } +} + +void MainWindow::on_saveButton_clicked() +{ + m_OrganizerCore.savePluginList(); + QDateTime const now = QDateTime::currentDateTime(); + if (createBackup(m_OrganizerCore.currentProfile()->getPluginsFileName(), now) && + createBackup(m_OrganizerCore.currentProfile()->getLoadOrderFileName(), now) && + createBackup(m_OrganizerCore.currentProfile()->getLockedOrderFileName(), now)) { + MessageDialog::showMessage(tr("Backup of load order created"), this); + } +} + +QString MainWindow::queryRestore(const QString& filePath) +{ + QFileInfo const pluginFileInfo(filePath); + QString const pattern = pluginFileInfo.fileName() + ".*"; + QFileInfoList files = pluginFileInfo.absoluteDir().entryInfoList( + QStringList(pattern), QDir::Files, QDir::Name); + + SelectionDialog dialog(tr("Choose backup to restore"), this); + QRegularExpression const exp(QRegularExpression::anchoredPattern(pluginFileInfo.fileName() + + PATTERN_BACKUP_REGEX)); + // match orphaned SafeWriteFile temporaries + QRegularExpression const exp2(QRegularExpression::anchoredPattern( + pluginFileInfo.fileName() + "\\.([A-Za-z]{6})")); + QRegularExpression const exp3( + QRegularExpression::anchoredPattern(pluginFileInfo.fileName() + "\\.(.*)")); + for (const QFileInfo& info : boost::adaptors::reverse(files)) { + auto match = exp.match(info.fileName()); + auto match2 = exp2.match(info.fileName()); + auto match3 = exp3.match(info.fileName()); + if (match.hasMatch()) { + QDateTime const time = QDateTime::fromString(match.captured(1), PATTERN_BACKUP_DATE); + dialog.addChoice(time.toString(), "", match.captured(1)); + } else if (match2.hasMatch()) { + dialog.addChoice(match2.captured(1), + tr("This file might be left over following a crash or power " + "loss event. Check its contents before restoring."), + match2.captured(1)); + } else if (match3.hasMatch()) { + dialog.addChoice(match3.captured(1), "", match3.captured(1)); + } + } + + if (dialog.numChoices() == 0) { + QMessageBox::information(this, tr("No Backups"), + tr("There are no backups to restore")); + return {}; + } + + if (dialog.exec() == QDialog::Accepted) { + return dialog.getChoiceData().toString(); + } else { + return {}; + } +} + +void MainWindow::on_restoreButton_clicked() +{ + QString const pluginName = m_OrganizerCore.currentProfile()->getPluginsFileName(); + QString const choice = queryRestore(pluginName); + if (!choice.isEmpty()) { + QString const loadOrderName = m_OrganizerCore.currentProfile()->getLoadOrderFileName(); + QString const lockedName = m_OrganizerCore.currentProfile()->getLockedOrderFileName(); + if (!shellCopy(pluginName + "." + choice, pluginName, true, this) || + !shellCopy(loadOrderName + "." + choice, loadOrderName, true, this) || + !shellCopy(lockedName + "." + choice, lockedName, true, this)) { + + const auto e = GetLastError(); + + QMessageBox::critical(this, tr("Restore failed"), + tr("Failed to restore the backup. Errorcode: %1") + .arg(QString::fromStdWString(formatSystemMessage(e)))); + } + m_OrganizerCore.refreshESPList(true); + } +} + +void MainWindow::on_saveModsButton_clicked() +{ + m_OrganizerCore.currentProfile()->writeModlistNow(true); + QDateTime const now = QDateTime::currentDateTime(); + if (createBackup(m_OrganizerCore.currentProfile()->getModlistFileName(), now)) { + MessageDialog::showMessage(tr("Backup of mod list created"), this); + } +} + +void MainWindow::on_restoreModsButton_clicked() +{ + QString const modlistName = m_OrganizerCore.currentProfile()->getModlistFileName(); + QString const choice = queryRestore(modlistName); + if (!choice.isEmpty()) { + if (!shellCopy(modlistName + "." + choice, modlistName, true, this)) { + const auto e = GetLastError(); + QMessageBox::critical(this, tr("Restore failed"), + tr("Failed to restore the backup. Errorcode: %1") + .arg(QString::fromStdWString(formatSystemMessage(e)))); + } + m_OrganizerCore.refresh(false); + } +} + +void MainWindow::on_managedArchiveLabel_linkHovered(const QString&) +{ + QToolTip::showText(QCursor::pos(), ui->managedArchiveLabel->toolTip()); +} + +void MainWindow::dragEnterEvent(QDragEnterEvent* event) +{ + // Accept copy or move drags to the download window. Link drags are not + // meaningful (Well, they are - we could drop a link in the download folder, + // but you need privileges to do that). + if (ui->downloadTab->isVisible() && + (event->proposedAction() == Qt::CopyAction || + event->proposedAction() == Qt::MoveAction) && + event->answerRect().intersects(ui->downloadTab->rect())) { + + // If I read the documentation right, this won't work under a motif windows + // manager and the check needs to be done at the drop. However, that means + // the user might be allowed to drop things which we can't sanely process + QMimeData const* data = event->mimeData(); + + if (data->hasUrls()) { + QStringList const extensions = + m_OrganizerCore.installationManager()->getSupportedExtensions(); + + // This is probably OK - scan to see if these are moderately sane archive + // types + QList const urls = data->urls(); + bool ok = true; + for (const QUrl& url : urls) { + if (url.isLocalFile()) { + QString const local = url.toLocalFile(); + bool fok = false; + for (const auto& ext : extensions) { + if (local.endsWith(ext, Qt::CaseInsensitive)) { + fok = true; + break; + } + } + if (!fok) { + ok = false; + break; + } + } + } + if (ok) { + event->accept(); + } + } + } +} + +void MainWindow::dropLocalFile(const QUrl& url, const QString& outputDir, bool move) +{ + QFileInfo const file(url.toLocalFile()); + if (!file.exists()) { + log::warn("invalid source file: {}", file.absoluteFilePath()); + return; + } + QString target = outputDir + "/" + file.fileName(); + if (QFile::exists(target)) { + QMessageBox box(QMessageBox::Question, file.fileName(), + tr("A file with the same name has already been downloaded. " + "What would you like to do?")); + box.addButton(tr("Overwrite"), QMessageBox::ActionRole); + box.addButton(tr("Rename new file"), QMessageBox::YesRole); + box.addButton(tr("Ignore file"), QMessageBox::RejectRole); + + box.exec(); + switch (box.buttonRole(box.clickedButton())) { + case QMessageBox::RejectRole: + return; + case QMessageBox::ActionRole: + break; + default: + case QMessageBox::YesRole: + target = m_OrganizerCore.downloadManager()->getDownloadFileName(file.fileName()); + break; + } + } + + bool success = false; + if (move) { + success = shellMove(file.absoluteFilePath(), target, true, this); + } else { + success = shellCopy(file.absoluteFilePath(), target, true, this); + } + if (!success) { + const auto e = GetLastError(); + log::error("file operation failed: {}", formatSystemMessage(e)); + } +} + +void MainWindow::dropEvent(QDropEvent* event) +{ + Qt::DropAction const action = event->proposedAction(); + QString const outputDir = m_OrganizerCore.downloadManager()->getOutputDirectory(); + if (action == Qt::MoveAction) { + // Tell windows I'm taking control and will delete the source of a move. + event->setDropAction(Qt::TargetMoveAction); + } + for (const QUrl& url : event->mimeData()->urls()) { + if (url.isLocalFile()) { + dropLocalFile(url, outputDir, action == Qt::MoveAction); + } else { + m_OrganizerCore.downloadManager()->startDownloadURLs(QStringList() << url.url()); + } + } + event->accept(); +} + +void MainWindow::keyReleaseEvent(QKeyEvent* event) +{ + // if the ui is locked, ignore the ALT key event + // alt-tabbing out of a game triggers this + auto& uilocker = UILocker::instance(); + auto& settings = Settings::instance(); + if (!uilocker.locked()) { + // if the menubar is hidden and showMenuBarOnAlt is true, + // pressing Alt will make it visible + if (event->key() == Qt::Key_Alt) { + bool const showMenubarOnAlt = settings.interface().showMenubarOnAlt(); + if (showMenubarOnAlt && !ui->menuBar->isVisible()) { + ui->menuBar->show(); + } + } + } + + QMainWindow::keyReleaseEvent(event); +} diff --git a/src/src/moapplication.cpp b/src/src/moapplication.cpp index db4ad6b..2d5be5f 100644 --- a/src/src/moapplication.cpp +++ b/src/src/moapplication.cpp @@ -1,977 +1,950 @@ -/* -Copyright (C) 2012 Sebastian Herbord. All rights reserved. - -This file is part of Mod Organizer. - -Mod Organizer is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -Mod Organizer is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with Mod Organizer. If not, see . -*/ - -#include "moapplication.h" -#include "commandline.h" -#include "instancemanager.h" -#include "loglist.h" -#include "mainwindow.h" -#include "messagedialog.h" -#include "multiprocess.h" -#include "nexusinterface.h" -#include "nxmaccessmanager.h" -#include "organizercore.h" -#include "sanitychecks.h" -#include "settings.h" -#include "fluorineconfig.h" -#include "fluorinepaths.h" -#include "fuseconnector.h" -#include "wineprefix.h" - -#include -#include -#include "shared/appconfig.h" -#include "shared/util.h" -#include "thread_utils.h" -#include "tutorialmanager.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -using namespace MOBase; -using namespace MOShared; - -// style proxy that changes the appearance of drop indicators -// -class ProxyStyle : public QProxyStyle -{ -public: - ProxyStyle(QStyle* baseStyle = nullptr) : QProxyStyle(baseStyle) {} - - void drawPrimitive(PrimitiveElement element, const QStyleOption* option, - QPainter* painter, const QWidget* widget) const override - { - if (element == QStyle::PE_IndicatorItemViewItemDrop) { - - // 0. Fix a bug that made the drop indicator sometimes appear on top - // of the mod list when selecting a mod. - if (option->rect.height() == 0 && option->rect.bottomRight() == QPoint(-1, -1)) { - return; - } - - // 1. full-width drop indicator - QRect rect(option->rect); - if (auto* view = qobject_cast(widget)) { - rect.setLeft(view->indentation()); - rect.setRight(widget->width()); - } - - // 2. stylish drop indicator - painter->setRenderHint(QPainter::Antialiasing, true); - - QColor col(option->palette.windowText().color()); - QPen pen(col); - pen.setWidth(2); - col.setAlpha(50); - - painter->setPen(pen); - painter->setBrush(QBrush(col)); - if (rect.height() == 0) { - QPoint tri[3] = {rect.topLeft(), rect.topLeft() + QPoint(-5, 5), - rect.topLeft() + QPoint(-5, -5)}; - painter->drawPolygon(tri, 3); - painter->drawLine(rect.topLeft(), rect.topRight()); - } else { - painter->drawRoundedRect(rect, 5, 5); - } - } else { - QProxyStyle::drawPrimitive(element, option, painter, widget); - } - } -}; - -// This adds the `dlls` directory to the path so the dlls can be found. How -// MO is able to find dlls in there is a bit convoluted: -// -// Dependencies on DLLs can be baked into an executable by passing a -// `manifestdependency` option to the linker. This can be done on the command -// line or with a pragma. Typically, the dependency will not be a hardcoded -// filename, but an assembly name, such as Microsoft.Windows.Common-Controls. -// -// When Windows loads the exe, it will look for this assembly in a variety of -// places, such as in the WinSxS folder, but also in the program's folder. It -// will look for `assemblyname.dll` or `assemblyname/assemblyname.dll` and try -// to load that. -// -// If these files don't exist, then the loader gets creative and looks for -// `assemblyname.manifest` and `assemblyname/assemblyname.manifest`. A manifest -// file is just an XML file that can contain a list of DLLs to load for this -// assembly. -// -// In MO's case, there's a `pragma` at the beginning of this file which adds -// `dlls` as an "assembly" dependency. This is a bit of a hack to just force -// the loader to eventually find `dlls/dlls.manifest`, which contains the list -// of all the DLLs MO requires to load. -// -// This file was handwritten in `modorganizer/src/dlls.manifest.qt5` and -// is copied and renamed in CMakeLists.txt into `bin/dlls/dlls.manifest`. Note -// that the useless and incorrect .qt5 extension is removed. -// -void addDllsToPath() -{ - const auto dllsPath = - QDir::toNativeSeparators(QCoreApplication::applicationDirPath() + "/dlls"); - - QCoreApplication::setLibraryPaths(QStringList(dllsPath) + - QCoreApplication::libraryPaths()); - - env::prependToPath(dllsPath); -} - -#ifdef MO2_WEBENGINE -void configureQtWebEngineProcessPath() -{ - const QString appDir = QCoreApplication::applicationDirPath(); - - if (qEnvironmentVariableIsSet("QTWEBENGINEPROCESS_PATH")) { - // keep user override - } else { - const QString candidates[] = { - appDir + "/QtWebEngineProcess", - appDir + "/../libexec/QtWebEngineProcess", - appDir + "/../lib/QtWebEngineProcess", - "/usr/lib/qt6/QtWebEngineProcess", - "/usr/lib/qt6/libexec/QtWebEngineProcess", - "/usr/lib64/qt6/QtWebEngineProcess", - "/usr/lib64/qt6/libexec/QtWebEngineProcess", - }; - - for (const auto& candidate : candidates) { - if (QFileInfo::exists(candidate)) { - qputenv("QTWEBENGINEPROCESS_PATH", candidate.toUtf8()); - break; - } - } - - if (!qEnvironmentVariableIsSet("QTWEBENGINEPROCESS_PATH")) { - const QString fromPath = QStandardPaths::findExecutable("QtWebEngineProcess"); - if (!fromPath.isEmpty()) { - qputenv("QTWEBENGINEPROCESS_PATH", fromPath.toUtf8()); - } - } - } - - if (!qEnvironmentVariableIsSet("QTWEBENGINE_RESOURCES_PATH")) { - const QString resourceDirs[] = { - appDir + "/resources", - appDir + "/../resources", - "/usr/share/qt6/resources", - "/usr/lib/qt6/resources", - "/usr/lib64/qt6/resources", - }; - for (const auto& dir : resourceDirs) { - if (QFileInfo::exists(dir + "/qtwebengine_resources.pak")) { - qputenv("QTWEBENGINE_RESOURCES_PATH", dir.toUtf8()); - break; - } - } - } - - if (!qEnvironmentVariableIsSet("QTWEBENGINE_LOCALES_PATH")) { - const QString localeDirs[] = { - appDir + "/translations/qtwebengine_locales", - appDir + "/../translations/qtwebengine_locales", - "/usr/share/qt6/translations/qtwebengine_locales", - "/usr/lib/qt6/translations/qtwebengine_locales", - "/usr/lib64/qt6/translations/qtwebengine_locales", - }; - for (const auto& dir : localeDirs) { - if (QFileInfo::exists(dir)) { - qputenv("QTWEBENGINE_LOCALES_PATH", dir.toUtf8()); - break; - } - } - } -} -#endif - -MOApplication::MOApplication(int& argc, char** argv) : QApplication(argc, argv) -{ - TimeThis const tt("MOApplication()"); - - // Ensure the app name is always "ModOrganizer" regardless of the binary - // filename (e.g. "ModOrganizer.bin" inside an AppImage). - setApplicationName("ModOrganizer"); - setDesktopFileName(QStringLiteral("com.fluorine.manager")); - setWindowIcon(QIcon(":/MO/gui/app_icon")); - - qputenv("QML_DISABLE_DISK_CACHE", "true"); - - connect(&m_styleWatcher, &QFileSystemWatcher::fileChanged, [&](auto&& file) { - log::debug("style file '{}' changed, reloading", file); - updateStyle(file); - }); - - // Pick a Qt style available on this system. "Fusion" is bundled with Qt and - // looks identical across distros, so prefer it; fall back to whatever the - // QStyleFactory advertises first. - const auto availableStyles = QStyleFactory::keys(); - if (availableStyles.contains("Fusion")) { - m_defaultStyle = "Fusion"; - } else if (!availableStyles.isEmpty()) { - m_defaultStyle = availableStyles.first(); - } - // Start with "None" style setting and only apply custom styles from settings - // later during setup(). - setStyleFile(""); - addDllsToPath(); -#ifdef MO2_WEBENGINE - configureQtWebEngineProcessPath(); -#endif -} - -OrganizerCore& MOApplication::core() -{ - return *m_core; -} - -void MOApplication::firstTimeSetup(MOMultiProcess& multiProcess) -{ - connect( - &multiProcess, &MOMultiProcess::messageSent, this, - [this](auto&& s) { - externalMessage(s); - }, - Qt::QueuedConnection); -} - -int MOApplication::setup(MOMultiProcess& multiProcess, bool forceSelect) -{ - TimeThis tt("MOApplication setup()"); - std::fprintf(stderr, "[setup-diag] setup() entered\n"); - std::fflush(stderr); - - // makes plugin data path available to plugins, see - // IOrganizer::getPluginDataPath() - MOBase::details::setPluginDataPath(OrganizerCore::pluginDataPath()); - - // figuring out the current instance - m_instance = getCurrentInstance(forceSelect); - if (!m_instance) { - std::fprintf(stderr, "[setup-diag] getCurrentInstance() returned null\n"); - std::fflush(stderr); - return 1; - } - std::fprintf(stderr, "[setup-diag] instance dir='%s' ini='%s'\n", - qUtf8Printable(m_instance->directory()), - qUtf8Printable(m_instance->iniPath())); - std::fflush(stderr); - - // first time the data path is available, set the global property and log - // directory, then log a bunch of debug stuff - const QString dataPath = m_instance->directory(); - setProperty("dataPath", dataPath); - - if (!setLogDirectory(dataPath)) { - std::fprintf(stderr, "[setup-diag] setLogDirectory() failed for '%s'\n", - qUtf8Printable(dataPath)); - std::fflush(stderr); - reportError(tr("Failed to create log folder.")); - InstanceManager::singleton().clearCurrentInstance(); - return 1; - } - std::fprintf(stderr, "[setup-diag] setLogDirectory() ok\n"); - std::fflush(stderr); - - log::debug("command line: '{}'", QCoreApplication::arguments().join(' ')); - -#ifndef GITID -#define GITID "unknown" -#endif - log::info("starting Mod Organizer version {} revision {} in {}", - createVersionInfo().string(), GITID, QCoreApplication::applicationDirPath()); - - if (multiProcess.secondary()) { - log::debug("another instance of MO is running but --multiple was given"); - } - - log::info("data path: {}", m_instance->directory()); - log::info("working directory: {}", QDir::currentPath()); - - tt.start("MOApplication::doOneRun() settings"); - - // deleting old files, only for the main instance - if (!multiProcess.secondary()) { - purgeOldFiles(); - } - - // loading settings - m_settings.reset(new Settings(m_instance->iniPath(), true)); - std::fprintf(stderr, "[setup-diag] settings loaded from '%s'\n", - qUtf8Printable(m_instance->iniPath())); - std::fflush(stderr); - log::getDefault().setLevel(m_settings->diagnostics().logLevel()); - log::debug("using ini at '{}'", m_settings->filename()); - - OrganizerCore::setGlobalCoreDumpType(m_settings->diagnostics().coreDumpType()); - - tt.start("MOApplication::doOneRun() log and checks"); - - // logging and checking - env::Environment const env; - env.dump(*m_settings); - m_settings->dump(); - sanity::checkEnvironment(env); - - m_modules = std::move(env.onModuleLoaded(qApp, [](auto&& m) { - if (m.interesting()) { - log::debug("loaded module {}", m.toString()); - } - - sanity::checkIncompatibleModule(m); - })); - - auto sslBuildVersion = QSslSocket::sslLibraryBuildVersionString(); - auto sslVersion = QSslSocket::sslLibraryVersionString(); - log::debug("SSL Build Version: {}, SSL Runtime Version {}", sslBuildVersion, - sslVersion); - - // nexus interface - tt.start("MOApplication::doOneRun() NexusInterface"); - log::debug("initializing nexus interface"); - m_nexus.reset(new NexusInterface(m_settings.get())); - - // organizer core - tt.start("MOApplication::doOneRun() OrganizerCore"); - log::debug("initializing core"); - - m_core.reset(new OrganizerCore(*m_settings)); - std::fprintf(stderr, "[setup-diag] organizer core constructed\n"); - std::fflush(stderr); - if (!m_core->bootstrap()) { - std::fprintf(stderr, "[setup-diag] organizer core bootstrap failed\n"); - std::fflush(stderr); - reportError(tr("Failed to set up data paths.")); - InstanceManager::singleton().clearCurrentInstance(); - return 1; - } - std::fprintf(stderr, "[setup-diag] organizer core bootstrap ok\n"); - std::fflush(stderr); - - // plugins - tt.start("MOApplication::doOneRun() plugins"); - log::debug("initializing plugins"); - - m_plugins = std::make_unique(m_core.get()); - std::fprintf(stderr, "[setup-diag] plugin container constructed, loading plugins...\n"); - std::fflush(stderr); - m_plugins->loadPlugins(); - std::fprintf(stderr, "[setup-diag] plugin loading finished\n"); - std::fflush(stderr); - log::debug("all plugins loaded"); - - // instance - log::debug("entering setupInstanceLoop..."); - if (auto r = setupInstanceLoop(*m_instance, *m_plugins)) { - log::debug("setupInstanceLoop returned {}", *r); - return *r; - } - log::debug("setupInstanceLoop done"); - - if (m_instance->isPortable()) { - log::debug("this is a portable instance"); - } - - tt.start("MOApplication::doOneRun() OrganizerCore setup"); - - sanity::checkPaths(*m_instance->gamePlugin(), *m_settings); - - // setting up organizer core - m_core->setManagedGame(m_instance->gamePlugin()); - - // Clean up stale FUSE mounts from a previous crash BEFORE any game - // directory access (profile init, BSA invalidation, etc.). - { - const auto dataDir = m_instance->gamePlugin()->dataDirectory().absolutePath(); - log::info("checking for stale FUSE mount on '{}'", dataDir); - FuseConnector::tryCleanupStaleMount(dataDir); - } - - // Restore any stale INI/save backups left by a previous crash. This ensures - // the documents directory is clean before we do anything else. - { - auto prefixPath = FluorineConfig::prefixPath(); - if (!prefixPath || prefixPath->isEmpty()) { - QSettings const instanceSettings(m_settings->filename(), QSettings::IniFormat); - for (const auto& key : {"Settings/proton_prefix_path", "Settings/prefix_path", - "Proton/prefix_path", "fluorine/prefix_path"}) { - const QString value = instanceSettings.value(key).toString().trimmed(); - if (!value.isEmpty()) { - prefixPath = value; - break; - } - } - } - if (prefixPath && !prefixPath->isEmpty()) { - WinePrefix const prefix(*prefixPath); - if (prefix.isValid()) { - log::info("checking for stale backup files in prefix '{}'", *prefixPath); - prefix.restoreStaleBackups(); - } - } - } - - m_core->createDefaultProfile(); - m_core->createOverwriteDirectories(); - - { - const auto edition = m_settings->game().edition().value_or(""); - const auto variant = edition.isEmpty() ? QString("Steam") : edition; - log::info("using game plugin '{}' ('{}', variant {}) at {}", - m_instance->gamePlugin()->gameName(), - m_instance->gamePlugin()->gameShortName(), - variant, - m_instance->gamePlugin()->gameDirectory().absolutePath()); - } - - CategoryFactory::instance().loadCategories(); - m_core->updateExecutablesList(); - m_core->updateModInfoFromDisc(); - m_core->setCurrentProfile(m_instance->profileName()); - - return 0; -} - -int MOApplication::run(MOMultiProcess& multiProcess) -{ - log::debug("MOApplication::run() entered"); - // checking command line - TimeThis tt("MOApplication::run()"); - - // show splash - tt.start("MOApplication::doOneRun() splash"); - - MOSplash splash(*m_settings, m_instance->directory(), m_instance->gamePlugin()); - - tt.start("MOApplication::doOneRun() finishing"); - - // start an api check - QString apiKey; - if (GlobalSettings::nexusApiKey(apiKey)) { - m_nexus->getAccessManager()->apiCheck(apiKey); - } - - // tutorials - log::debug("initializing tutorials"); - TutorialManager::init(qApp->applicationDirPath() + "/" + - QString::fromStdWString(AppConfig::tutorialsPath()) + "/", - m_core.get()); - - // styling - if (!setStyleFile(m_settings->interface().styleName().value_or(""))) { - // disable invalid stylesheet - m_settings->interface().setStyleName(""); - } - - int res = 1; - - { - tt.start("MOApplication::doOneRun() MainWindow setup"); - log::debug("creating MainWindow..."); - MainWindow mainWindow(*m_settings, *m_core, *m_plugins); - log::debug("MainWindow created, showing..."); - - // the nexus interface can show dialogs, make sure they're parented to the - // main window - m_nexus->getAccessManager()->setTopLevelWidget(&mainWindow); - - connect( - &mainWindow, &MainWindow::styleChanged, this, - [this](auto&& file) { - setStyleFile(file); - }, - Qt::QueuedConnection); - - log::debug("displaying main window"); - mainWindow.show(); - mainWindow.activateWindow(); - splash.close(); - - tt.stop(); - - res = exec(); - mainWindow.close(); - - // main window is about to be destroyed - m_nexus->getAccessManager()->setTopLevelWidget(nullptr); - } - - // reset geometry if the flag was set from the settings dialog - m_settings->geometry().resetIfNeeded(); - - return res; -} - -void MOApplication::externalMessage(const QString& message) -{ - log::debug("received external message '{}'", message); - - MOShortcut const moshortcut(message); - - if (moshortcut.isValid()) { - if (moshortcut.hasExecutable()) { - try { - m_core->processRunner() - .setFromShortcut(moshortcut) - .setWaitForCompletion(ProcessRunner::TriggerRefresh) - .run(); - } catch (std::exception&) { - // user was already warned - } - } - } else if (isNxmLink(message)) { - if (m_core == nullptr) { - // This can happen if MO2 is started with the --pick option and no instance has - // been selected yet. - reportError(tr("You need to select an instance before trying to download mods.")); - } else { - MessageDialog::showMessage(tr("Download started"), qApp->activeWindow(), false); - m_core->downloadRequestedNXM(message); - } - } else { - cl::CommandLine cl; - - if (auto r = cl.process(message.toStdWString())) { - log::debug("while processing external message, command line wants to " - "exit; ignoring"); - - return; - } - - if (auto i = cl.instance()) { - const auto ci = InstanceManager::singleton().currentInstance(); - - if (*i != ci->displayName()) { - reportError( - tr("This shortcut or command line is for instance '%1', but the current " - "instance is '%2'.") - .arg(*i) - .arg(ci->displayName())); - - return; - } - } - - if (auto p = cl.profile()) { - if (*p != m_core->profileName()) { - reportError( - tr("This shortcut or command line is for profile '%1', but the current " - "profile is '%2'.") - .arg(*p) - .arg(m_core->profileName())); - - return; - } - } - - cl.runPostOrganizer(*m_core); - } -} - -std::unique_ptr MOApplication::getCurrentInstance(bool forceSelect) -{ - auto& m = InstanceManager::singleton(); - auto currentInstance = m.currentInstance(); - - if (forceSelect || !currentInstance) { - // clear any overrides that might have been given on the command line - m.clearOverrides(); - currentInstance = selectInstance(); - } else { - if (!QDir(currentInstance->directory()).exists()) { - // the previously used instance doesn't exist anymore - - // clear any overrides that might have been given on the command line - m.clearOverrides(); - - if (m.hasAnyInstances()) { - reportError(QObject::tr("Instance at '%1' not found. Select another instance.") - .arg(currentInstance->directory())); - } else { - reportError( - QObject::tr("Instance at '%1' not found. You must create a new instance") - .arg(currentInstance->directory())); - } - - currentInstance = selectInstance(); - } - } - - return currentInstance; -} - -std::optional MOApplication::setupInstanceLoop(Instance& currentInstance, - PluginContainer& pc) -{ - for (;;) { - const auto setupResult = setupInstance(currentInstance, pc); - - if (setupResult == SetupInstanceResults::Okay) { - return {}; - } else if (setupResult == SetupInstanceResults::TryAgain) { - continue; - } else if (setupResult == SetupInstanceResults::SelectAnother) { - InstanceManager::singleton().clearCurrentInstance(); - return ReselectExitCode; - } else { - return 1; - } - } -} - -void MOApplication::purgeOldFiles() -{ - // remove the temporary backup directory in case we're restarting after an - // update - QString const backupDirectory = qApp->applicationDirPath() + "/update_backup"; - if (QDir(backupDirectory).exists()) { - shellDelete(QStringList(backupDirectory)); - } - - // cycle log file - removeOldFiles(qApp->property("dataPath").toString() + "/" + - QString::fromStdWString(AppConfig::logPath()), - "usvfs*.log", 5, QDir::Name); -} - -void MOApplication::resetForRestart() -{ - LogModel::instance().clear(); - ResetExitFlag(); - - // make sure the log file isn't locked in case MO was restarted and - // the previous instance gets deleted - log::getDefault().setFile({}); - - // clear instance and profile overrides - InstanceManager::singleton().clearOverrides(); - - m_core = {}; - m_plugins = {}; - m_nexus = {}; - m_settings = {}; - m_instance = {}; -} - -bool MOApplication::setStyleFile(const QString& styleName) -{ - // remove all files from watch - QStringList const currentWatch = m_styleWatcher.files(); - if (currentWatch.count() != 0) { - m_styleWatcher.removePaths(currentWatch); - } - // set new stylesheet or clear it - if (styleName.length() != 0) { - // Search for the stylesheet in multiple locations: - // 1. applicationDirPath()/stylesheets/ — bundled themes - // 2. instance baseDir/stylesheets/ — instance/portable themes (modlists) - // 3. fluorineDataDir()/stylesheets/ — user-installed custom themes - const QString ssSubdir = MOBase::ToQString(AppConfig::stylesheetsPath()); - QStringList searchDirs; - searchDirs << applicationDirPath() + "/" + ssSubdir; - if (m_instance) { - // Prefer baseDirectory() (populated after readFromIni), fall back to - // directory() which is always set by the constructor. - QString base = m_instance->baseDirectory(); - if (base.isEmpty()) - base = m_instance->directory(); - const QString instanceDir = base + "/" + ssSubdir; - if (!searchDirs.contains(instanceDir)) - searchDirs << instanceDir; - } - const QString userDir = fluorineDataDir() + "/stylesheets"; - if (!searchDirs.contains(userDir)) - searchDirs << userDir; - - QString resolved; - for (const auto& dir : searchDirs) { - QString const candidate = dir + "/" + styleName; - if (QFile::exists(candidate)) { - resolved = candidate; - break; - } - } - - if (!resolved.isEmpty()) { - m_styleWatcher.addPath(resolved); - updateStyle(resolved); - } else { - // Could be a built-in Qt style name (e.g. "Fusion") - updateStyle(styleName); - } - } else { - setStyle(new ProxyStyle(QStyleFactory::create(m_defaultStyle))); - setStyleSheet(""); - } - return true; -} - -bool MOApplication::notify(QObject* receiver, QEvent* event) -{ - try { - return QApplication::notify(receiver, event); - } catch (const std::filesystem::filesystem_error& fe) { - log::error("uncaught filesystem exception in handler (object {}, eventtype {}): {}", - receiver->objectName(), event->type(), fe.what()); - - // ENOTCONN = stale FUSE mount. Attempt recovery so MO2 can continue. - if (fe.code().value() == ENOTCONN) { - const auto& p1 = fe.path1(); - if (!p1.empty()) { - log::warn("ENOTCONN on '{}' — attempting stale mount cleanup", p1.string()); - FuseConnector::tryCleanupStaleMount(QString::fromStdString(p1.string())); - } - const auto& p2 = fe.path2(); - if (!p2.empty()) { - log::warn("ENOTCONN on '{}' — attempting stale mount cleanup", p2.string()); - FuseConnector::tryCleanupStaleMount(QString::fromStdString(p2.string())); - } - } - - reportError(tr("an error occurred: %1").arg(fe.what())); - return false; - } catch (const std::exception& e) { - log::error("uncaught exception in handler (object {}, eventtype {}): {}", - receiver->objectName(), event->type(), e.what()); - reportError(tr("an error occurred: %1").arg(e.what())); - return false; - } catch (...) { - log::error("uncaught non-std exception in handler (object {}, eventtype {})", - receiver->objectName(), event->type()); - reportError(tr("an error occurred")); - return false; - } -} - -namespace -{ -QStringList extractTopStyleSheetComments(QFile& stylesheet) -{ - if (!stylesheet.open(QFile::ReadOnly)) { - log::error("failed to open stylesheet file {}", stylesheet.fileName()); - return {}; - } - ON_BLOCK_EXIT([&stylesheet]() { - stylesheet.close(); - }); - - QStringList topComments; - - while (true) { - const auto byteLine = stylesheet.readLine(); - if (byteLine.isNull()) { - break; - } - - const auto line = QString(byteLine).trimmed(); - - // skip empty lines - if (line.isEmpty()) { - continue; - } - - // only handle single line comments - if (!line.startsWith("/*")) { - break; - } - - topComments.push_back(line.mid(2, line.size() - 4).trimmed()); - } - - return topComments; -} - -QString extractBaseStyleFromStyleSheet(QFile& stylesheet, const QString& defaultStyle) -{ - // read the first line of the files that are either empty or comments - // - const auto topLines = extractTopStyleSheetComments(stylesheet); - - const auto factoryStyles = QStyleFactory::keys(); - - QString style = defaultStyle; - - for (const auto& line : topLines) { - if (!line.startsWith("mo2-base-style")) { - continue; - } - - const auto parts = line.split(":"); - if (parts.size() != 2) { - log::warn("found invalid top-comment for mo2 in {}: {}", stylesheet.fileName(), - line); - continue; - } - - const auto tmpStyle = parts[1].trimmed(); - const auto index = factoryStyles.indexOf(tmpStyle, 0, Qt::CaseInsensitive); - if (index == -1) { - log::warn("base style '{}' from style '{}' not found", tmpStyle, - stylesheet.fileName(), line); - continue; - } - - style = factoryStyles[index]; - log::info("found base style '{}' for style '{}'", style, stylesheet.fileName()); - break; - } - - return style; -} - -} // namespace - -// Walk a directory and create lowercase symlinks for any file whose name -// contains uppercase letters, if the lowercase name doesn't already exist. -// QSS files authored on Windows often reference asset files with lowercase -// names but the actual files on disk use mixed case — on a case-sensitive -// filesystem Qt's url() resolver fails. Shimming lowercase symlinks lets the -// existing paths resolve without touching the original files. -static void createLowercaseStylesheetShims(const QString& dirPath) -{ - namespace fs = std::filesystem; - std::error_code ec; - if (!fs::exists(dirPath.toStdString(), ec) || - !fs::is_directory(dirPath.toStdString(), ec)) { - return; - } - for (auto it = fs::recursive_directory_iterator( - dirPath.toStdString(), - fs::directory_options::skip_permission_denied, ec); - !ec && it != fs::recursive_directory_iterator(); it.increment(ec)) { - const auto& entry = *it; - if (!entry.is_regular_file(ec)) continue; - - const std::string name = entry.path().filename().string(); - std::string lowerName = name; - std::transform(lowerName.begin(), lowerName.end(), lowerName.begin(), - [](unsigned char c) { return std::tolower(c); }); - if (lowerName == name) continue; // already lowercase - - const auto parent = entry.path().parent_path(); - const auto lowerPath = parent / lowerName; - if (fs::exists(lowerPath, ec)) continue; - - std::error_code lec; - fs::create_symlink(name, lowerPath, lec); - if (lec) { - log::debug("stylesheet shim: failed to link '{}' -> '{}': {}", - lowerPath.string(), name, lec.message()); - } - } -} - -void MOApplication::updateStyle(const QString& fileName) -{ - if (QStyleFactory::keys().contains(fileName)) { - setStyleSheet(""); - setStyle(new ProxyStyle(QStyleFactory::create(fileName))); - } else { - QFile stylesheet(fileName); - if (stylesheet.exists()) { - // Pre-create lowercase shims so url(foo.svg) in the QSS resolves even - // when the on-disk file is Foo.svg. - createLowercaseStylesheetShims(QFileInfo(fileName).absolutePath()); - setStyle(new ProxyStyle(QStyleFactory::create( - extractBaseStyleFromStyleSheet(stylesheet, m_defaultStyle)))); - setStyleSheet(QString("file:///%1").arg(fileName)); - } else { - log::warn("invalid stylesheet: {}", fileName); - } - } -} - -MOSplash::MOSplash(const Settings& settings, const QString& dataPath, - const MOBase::IPluginGame* game) -{ - const auto splashPath = getSplashPath(settings, dataPath, game); - if (splashPath.isEmpty()) { - return; - } - - QPixmap const image(splashPath); - if (image.isNull()) { - log::error("failed to load splash from {}", splashPath); - return; - } - - ss_.reset(new QSplashScreen(image)); - settings.geometry().centerOnMainWindowMonitor(ss_.get()); - - ss_->show(); - ss_->activateWindow(); -} - -void MOSplash::close() -{ - if (ss_) { - // don't pass mainwindow as it just waits half a second for it - // instead of proceding - ss_->finish(nullptr); - } -} - -QString MOSplash::getSplashPath(const Settings& settings, const QString& dataPath, - const MOBase::IPluginGame* game) -{ - if (!settings.useSplash()) { - return {}; - } - - // try splash from instance directory - const QString splashPath = dataPath + "/splash.png"; - if (QFile::exists(dataPath + "/splash.png")) { - QImage const image(splashPath); - if (!image.isNull()) { - return splashPath; - } - } - - // try splash from plugin - QString pluginSplash = QString(":/%1/splash").arg(game->gameShortName()); - if (QFile::exists(pluginSplash)) { - QImage const image(pluginSplash); - if (!image.isNull()) { - image.save(splashPath); - return pluginSplash; - } - } - - // try default splash from resource - QString defaultSplash = ":/MO/gui/splash"; - if (QFile::exists(defaultSplash)) { - QImage const image(defaultSplash); - if (!image.isNull()) { - return defaultSplash; - } - } - - return splashPath; -} +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "moapplication.h" +#include "commandline.h" +#include "instancemanager.h" +#include "loglist.h" +#include "mainwindow.h" +#include "messagedialog.h" +#include "multiprocess.h" +#include "nexusinterface.h" +#include "nxmaccessmanager.h" +#include "organizercore.h" +#include "sanitychecks.h" +#include "settings.h" +#include "fluorineconfig.h" +#include "fluorinepaths.h" +#include "fuseconnector.h" +#include "wineprefix.h" + +#include +#include +#include "shared/appconfig.h" +#include "shared/util.h" +#include "thread_utils.h" +#include "tutorialmanager.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace MOBase; +using namespace MOShared; + +// style proxy that changes the appearance of drop indicators +// +class ProxyStyle : public QProxyStyle +{ +public: + ProxyStyle(QStyle* baseStyle = nullptr) : QProxyStyle(baseStyle) {} + + void drawPrimitive(PrimitiveElement element, const QStyleOption* option, + QPainter* painter, const QWidget* widget) const override + { + if (element == QStyle::PE_IndicatorItemViewItemDrop) { + + // 0. Fix a bug that made the drop indicator sometimes appear on top + // of the mod list when selecting a mod. + if (option->rect.height() == 0 && option->rect.bottomRight() == QPoint(-1, -1)) { + return; + } + + // 1. full-width drop indicator + QRect rect(option->rect); + if (const auto* view = qobject_cast(widget)) { + rect.setLeft(view->indentation()); + rect.setRight(widget->width()); + } + + // 2. stylish drop indicator + painter->setRenderHint(QPainter::Antialiasing, true); + + QColor col(option->palette.windowText().color()); + QPen pen(col); + pen.setWidth(2); + col.setAlpha(50); + + painter->setPen(pen); + painter->setBrush(QBrush(col)); + if (rect.height() == 0) { + QPoint tri[3] = {rect.topLeft(), rect.topLeft() + QPoint(-5, 5), + rect.topLeft() + QPoint(-5, -5)}; + painter->drawPolygon(tri, 3); + painter->drawLine(rect.topLeft(), rect.topRight()); + } else { + painter->drawRoundedRect(rect, 5, 5); + } + } else { + QProxyStyle::drawPrimitive(element, option, painter, widget); + } + } +}; + +void addLinuxLibrariesToPath() +{ + const auto libsPath = + QDir::toNativeSeparators(QCoreApplication::applicationDirPath() + "/lib"); + + QCoreApplication::setLibraryPaths(QStringList(libsPath) + + QCoreApplication::libraryPaths()); + + env::prependToPath(libsPath); +} + +#ifdef MO2_WEBENGINE +void configureQtWebEngineProcessPath() +{ + const QString appDir = QCoreApplication::applicationDirPath(); + + if (qEnvironmentVariableIsSet("QTWEBENGINEPROCESS_PATH")) { + // keep user override + } else { + const QString candidates[] = { + appDir + "/QtWebEngineProcess", + appDir + "/../libexec/QtWebEngineProcess", + appDir + "/../lib/QtWebEngineProcess", + "/usr/lib/qt6/QtWebEngineProcess", + "/usr/lib/qt6/libexec/QtWebEngineProcess", + "/usr/lib64/qt6/QtWebEngineProcess", + "/usr/lib64/qt6/libexec/QtWebEngineProcess", + }; + + for (const auto& candidate : candidates) { + if (QFileInfo::exists(candidate)) { + qputenv("QTWEBENGINEPROCESS_PATH", candidate.toUtf8()); + break; + } + } + + if (!qEnvironmentVariableIsSet("QTWEBENGINEPROCESS_PATH")) { + const QString fromPath = QStandardPaths::findExecutable("QtWebEngineProcess"); + if (!fromPath.isEmpty()) { + qputenv("QTWEBENGINEPROCESS_PATH", fromPath.toUtf8()); + } + } + } + + if (!qEnvironmentVariableIsSet("QTWEBENGINE_RESOURCES_PATH")) { + const QString resourceDirs[] = { + appDir + "/resources", + appDir + "/../resources", + "/usr/share/qt6/resources", + "/usr/lib/qt6/resources", + "/usr/lib64/qt6/resources", + }; + for (const auto& dir : resourceDirs) { + if (QFileInfo::exists(dir + "/qtwebengine_resources.pak")) { + qputenv("QTWEBENGINE_RESOURCES_PATH", dir.toUtf8()); + break; + } + } + } + + if (!qEnvironmentVariableIsSet("QTWEBENGINE_LOCALES_PATH")) { + const QString localeDirs[] = { + appDir + "/translations/qtwebengine_locales", + appDir + "/../translations/qtwebengine_locales", + "/usr/share/qt6/translations/qtwebengine_locales", + "/usr/lib/qt6/translations/qtwebengine_locales", + "/usr/lib64/qt6/translations/qtwebengine_locales", + }; + for (const auto& dir : localeDirs) { + if (QFileInfo::exists(dir)) { + qputenv("QTWEBENGINE_LOCALES_PATH", dir.toUtf8()); + break; + } + } + } +} +#endif + +MOApplication::MOApplication(int& argc, char** argv) : QApplication(argc, argv) +{ + TimeThis const tt("MOApplication()"); + + // Ensure the app name is always "ModOrganizer" regardless of the binary + // filename (e.g. "ModOrganizer.bin" inside an AppImage). + setApplicationName("ModOrganizer"); + setDesktopFileName(QStringLiteral("com.fluorine.manager")); + setWindowIcon(QIcon(":/MO/gui/app_icon")); + + qputenv("QML_DISABLE_DISK_CACHE", "true"); + + connect(&m_styleWatcher, &QFileSystemWatcher::fileChanged, [&](auto&& file) { + log::debug("style file '{}' changed, reloading", file); + updateStyle(file); + }); + + // Pick a Qt style available on this system. "Fusion" is bundled with Qt and + // looks identical across distros, so prefer it; fall back to whatever the + // QStyleFactory advertises first. + const auto availableStyles = QStyleFactory::keys(); + if (availableStyles.contains("Fusion")) { + m_defaultStyle = "Fusion"; + } else if (!availableStyles.isEmpty()) { + m_defaultStyle = availableStyles.first(); + } + // Start with "None" style setting and only apply custom styles from settings + // later during setup(). + setStyleFile(""); + addLinuxLibrariesToPath(); +#ifdef MO2_WEBENGINE + configureQtWebEngineProcessPath(); +#endif +} + +OrganizerCore& MOApplication::core() +{ + return *m_core; +} + +void MOApplication::firstTimeSetup(MOMultiProcess& multiProcess) +{ + connect( + &multiProcess, &MOMultiProcess::messageSent, this, + [this](auto&& s) { + externalMessage(s); + }, + Qt::QueuedConnection); +} + +int MOApplication::setup(MOMultiProcess& multiProcess, bool forceSelect) +{ + TimeThis tt("MOApplication setup()"); + std::fprintf(stderr, "[setup-diag] setup() entered\n"); + std::fflush(stderr); + + // makes plugin data path available to plugins, see + // IOrganizer::getPluginDataPath() + MOBase::details::setPluginDataPath(OrganizerCore::pluginDataPath()); + + // figuring out the current instance + m_instance = getCurrentInstance(forceSelect); + if (!m_instance) { + std::fprintf(stderr, "[setup-diag] getCurrentInstance() returned null\n"); + std::fflush(stderr); + return 1; + } + std::fprintf(stderr, "[setup-diag] instance dir='%s' ini='%s'\n", + qUtf8Printable(m_instance->directory()), + qUtf8Printable(m_instance->iniPath())); + std::fflush(stderr); + + // first time the data path is available, set the global property and log + // directory, then log a bunch of debug stuff + const QString dataPath = m_instance->directory(); + setProperty("dataPath", dataPath); + + if (!setLogDirectory(dataPath)) { + std::fprintf(stderr, "[setup-diag] setLogDirectory() failed for '%s'\n", + qUtf8Printable(dataPath)); + std::fflush(stderr); + reportError(tr("Failed to create log folder.")); + InstanceManager::singleton().clearCurrentInstance(); + return 1; + } + std::fprintf(stderr, "[setup-diag] setLogDirectory() ok\n"); + std::fflush(stderr); + + log::debug("command line: '{}'", QCoreApplication::arguments().join(' ')); + +#ifndef GITID +#define GITID "unknown" +#endif + log::info("starting Mod Organizer version {} revision {} in {}", + createVersionInfo().string(), GITID, QCoreApplication::applicationDirPath()); + + if (multiProcess.secondary()) { + log::debug("another instance of MO is running but --multiple was given"); + } + + log::info("data path: {}", m_instance->directory()); + log::info("working directory: {}", QDir::currentPath()); + + tt.start("MOApplication::doOneRun() settings"); + + // deleting old files, only for the main instance + if (!multiProcess.secondary()) { + purgeOldFiles(); + } + + // loading settings + m_settings.reset(new Settings(m_instance->iniPath(), true)); + std::fprintf(stderr, "[setup-diag] settings loaded from '%s'\n", + qUtf8Printable(m_instance->iniPath())); + std::fflush(stderr); + log::getDefault().setLevel(m_settings->diagnostics().logLevel()); + log::debug("using ini at '{}'", m_settings->filename()); + + OrganizerCore::setGlobalCoreDumpType(m_settings->diagnostics().coreDumpType()); + + tt.start("MOApplication::doOneRun() log and checks"); + + // logging and checking + env::Environment const env; + env.dump(*m_settings); + m_settings->dump(); + sanity::checkEnvironment(env); + + m_modules = std::move(env::Environment::onModuleLoaded(qApp, [](auto&& m) { + if (m.interesting()) { + log::debug("loaded module {}", m.toString()); + } + + sanity::checkIncompatibleModule(m); + })); + + auto sslBuildVersion = QSslSocket::sslLibraryBuildVersionString(); + auto sslVersion = QSslSocket::sslLibraryVersionString(); + log::debug("SSL Build Version: {}, SSL Runtime Version {}", sslBuildVersion, + sslVersion); + + // nexus interface + tt.start("MOApplication::doOneRun() NexusInterface"); + log::debug("initializing nexus interface"); + m_nexus.reset(new NexusInterface(m_settings.get())); + + // organizer core + tt.start("MOApplication::doOneRun() OrganizerCore"); + log::debug("initializing core"); + + m_core.reset(new OrganizerCore(*m_settings)); + std::fprintf(stderr, "[setup-diag] organizer core constructed\n"); + std::fflush(stderr); + if (!m_core->bootstrap()) { + std::fprintf(stderr, "[setup-diag] organizer core bootstrap failed\n"); + std::fflush(stderr); + reportError(tr("Failed to set up data paths.")); + InstanceManager::singleton().clearCurrentInstance(); + return 1; + } + std::fprintf(stderr, "[setup-diag] organizer core bootstrap ok\n"); + std::fflush(stderr); + + // plugins + tt.start("MOApplication::doOneRun() plugins"); + log::debug("initializing plugins"); + + m_plugins = std::make_unique(m_core.get()); + std::fprintf(stderr, "[setup-diag] plugin container constructed, loading plugins...\n"); + std::fflush(stderr); + m_plugins->loadPlugins(); + std::fprintf(stderr, "[setup-diag] plugin loading finished\n"); + std::fflush(stderr); + log::debug("all plugins loaded"); + + // instance + log::debug("entering setupInstanceLoop..."); + if (auto r = setupInstanceLoop(*m_instance, *m_plugins)) { + log::debug("setupInstanceLoop returned {}", *r); + return *r; + } + log::debug("setupInstanceLoop done"); + + if (m_instance->isPortable()) { + log::debug("this is a portable instance"); + } + + tt.start("MOApplication::doOneRun() OrganizerCore setup"); + + sanity::checkPaths(*m_instance->gamePlugin(), *m_settings); + + // setting up organizer core + m_core->setManagedGame(m_instance->gamePlugin()); + + // Clean up stale FUSE mounts from a previous crash BEFORE any game + // directory access (profile init, BSA invalidation, etc.). + { + const auto dataDir = m_instance->gamePlugin()->dataDirectory().absolutePath(); + log::info("checking for stale FUSE mount on '{}'", dataDir); + FuseConnector::tryCleanupStaleMount(dataDir); + } + + // Restore any stale INI/save backups left by a previous crash. This ensures + // the documents directory is clean before we do anything else. + { + auto prefixPath = FluorineConfig::prefixPath(); + if (!prefixPath || prefixPath->isEmpty()) { + QSettings const instanceSettings(m_settings->filename(), QSettings::IniFormat); + for (const auto& key : {"Settings/proton_prefix_path", "Settings/prefix_path", + "Proton/prefix_path", "fluorine/prefix_path"}) { + const QString value = instanceSettings.value(key).toString().trimmed(); + if (!value.isEmpty()) { + prefixPath = value; + break; + } + } + } + if (prefixPath && !prefixPath->isEmpty()) { + WinePrefix const prefix(*prefixPath); + if (prefix.isValid()) { + log::info("checking for stale backup files in prefix '{}'", *prefixPath); + prefix.restoreStaleBackups(); + } + } + } + + m_core->createDefaultProfile(); + m_core->createOverwriteDirectories(); + + { + const auto edition = m_settings->game().edition().value_or(""); + const auto variant = edition.isEmpty() ? QString("Steam") : edition; + log::info("using game plugin '{}' ('{}', variant {}) at {}", + m_instance->gamePlugin()->gameName(), + m_instance->gamePlugin()->gameShortName(), + variant, + m_instance->gamePlugin()->gameDirectory().absolutePath()); + } + + CategoryFactory::instance().loadCategories(); + m_core->updateExecutablesList(); + m_core->updateModInfoFromDisc(); + m_core->setCurrentProfile(m_instance->profileName()); + + return 0; +} + +int MOApplication::run(MOMultiProcess& multiProcess) +{ + log::debug("MOApplication::run() entered"); + // checking command line + TimeThis tt("MOApplication::run()"); + + // show splash + tt.start("MOApplication::doOneRun() splash"); + + MOSplash splash(*m_settings, m_instance->directory(), m_instance->gamePlugin()); + + tt.start("MOApplication::doOneRun() finishing"); + + // start an api check + QString apiKey; + if (GlobalSettings::nexusApiKey(apiKey)) { + m_nexus->getAccessManager()->apiCheck(apiKey); + } + + // tutorials + log::debug("initializing tutorials"); + TutorialManager::init(qApp->applicationDirPath() + "/" + + QString::fromStdWString(AppConfig::tutorialsPath()) + "/", + m_core.get()); + + // styling + if (!setStyleFile(m_settings->interface().styleName().value_or(""))) { + // disable invalid stylesheet + m_settings->interface().setStyleName(""); + } + + int res = 1; + + { + tt.start("MOApplication::doOneRun() MainWindow setup"); + log::debug("creating MainWindow..."); + MainWindow mainWindow(*m_settings, *m_core, *m_plugins); + log::debug("MainWindow created, showing..."); + + // the nexus interface can show dialogs, make sure they're parented to the + // main window + m_nexus->getAccessManager()->setTopLevelWidget(&mainWindow); + + connect( + &mainWindow, &MainWindow::styleChanged, this, + [this](auto&& file) { + setStyleFile(file); + }, + Qt::QueuedConnection); + + log::debug("displaying main window"); + mainWindow.show(); + mainWindow.activateWindow(); + splash.close(); + + tt.stop(); + + res = exec(); + mainWindow.close(); + + // main window is about to be destroyed + m_nexus->getAccessManager()->setTopLevelWidget(nullptr); + } + + // reset geometry if the flag was set from the settings dialog + m_settings->geometry().resetIfNeeded(); + + return res; +} + +void MOApplication::externalMessage(const QString& message) +{ + log::debug("received external message '{}'", message); + + MOShortcut const moshortcut(message); + + if (moshortcut.isValid()) { + if (moshortcut.hasExecutable()) { + try { + m_core->processRunner() + .setFromShortcut(moshortcut) + .setWaitForCompletion(ProcessRunner::TriggerRefresh) + .run(); + } catch (std::exception&) { + // user was already warned + } + } + } else if (isNxmLink(message)) { + if (m_core == nullptr) { + // This can happen if MO2 is started with the --pick option and no instance has + // been selected yet. + reportError(tr("You need to select an instance before trying to download mods.")); + } else { + MessageDialog::showMessage(tr("Download started"), qApp->activeWindow(), false); + m_core->downloadRequestedNXM(message); + } + } else { + cl::CommandLine cl; + + if (auto r = cl.process(message.toStdWString())) { + log::debug("while processing external message, command line wants to " + "exit; ignoring"); + + return; + } + + if (auto i = cl.instance()) { + const auto ci = InstanceManager::singleton().currentInstance(); + + if (*i != ci->displayName()) { + reportError( + tr("This shortcut or command line is for instance '%1', but the current " + "instance is '%2'.") + .arg(*i) + .arg(ci->displayName())); + + return; + } + } + + if (auto p = cl.profile()) { + if (*p != m_core->profileName()) { + reportError( + tr("This shortcut or command line is for profile '%1', but the current " + "profile is '%2'.") + .arg(*p) + .arg(m_core->profileName())); + + return; + } + } + + cl.runPostOrganizer(*m_core); + } +} + +std::unique_ptr MOApplication::getCurrentInstance(bool forceSelect) +{ + auto& m = InstanceManager::singleton(); + auto currentInstance = m.currentInstance(); + + if (forceSelect || !currentInstance) { + // clear any overrides that might have been given on the command line + m.clearOverrides(); + currentInstance = selectInstance(); + } else { + if (!QDir(currentInstance->directory()).exists()) { + // the previously used instance doesn't exist anymore + + // clear any overrides that might have been given on the command line + m.clearOverrides(); + + if (m.hasAnyInstances()) { + reportError(QObject::tr("Instance at '%1' not found. Select another instance.") + .arg(currentInstance->directory())); + } else { + reportError( + QObject::tr("Instance at '%1' not found. You must create a new instance") + .arg(currentInstance->directory())); + } + + currentInstance = selectInstance(); + } + } + + return currentInstance; +} + +std::optional MOApplication::setupInstanceLoop(Instance& currentInstance, + PluginContainer& pc) +{ + for (;;) { + const auto setupResult = setupInstance(currentInstance, pc); + + if (setupResult == SetupInstanceResults::Okay) { + return {}; + } else if (setupResult == SetupInstanceResults::TryAgain) { + continue; + } else if (setupResult == SetupInstanceResults::SelectAnother) { + InstanceManager::singleton().clearCurrentInstance(); + return ReselectExitCode; + } else { + return 1; + } + } +} + +void MOApplication::purgeOldFiles() +{ + // remove the temporary backup directory in case we're restarting after an + // update + QString const backupDirectory = qApp->applicationDirPath() + "/update_backup"; + if (QDir(backupDirectory).exists()) { + shellDelete(QStringList(backupDirectory)); + } + + // cycle log file + removeOldFiles(qApp->property("dataPath").toString() + "/" + + QString::fromStdWString(AppConfig::logPath()), + "usvfs*.log", 5, QDir::Name); +} + +void MOApplication::resetForRestart() +{ + LogModel::instance().clear(); + ResetExitFlag(); + + // make sure the log file isn't locked in case MO was restarted and + // the previous instance gets deleted + log::getDefault().setFile({}); + + // clear instance and profile overrides + InstanceManager::singleton().clearOverrides(); + + m_core = {}; + m_plugins = {}; + m_nexus = {}; + m_settings = {}; + m_instance = {}; +} + +bool MOApplication::setStyleFile(const QString& styleName) +{ + // remove all files from watch + QStringList const currentWatch = m_styleWatcher.files(); + if (currentWatch.count() != 0) { + m_styleWatcher.removePaths(currentWatch); + } + // set new stylesheet or clear it + if (styleName.length() != 0) { + // Search for the stylesheet in multiple locations: + // 1. applicationDirPath()/stylesheets/ — bundled themes + // 2. instance baseDir/stylesheets/ — instance/portable themes (modlists) + // 3. fluorineDataDir()/stylesheets/ — user-installed custom themes + const QString ssSubdir = MOBase::ToQString(AppConfig::stylesheetsPath()); + QStringList searchDirs; + searchDirs << applicationDirPath() + "/" + ssSubdir; + if (m_instance) { + // Prefer baseDirectory() (populated after readFromIni), fall back to + // directory() which is always set by the constructor. + QString base = m_instance->baseDirectory(); + if (base.isEmpty()) + base = m_instance->directory(); + const QString instanceDir = base + "/" + ssSubdir; + if (!searchDirs.contains(instanceDir)) + searchDirs << instanceDir; + } + const QString userDir = fluorineDataDir() + "/stylesheets"; + if (!searchDirs.contains(userDir)) + searchDirs << userDir; + + QString resolved; + for (const auto& dir : searchDirs) { + QString const candidate = dir + "/" + styleName; + if (QFile::exists(candidate)) { + resolved = candidate; + break; + } + } + + if (!resolved.isEmpty()) { + m_styleWatcher.addPath(resolved); + updateStyle(resolved); + } else { + // Could be a built-in Qt style name (e.g. "Fusion") + updateStyle(styleName); + } + } else { + setStyle(new ProxyStyle(QStyleFactory::create(m_defaultStyle))); + setStyleSheet(""); + } + return true; +} + +bool MOApplication::notify(QObject* receiver, QEvent* event) +{ + try { + return QApplication::notify(receiver, event); + } catch (const std::filesystem::filesystem_error& fe) { + log::error("uncaught filesystem exception in handler (object {}, eventtype {}): {}", + receiver->objectName(), event->type(), fe.what()); + + // ENOTCONN = stale FUSE mount. Attempt recovery so MO2 can continue. + if (fe.code().value() == ENOTCONN) { + const auto& p1 = fe.path1(); + if (!p1.empty()) { + log::warn("ENOTCONN on '{}' — attempting stale mount cleanup", p1.string()); + FuseConnector::tryCleanupStaleMount(QString::fromStdString(p1.string())); + } + const auto& p2 = fe.path2(); + if (!p2.empty()) { + log::warn("ENOTCONN on '{}' — attempting stale mount cleanup", p2.string()); + FuseConnector::tryCleanupStaleMount(QString::fromStdString(p2.string())); + } + } + + reportError(tr("an error occurred: %1").arg(fe.what())); + return false; + } catch (const std::exception& e) { + log::error("uncaught exception in handler (object {}, eventtype {}): {}", + receiver->objectName(), event->type(), e.what()); + reportError(tr("an error occurred: %1").arg(e.what())); + return false; + } catch (...) { + log::error("uncaught non-std exception in handler (object {}, eventtype {})", + receiver->objectName(), event->type()); + reportError(tr("an error occurred")); + return false; + } +} + +namespace +{ +QStringList extractTopStyleSheetComments(QFile& stylesheet) +{ + if (!stylesheet.open(QFile::ReadOnly)) { + log::error("failed to open stylesheet file {}", stylesheet.fileName()); + return {}; + } + ON_BLOCK_EXIT([&stylesheet]() { + stylesheet.close(); + }); + + QStringList topComments; + + while (true) { + const auto byteLine = stylesheet.readLine(); + if (byteLine.isNull()) { + break; + } + + const auto line = QString(byteLine).trimmed(); + + // skip empty lines + if (line.isEmpty()) { + continue; + } + + // only handle single line comments + if (!line.startsWith("/*")) { + break; + } + + topComments.push_back(line.mid(2, line.size() - 4).trimmed()); + } + + return topComments; +} + +QString extractBaseStyleFromStyleSheet(QFile& stylesheet, const QString& defaultStyle) +{ + // read the first line of the files that are either empty or comments + // + const auto topLines = extractTopStyleSheetComments(stylesheet); + + const auto factoryStyles = QStyleFactory::keys(); + + QString style = defaultStyle; + + for (const auto& line : topLines) { + if (!line.startsWith("mo2-base-style")) { + continue; + } + + const auto parts = line.split(":"); + if (parts.size() != 2) { + log::warn("found invalid top-comment for mo2 in {}: {}", stylesheet.fileName(), + line); + continue; + } + + const auto tmpStyle = parts[1].trimmed(); + const auto index = factoryStyles.indexOf(tmpStyle, 0, Qt::CaseInsensitive); + if (index == -1) { + log::warn("base style '{}' from style '{}' not found", tmpStyle, + stylesheet.fileName(), line); + continue; + } + + style = factoryStyles[index]; + log::info("found base style '{}' for style '{}'", style, stylesheet.fileName()); + break; + } + + return style; +} + +} // namespace + +// Walk a directory and create lowercase symlinks for any file whose name +// contains uppercase letters, if the lowercase name doesn't already exist. +// QSS files authored on Windows often reference asset files with lowercase +// names but the actual files on disk use mixed case — on a case-sensitive +// filesystem Qt's url() resolver fails. Shimming lowercase symlinks lets the +// existing paths resolve without touching the original files. +static void createLowercaseStylesheetShims(const QString& dirPath) +{ + namespace fs = std::filesystem; + std::error_code ec; + if (!fs::exists(dirPath.toStdString(), ec) || + !fs::is_directory(dirPath.toStdString(), ec)) { + return; + } + for (auto it = fs::recursive_directory_iterator( + dirPath.toStdString(), + fs::directory_options::skip_permission_denied, ec); + !ec && it != fs::recursive_directory_iterator(); it.increment(ec)) { + const auto& entry = *it; + if (!entry.is_regular_file(ec)) continue; + + const std::string name = entry.path().filename().string(); + std::string lowerName = name; + std::transform(lowerName.begin(), lowerName.end(), lowerName.begin(), + [](unsigned char c) { return std::tolower(c); }); + if (lowerName == name) continue; // already lowercase + + const auto parent = entry.path().parent_path(); + const auto lowerPath = parent / lowerName; + if (fs::exists(lowerPath, ec)) continue; + + std::error_code lec; + fs::create_symlink(name, lowerPath, lec); + if (lec) { + log::debug("stylesheet shim: failed to link '{}' -> '{}': {}", + lowerPath.string(), name, lec.message()); + } + } +} + +void MOApplication::updateStyle(const QString& fileName) +{ + if (QStyleFactory::keys().contains(fileName)) { + setStyleSheet(""); + setStyle(new ProxyStyle(QStyleFactory::create(fileName))); + } else { + QFile stylesheet(fileName); + if (stylesheet.exists()) { + // Pre-create lowercase shims so url(foo.svg) in the QSS resolves even + // when the on-disk file is Foo.svg. + createLowercaseStylesheetShims(QFileInfo(fileName).absolutePath()); + setStyle(new ProxyStyle(QStyleFactory::create( + extractBaseStyleFromStyleSheet(stylesheet, m_defaultStyle)))); + setStyleSheet(QString("file:///%1").arg(fileName)); + } else { + log::warn("invalid stylesheet: {}", fileName); + } + } +} + +MOSplash::MOSplash(const Settings& settings, const QString& dataPath, + const MOBase::IPluginGame* game) +{ + const auto splashPath = getSplashPath(settings, dataPath, game); + if (splashPath.isEmpty()) { + return; + } + + QPixmap const image(splashPath); + if (image.isNull()) { + log::error("failed to load splash from {}", splashPath); + return; + } + + ss_.reset(new QSplashScreen(image)); + settings.geometry().centerOnMainWindowMonitor(ss_.get()); + + ss_->show(); + ss_->activateWindow(); +} + +void MOSplash::close() +{ + if (ss_) { + // don't pass mainwindow as it just waits half a second for it + // instead of proceding + ss_->finish(nullptr); + } +} + +QString MOSplash::getSplashPath(const Settings& settings, const QString& dataPath, + const MOBase::IPluginGame* game) +{ + if (!settings.useSplash()) { + return {}; + } + + // try splash from instance directory + const QString splashPath = dataPath + "/splash.png"; + if (QFile::exists(dataPath + "/splash.png")) { + QImage const image(splashPath); + if (!image.isNull()) { + return splashPath; + } + } + + // try splash from plugin + QString pluginSplash = QString(":/%1/splash").arg(game->gameShortName()); + if (QFile::exists(pluginSplash)) { + QImage const image(pluginSplash); + if (!image.isNull()) { + image.save(splashPath); + return pluginSplash; + } + } + + // try default splash from resource + QString defaultSplash = ":/MO/gui/splash"; + if (QFile::exists(defaultSplash)) { + QImage const image(defaultSplash); + if (!image.isNull()) { + return defaultSplash; + } + } + + return splashPath; +} diff --git a/src/src/modelutils.cpp b/src/src/modelutils.cpp index 9bbc398..e2f8432 100644 --- a/src/src/modelutils.cpp +++ b/src/src/modelutils.cpp @@ -1,95 +1,96 @@ -#include "modelutils.h" - -#include - -namespace MOShared -{ - -QModelIndexList flatIndex(const QAbstractItemModel* model, int column, - const QModelIndex& parent) -{ - QModelIndexList index; - for (std::size_t i = 0; i < model->rowCount(parent); ++i) { - index.append(model->index(i, column, parent)); - index.append(flatIndex(model, column, index.back())); - } - return index; -} - -static QModelIndexList visibleIndexImpl(QTreeView* view, int column, - const QModelIndex& parent) -{ - if (parent.isValid() && !view->isExpanded(parent)) { - return {}; - } - - auto* model = view->model(); - QModelIndexList index; - for (std::size_t i = 0; i < model->rowCount(parent); ++i) { - index.append(model->index(i, column, parent)); - index.append(visibleIndexImpl(view, column, index.back())); - } - return index; -} - -QModelIndexList visibleIndex(QTreeView* view, int column) -{ - return visibleIndexImpl(view, column, QModelIndex()); -} - -QModelIndex indexModelToView(const QModelIndex& index, const QAbstractItemView* view) -{ - // we need to stack the proxy - std::vector proxies; - { - auto* currentModel = view->model(); - while (auto* proxy = qobject_cast(currentModel)) { - proxies.push_back(proxy); - currentModel = proxy->sourceModel(); - } - } - - if (proxies.empty() || proxies.back()->sourceModel() != index.model()) { - return {}; - } - - auto qindex = index; - for (auto rit = proxies.rbegin(); rit != proxies.rend(); ++rit) { - qindex = (*rit)->mapFromSource(qindex); - } - - return qindex; -} - -QModelIndexList indexModelToView(const QModelIndexList& index, - const QAbstractItemView* view) -{ - QModelIndexList result; - for (auto& idx : index) { - result.append(indexModelToView(idx, view)); - } - return result; -} - -QModelIndex indexViewToModel(const QModelIndex& index, const QAbstractItemModel* model) -{ - if (index.model() == model) { - return index; - } else if (auto* proxy = qobject_cast(index.model())) { - return indexViewToModel(proxy->mapToSource(index), model); - } else { - return {}; - } -} - -QModelIndexList indexViewToModel(const QModelIndexList& index, - const QAbstractItemModel* model) -{ - QModelIndexList result; - for (auto& idx : index) { - result.append(indexViewToModel(idx, model)); - } - return result; -} - -} // namespace MOShared +#include "modelutils.h" + +#include +#include + +namespace MOShared +{ + +QModelIndexList flatIndex(const QAbstractItemModel* model, int column, + const QModelIndex& parent) +{ + QModelIndexList index; + for (std::size_t i = 0; std::cmp_less(i , model->rowCount(parent)); ++i) { + index.append(model->index(i, column, parent)); + index.append(flatIndex(model, column, index.back())); + } + return index; +} + +static QModelIndexList visibleIndexImpl(QTreeView* view, int column, + const QModelIndex& parent) +{ + if (parent.isValid() && !view->isExpanded(parent)) { + return {}; + } + + auto* model = view->model(); + QModelIndexList index; + for (std::size_t i = 0; std::cmp_less(i , model->rowCount(parent)); ++i) { + index.append(model->index(i, column, parent)); + index.append(visibleIndexImpl(view, column, index.back())); + } + return index; +} + +QModelIndexList visibleIndex(QTreeView* view, int column) +{ + return visibleIndexImpl(view, column, QModelIndex()); +} + +QModelIndex indexModelToView(const QModelIndex& index, const QAbstractItemView* view) +{ + // we need to stack the proxy + std::vector proxies; + { + auto* currentModel = view->model(); + while (auto* proxy = qobject_cast(currentModel)) { + proxies.push_back(proxy); + currentModel = proxy->sourceModel(); + } + } + + if (proxies.empty() || proxies.back()->sourceModel() != index.model()) { + return {}; + } + + auto qindex = index; + for (auto rit = proxies.rbegin(); rit != proxies.rend(); ++rit) { + qindex = (*rit)->mapFromSource(qindex); + } + + return qindex; +} + +QModelIndexList indexModelToView(const QModelIndexList& index, + const QAbstractItemView* view) +{ + QModelIndexList result; + for (const auto& idx : index) { + result.append(indexModelToView(idx, view)); + } + return result; +} + +QModelIndex indexViewToModel(const QModelIndex& index, const QAbstractItemModel* model) +{ + if (index.model() == model) { + return index; + } else if (const auto* proxy = qobject_cast(index.model())) { + return indexViewToModel(proxy->mapToSource(index), model); + } else { + return {}; + } +} + +QModelIndexList indexViewToModel(const QModelIndexList& index, + const QAbstractItemModel* model) +{ + QModelIndexList result; + for (const auto& idx : index) { + result.append(indexViewToModel(idx, model)); + } + return result; +} + +} // namespace MOShared diff --git a/src/src/modinfo.cpp b/src/src/modinfo.cpp index 75e4487..647f6c9 100644 --- a/src/src/modinfo.cpp +++ b/src/src/modinfo.cpp @@ -1,588 +1,586 @@ -/* -Copyright (C) 2012 Sebastian Herbord. All rights reserved. - -This file is part of Mod Organizer. - -Mod Organizer is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -Mod Organizer is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with Mod Organizer. If not, see . -*/ - -#include "modinfo.h" - -#include "modinfobackup.h" -#include "modinfoforeign.h" -#include "modinfooverwrite.h" -#include "modinforegular.h" -#include "modinfoseparator.h" - -#include "categories.h" -#include "modinfodialog.h" -#include "modlist.h" -#include "organizercore.h" -#include "overwriteinfodialog.h" -#include "thread_utils.h" -#include "versioninfo.h" - -#include "shared/appconfig.h" -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include - -using namespace MOBase; -using namespace MOShared; - -const std::set ModInfo::s_EmptySet; -std::vector ModInfo::s_Collection; -ModInfo::Ptr ModInfo::s_Overwrite; -std::map ModInfo::s_ModsByName; -std::map, std::vector> ModInfo::s_ModsByModID; -int ModInfo::s_NextID; -QRecursiveMutex ModInfo::s_Mutex; - -QString ModInfo::s_HiddenExt(".mohidden"); - -bool ModInfo::ByName(const ModInfo::Ptr& LHS, const ModInfo::Ptr& RHS) -{ - return QString::compare(LHS->name(), RHS->name(), Qt::CaseInsensitive) < 0; -} - -bool ModInfo::isSeparatorName(const QString& name) -{ - static QRegularExpression const separatorExp( - QRegularExpression::anchoredPattern(".*_separator")); - return separatorExp.match(name).hasMatch(); -} - -bool ModInfo::isBackupName(const QString& name) -{ - static QRegularExpression const backupExp( - QRegularExpression::anchoredPattern(".*backup[0-9]*")); - return backupExp.match(name).hasMatch(); -} - -bool ModInfo::isRegularName(const QString& name) -{ - return !isSeparatorName(name) && !isBackupName(name); -} - -ModInfo::Ptr ModInfo::createFrom(const QDir& dir, OrganizerCore& core) -{ - QMutexLocker const locker(&s_Mutex); - ModInfo::Ptr result; - - if (isBackupName(dir.dirName())) { - result = ModInfo::Ptr(new ModInfoBackup(dir, core)); - } else if (isSeparatorName(dir.dirName())) { - result = Ptr(new ModInfoSeparator(dir, core)); - } else { - result = ModInfo::Ptr(new ModInfoRegular(dir, core)); - } - result->m_Index = s_Collection.size(); - s_Collection.push_back(result); - return result; -} - -ModInfo::Ptr ModInfo::createFromPlugin(const QString& modName, const QString& espName, - const QStringList& bsaNames, - ModInfo::EModType modType, OrganizerCore& core) -{ - QMutexLocker const locker(&s_Mutex); - ModInfo::Ptr result = - ModInfo::Ptr(new ModInfoForeign(modName, espName, bsaNames, modType, core)); - result->m_Index = s_Collection.size(); - s_Collection.push_back(result); - return result; -} - -ModInfo::Ptr ModInfo::createFromOverwrite(OrganizerCore& core) -{ - QMutexLocker const locker(&s_Mutex); - ModInfo::Ptr overwrite = ModInfo::Ptr(new ModInfoOverwrite(core)); - overwrite->m_Index = s_Collection.size(); - s_Collection.push_back(overwrite); - return overwrite; -} - -unsigned int ModInfo::getNumMods() -{ - QMutexLocker const locker(&s_Mutex); - return static_cast(s_Collection.size()); -} - -ModInfo::Ptr ModInfo::getByIndex(unsigned int index) -{ - QMutexLocker const locker(&s_Mutex); - - if (index >= s_Collection.size() && index != ULONG_MAX) { - throw MyException(tr("invalid mod index: %1").arg(index)); - } - if (index == ULONG_MAX) - return s_Collection[ModInfo::getIndex("Overwrite")]; - return s_Collection[index]; -} - -std::vector ModInfo::getByModID(QString game, int modID) -{ - QMutexLocker const locker(&s_Mutex); - - std::vector match; - for (auto iter : s_ModsByModID) { - if (iter.first.second == modID) { - if (iter.first.first.compare(game, Qt::CaseInsensitive) == 0) { - match.insert(match.end(), iter.second.begin(), iter.second.end()); - } - } - } - if (match.empty()) { - return {}; - } - - std::vector result; - for (auto iter : match) { - result.push_back(getByIndex(iter)); - } - - return result; -} - -ModInfo::Ptr ModInfo::getByName(const QString& name) -{ - QMutexLocker const locker(&s_Mutex); - - return s_Collection[ModInfo::getIndex(name)]; -} - -bool ModInfo::removeMod(unsigned int index) -{ - QMutexLocker const locker(&s_Mutex); - - if (index >= s_Collection.size()) { - throw Exception(tr("remove: invalid mod index %1").arg(index)); - } - - ModInfo::Ptr const modInfo = s_Collection[index]; - - // remove the actual mod (this is the most likely to fail so we do this first) - if (modInfo->isRegular()) { - if (!shellDelete(QStringList(modInfo->absolutePath()), true)) { - reportError( - tr("remove: failed to delete mod '%1' directory").arg(modInfo->name())); - return false; - } - } - - // update the indices - s_ModsByName.erase(s_ModsByName.find(modInfo->name())); - - auto iter = s_ModsByModID.find( - std::pair(modInfo->gameName(), modInfo->nexusId())); - if (iter != s_ModsByModID.end()) { - std::vector indices = iter->second; - indices.erase(std::remove(indices.begin(), indices.end(), index), indices.end()); - s_ModsByModID[std::pair(modInfo->gameName(), modInfo->nexusId())] = - indices; - } - - // finally, remove the mod from the collection - s_Collection.erase(s_Collection.begin() + index); - - // and update the indices - updateIndices(); - return true; -} - -unsigned int ModInfo::getIndex(const QString& name) -{ - QMutexLocker const locker(&s_Mutex); - - std::map::iterator const iter = s_ModsByName.find(name); - if (iter == s_ModsByName.end()) { - return UINT_MAX; - } - - return iter->second; -} - -unsigned int ModInfo::findMod(const boost::function& filter) -{ - for (unsigned int i = 0U; i < s_Collection.size(); ++i) { - if (filter(s_Collection[i])) { - return i; - } - } - return UINT_MAX; -} - -void ModInfo::updateFromDisc(const QString& modsDirectory, OrganizerCore& core, - bool displayForeign, std::size_t refreshThreadCount) -{ - TimeThis const tt("ModInfo::updateFromDisc()"); - - QMutexLocker const lock(&s_Mutex); - s_Collection.clear(); - s_NextID = 0; - s_Overwrite = nullptr; - - { // list all directories in the mod directory and make a mod out of each - const QString cleanModsDir = QDir::fromNativeSeparators(modsDirectory); - QDir mods(cleanModsDir); - if (!mods.exists()) { - log::error("mods directory does not exist: '{}'", cleanModsDir); - } - mods.setFilter(QDir::Dirs | QDir::NoDotAndDotDot); - QDirIterator modIter(mods); - std::size_t managedCount = 0; - while (modIter.hasNext()) { - createFrom(QDir(modIter.next()), core); - ++managedCount; - } - log::info("found {} managed mod directories in '{}'", managedCount, cleanModsDir); - if (managedCount == 0 && mods.exists()) { - log::warn("mods directory exists but contains no subdirectories; " - "check path and permissions"); - } - } - - auto* game = core.managedGame(); - auto& features = core.pluginContainer().gameFeatures(); - auto unmanaged = features.gameFeature(); - if (unmanaged != nullptr) { - for (const QString& modName : unmanaged->mods(!displayForeign)) { - ModInfo::EModType const modType = - game->DLCPlugins().contains(unmanaged->referenceFile(modName).fileName(), - Qt::CaseInsensitive) - ? ModInfo::EModType::MOD_DLC - : (game->CCPlugins().contains( - unmanaged->referenceFile(modName).fileName(), Qt::CaseInsensitive) - ? ModInfo::EModType::MOD_CC - : ModInfo::EModType::MOD_DEFAULT); - - createFromPlugin(unmanaged->displayName(modName), - unmanaged->referenceFile(modName).absoluteFilePath(), - unmanaged->secondaryFiles(modName), modType, core); - } - } - - s_Overwrite = createFromOverwrite(core); - - std::sort(s_Collection.begin(), s_Collection.end(), ModInfo::ByName); - - parallelMap(std::begin(s_Collection), std::end(s_Collection), &ModInfo::prefetch, - refreshThreadCount); - - updateIndices(); -} - -void ModInfo::updateIndices() -{ - s_ModsByName.clear(); - s_ModsByModID.clear(); - - for (unsigned int i = 0; i < s_Collection.size(); ++i) { - QString const modName = s_Collection[i]->internalName(); - QString const game = s_Collection[i]->gameName(); - int const modID = s_Collection[i]->nexusId(); - s_Collection[i]->m_Index = i; - s_ModsByName[modName] = i; - s_ModsByModID[std::pair(game, modID)].push_back(i); - } -} - -ModInfo::ModInfo(OrganizerCore& core) : m_Core(core) {} - -bool ModInfo::checkAllForUpdate(PluginContainer* pluginContainer, QObject* receiver) -{ - bool updatesAvailable = true; - - QDateTime earliest = QDateTime::currentDateTimeUtc(); - QDateTime latest = QDateTime::fromMSecsSinceEpoch(0); - std::set games; - for (const auto& mod : s_Collection) { - if (mod->canBeUpdated()) { - if (mod->getLastNexusUpdate() < earliest) - earliest = mod->getLastNexusUpdate(); - if (mod->getLastNexusUpdate() > latest) - latest = mod->getLastNexusUpdate(); - games.insert(mod->gameName().toLower()); - } - } - - // Detect invalid source games - for (auto itr = games.begin(); itr != games.end();) { - auto gamePlugins = pluginContainer->plugins(); - IPluginGame* gamePlugin = qApp->property("managed_game").value(); - for (auto plugin : gamePlugins) { - if (plugin != nullptr && - plugin->gameShortName().compare(*itr, Qt::CaseInsensitive) == 0) { - gamePlugin = plugin; - break; - } - } - if (gamePlugin != nullptr && gamePlugin->gameNexusName().isEmpty()) { - log::warn("{}", tr("The update check has found a mod with a Nexus ID and source " - "game of %1, but this game is not a valid Nexus source.") - .arg(gamePlugin->gameName())); - itr = games.erase(itr); - } else { - ++itr; - } - } - - if (latest < QDateTime::currentDateTimeUtc().addMonths(-1)) { - std::set> organizedGames; - for (const auto& mod : s_Collection) { - if (mod->canBeUpdated() && - mod->getLastNexusUpdate() < QDateTime::currentDateTimeUtc().addMonths(-1)) { - organizedGames.insert( - std::make_pair(mod->gameName().toLower(), mod->nexusId())); - } - } - - if (organizedGames.empty()) { - log::warn("{}", - tr("All of your mods have been checked recently. We restrict update " - "checks to help preserve your available API requests.")); - updatesAvailable = false; - } else { - log::info("{}", tr("You have mods that haven't been checked within the last " - "month using the new API. These mods must be checked before " - "we can use the bulk update API. " - "This will consume significantly more API requests than " - "usual. You will need to rerun the update check once complete " - "in order to parse the remaining mods.")); - } - - for (const auto& game : organizedGames) - NexusInterface::instance().requestUpdates(game.second, receiver, QVariant(), - game.first, QString()); - } else if (earliest < QDateTime::currentDateTimeUtc().addMonths(-1)) { - for (const auto& gameName : games) - NexusInterface::instance().requestUpdateInfo(gameName, - NexusInterface::UpdatePeriod::MONTH, - receiver, QVariant(true), QString()); - } else if (earliest < QDateTime::currentDateTimeUtc().addDays(-7)) { - for (const auto& gameName : games) - NexusInterface::instance().requestUpdateInfo( - gameName, NexusInterface::UpdatePeriod::MONTH, receiver, QVariant(false), - QString()); - } else if (earliest < QDateTime::currentDateTimeUtc().addDays(-1)) { - for (const auto& gameName : games) - NexusInterface::instance().requestUpdateInfo( - gameName, NexusInterface::UpdatePeriod::WEEK, receiver, QVariant(false), - QString()); - } else { - for (const auto& gameName : games) - NexusInterface::instance().requestUpdateInfo( - gameName, NexusInterface::UpdatePeriod::DAY, receiver, QVariant(false), - QString()); - } - - return updatesAvailable; -} - -std::set> ModInfo::filteredMods(QString gameName, - QVariantList updateData, - bool addOldMods, - bool markUpdated) -{ - std::set> finalMods; - for (const QVariant& result : updateData) { - QVariantMap const update = result.toMap(); - std::copy_if(s_Collection.begin(), s_Collection.end(), - std::inserter(finalMods, finalMods.end()), - [=](QSharedPointer info) -> bool { - if (info->nexusId() == update["mod_id"].toInt() && - info->gameName().compare(gameName, Qt::CaseInsensitive) == 0) - if (info->getLastNexusUpdate().addSecs(-3600) < - QDateTime::fromSecsSinceEpoch( - update["latest_file_update"].toInt(), QTimeZone::UTC)) - return true; - return false; - }); - } - - if (addOldMods) - for (const auto& mod : s_Collection) - if (mod->getLastNexusUpdate() < QDateTime::currentDateTimeUtc().addMonths(-1) && - mod->gameName().compare(gameName, Qt::CaseInsensitive) == 0) - finalMods.insert(mod); - - if (markUpdated) { - std::set> updates; - std::copy_if(s_Collection.begin(), s_Collection.end(), - std::inserter(updates, updates.end()), - [=](QSharedPointer info) -> bool { - if (info->gameName().compare(gameName, Qt::CaseInsensitive) == 0 && - info->canBeUpdated()) - return true; - return false; - }); - std::set> diff; - std::set_difference(updates.begin(), updates.end(), finalMods.begin(), - finalMods.end(), std::inserter(diff, diff.end())); - for (const auto& skipped : diff) { - skipped->setLastNexusUpdate(QDateTime::currentDateTimeUtc()); - } - } - return finalMods; -} - -void ModInfo::manualUpdateCheck(QObject* receiver, std::multimap IDs) -{ - std::vector> mods; - std::set> organizedGames; - - for (const auto& ID : IDs) { - for (const auto& matchedMod : getByModID(ID.first, ID.second)) { - bool alreadyMatched = false; - for (const auto& mod : mods) { - if (mod == matchedMod) { - alreadyMatched = true; - break; - } - } - if (!alreadyMatched) - mods.push_back(matchedMod); - } - } - mods.erase(std::remove_if(mods.begin(), mods.end(), - [](ModInfo::Ptr mod) -> bool { - return mod->nexusId() <= 0; - }), - mods.end()); - for (const auto& mod : mods) { - mod->setLastNexusUpdate(QDateTime()); - } - - std::sort(mods.begin(), mods.end(), - [](QSharedPointer a, QSharedPointer b) -> bool { - return a->getLastNexusUpdate() < b->getLastNexusUpdate(); - }); - - if (!mods.empty()) { - log::info("Checking updates for {} mods...", mods.size()); - - for (const auto& mod : mods) { - organizedGames.insert( - std::make_pair(mod->gameName().toLower(), mod->nexusId())); - } - - for (const auto& game : organizedGames) { - NexusInterface::instance().requestUpdates(game.second, receiver, QVariant(), - game.first, QString()); - } - } else { - log::info("None of the selected mods can be updated."); - } -} - -void ModInfo::setVersion(const VersionInfo& version) -{ - m_Version = version; -} - -void ModInfo::setPluginSelected(const bool& isSelected) -{ - m_PluginSelected = isSelected; -} - -void ModInfo::addCategory(const QString& categoryName) -{ - int id = CategoryFactory::instance().getCategoryID(categoryName); - if (id == -1) { - id = CategoryFactory::instance().addCategory( - categoryName, std::vector(), 0); - } - setCategory(id, true); -} - -bool ModInfo::removeCategory(const QString& categoryName) -{ - int const id = CategoryFactory::instance().getCategoryID(categoryName); - if (id == -1) { - return false; - } - if (!categorySet(id)) { - return false; - } - setCategory(id, false); - return true; -} - -QStringList ModInfo::categories() const -{ - QStringList result; - - CategoryFactory const& catFac = CategoryFactory::instance(); - for (int const id : m_Categories) { - result.append(catFac.getCategoryName(catFac.getCategoryIndex(id))); - } - - return result; -} - -bool ModInfo::hasFlag(ModInfo::EFlag flag) const -{ - std::vector flags = getFlags(); - return std::find(flags.begin(), flags.end(), flag) != flags.end(); -} - -bool ModInfo::hasAnyOfTheseFlags(std::vector flags) const -{ - std::vector const modFlags = getFlags(); - for (auto modFlag : modFlags) { - for (auto flag : flags) { - if (modFlag == flag) { - return true; - } - } - } - return false; -} - -bool ModInfo::categorySet(int categoryID) const -{ - for (std::set::const_iterator iter = m_Categories.begin(); - iter != m_Categories.end(); ++iter) { - if ((*iter == categoryID) || - (CategoryFactory::instance().isDescendantOf(*iter, categoryID))) { - return true; - } - } - - return false; -} - -QUrl ModInfo::parseCustomURL() const -{ - if (!hasCustomURL() || url().isEmpty()) { - return {}; - } - - const auto url = QUrl::fromUserInput(this->url()); - - if (!url.isValid()) { - log::error("mod '{}' has an invalid custom url '{}'", name(), this->url()); - return {}; - } - - return url; -} +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "modinfo.h" + +#include "modinfobackup.h" +#include "modinfoforeign.h" +#include "modinfooverwrite.h" +#include "modinforegular.h" +#include "modinfoseparator.h" + +#include "categories.h" +#include "modinfodialog.h" +#include "modlist.h" +#include "organizercore.h" +#include "overwriteinfodialog.h" +#include "thread_utils.h" +#include "versioninfo.h" + +#include "shared/appconfig.h" +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +using namespace MOBase; +using namespace MOShared; + +const std::set ModInfo::s_EmptySet; +std::vector ModInfo::s_Collection; +ModInfo::Ptr ModInfo::s_Overwrite; +std::map ModInfo::s_ModsByName; +std::map, std::vector> ModInfo::s_ModsByModID; +int ModInfo::s_NextID; +QRecursiveMutex ModInfo::s_Mutex; + +QString ModInfo::s_HiddenExt(".mohidden"); + +bool ModInfo::ByName(const ModInfo::Ptr& LHS, const ModInfo::Ptr& RHS) +{ + return QString::compare(LHS->name(), RHS->name(), Qt::CaseInsensitive) < 0; +} + +bool ModInfo::isSeparatorName(const QString& name) +{ + static QRegularExpression const separatorExp( + QRegularExpression::anchoredPattern(".*_separator")); + return separatorExp.match(name).hasMatch(); +} + +bool ModInfo::isBackupName(const QString& name) +{ + static QRegularExpression const backupExp( + QRegularExpression::anchoredPattern(".*backup[0-9]*")); + return backupExp.match(name).hasMatch(); +} + +bool ModInfo::isRegularName(const QString& name) +{ + return !isSeparatorName(name) && !isBackupName(name); +} + +ModInfo::Ptr ModInfo::createFrom(const QDir& dir, OrganizerCore& core) +{ + QMutexLocker const locker(&s_Mutex); + ModInfo::Ptr result; + + if (isBackupName(dir.dirName())) { + result = ModInfo::Ptr(new ModInfoBackup(dir, core)); + } else if (isSeparatorName(dir.dirName())) { + result = Ptr(new ModInfoSeparator(dir, core)); + } else { + result = ModInfo::Ptr(new ModInfoRegular(dir, core)); + } + result->m_Index = s_Collection.size(); + s_Collection.push_back(result); + return result; +} + +ModInfo::Ptr ModInfo::createFromPlugin(const QString& modName, const QString& espName, + const QStringList& bsaNames, + ModInfo::EModType modType, OrganizerCore& core) +{ + QMutexLocker const locker(&s_Mutex); + ModInfo::Ptr result = + ModInfo::Ptr(new ModInfoForeign(modName, espName, bsaNames, modType, core)); + result->m_Index = s_Collection.size(); + s_Collection.push_back(result); + return result; +} + +ModInfo::Ptr ModInfo::createFromOverwrite(OrganizerCore& core) +{ + QMutexLocker const locker(&s_Mutex); + ModInfo::Ptr overwrite = ModInfo::Ptr(new ModInfoOverwrite(core)); + overwrite->m_Index = s_Collection.size(); + s_Collection.push_back(overwrite); + return overwrite; +} + +unsigned int ModInfo::getNumMods() +{ + QMutexLocker const locker(&s_Mutex); + return static_cast(s_Collection.size()); +} + +ModInfo::Ptr ModInfo::getByIndex(unsigned int index) +{ + QMutexLocker const locker(&s_Mutex); + + if (index >= s_Collection.size() && index != ULONG_MAX) { + throw MyException(tr("invalid mod index: %1").arg(index)); + } + if (index == ULONG_MAX) + return s_Collection[ModInfo::getIndex("Overwrite")]; + return s_Collection[index]; +} + +std::vector ModInfo::getByModID(QString game, int modID) +{ + QMutexLocker const locker(&s_Mutex); + + std::vector match; + for (auto iter : s_ModsByModID) { + if (iter.first.second == modID) { + if (iter.first.first.compare(game, Qt::CaseInsensitive) == 0) { + match.insert(match.end(), iter.second.begin(), iter.second.end()); + } + } + } + if (match.empty()) { + return {}; + } + + std::vector result; + for (auto iter : match) { + result.push_back(getByIndex(iter)); + } + + return result; +} + +ModInfo::Ptr ModInfo::getByName(const QString& name) +{ + QMutexLocker const locker(&s_Mutex); + + return s_Collection[ModInfo::getIndex(name)]; +} + +bool ModInfo::removeMod(unsigned int index) +{ + QMutexLocker const locker(&s_Mutex); + + if (index >= s_Collection.size()) { + throw Exception(tr("remove: invalid mod index %1").arg(index)); + } + + ModInfo::Ptr const modInfo = s_Collection[index]; + + // remove the actual mod (this is the most likely to fail so we do this first) + if (modInfo->isRegular()) { + if (!shellDelete(QStringList(modInfo->absolutePath()), true)) { + reportError( + tr("remove: failed to delete mod '%1' directory").arg(modInfo->name())); + return false; + } + } + + // update the indices + s_ModsByName.erase(s_ModsByName.find(modInfo->name())); + + auto iter = s_ModsByModID.find( + std::pair(modInfo->gameName(), modInfo->nexusId())); + if (iter != s_ModsByModID.end()) { + std::vector indices = iter->second; + indices.erase(std::remove(indices.begin(), indices.end(), index), indices.end()); + s_ModsByModID[std::pair(modInfo->gameName(), modInfo->nexusId())] = + indices; + } + + // finally, remove the mod from the collection + s_Collection.erase(s_Collection.begin() + index); + + // and update the indices + updateIndices(); + return true; +} + +unsigned int ModInfo::getIndex(const QString& name) +{ + QMutexLocker const locker(&s_Mutex); + + std::map::iterator const iter = s_ModsByName.find(name); + if (iter == s_ModsByName.end()) { + return UINT_MAX; + } + + return iter->second; +} + +unsigned int ModInfo::findMod(const boost::function& filter) +{ + for (unsigned int i = 0U; i < s_Collection.size(); ++i) { + if (filter(s_Collection[i])) { + return i; + } + } + return UINT_MAX; +} + +void ModInfo::updateFromDisc(const QString& modsDirectory, OrganizerCore& core, + bool displayForeign, std::size_t refreshThreadCount) +{ + TimeThis const tt("ModInfo::updateFromDisc()"); + + QMutexLocker const lock(&s_Mutex); + s_Collection.clear(); + s_NextID = 0; + s_Overwrite = nullptr; + + { // list all directories in the mod directory and make a mod out of each + const QString cleanModsDir = QDir::fromNativeSeparators(modsDirectory); + QDir mods(cleanModsDir); + if (!mods.exists()) { + log::error("mods directory does not exist: '{}'", cleanModsDir); + } + mods.setFilter(QDir::Dirs | QDir::NoDotAndDotDot); + QDirIterator modIter(mods); + std::size_t managedCount = 0; + while (modIter.hasNext()) { + createFrom(QDir(modIter.next()), core); + ++managedCount; + } + log::info("found {} managed mod directories in '{}'", managedCount, cleanModsDir); + if (managedCount == 0 && mods.exists()) { + log::warn("mods directory exists but contains no subdirectories; " + "check path and permissions"); + } + } + + const auto* game = core.managedGame(); + auto& features = core.pluginContainer().gameFeatures(); + auto unmanaged = features.gameFeature(); + if (unmanaged != nullptr) { + for (const QString& modName : unmanaged->mods(!displayForeign)) { + ModInfo::EModType const modType = + game->DLCPlugins().contains(unmanaged->referenceFile(modName).fileName(), + Qt::CaseInsensitive) + ? ModInfo::EModType::MOD_DLC + : (game->CCPlugins().contains( + unmanaged->referenceFile(modName).fileName(), Qt::CaseInsensitive) + ? ModInfo::EModType::MOD_CC + : ModInfo::EModType::MOD_DEFAULT); + + createFromPlugin(unmanaged->displayName(modName), + unmanaged->referenceFile(modName).absoluteFilePath(), + unmanaged->secondaryFiles(modName), modType, core); + } + } + + s_Overwrite = createFromOverwrite(core); + + std::sort(s_Collection.begin(), s_Collection.end(), ModInfo::ByName); + + parallelMap(std::begin(s_Collection), std::end(s_Collection), &ModInfo::prefetch, + refreshThreadCount); + + updateIndices(); +} + +void ModInfo::updateIndices() +{ + s_ModsByName.clear(); + s_ModsByModID.clear(); + + for (unsigned int i = 0; i < s_Collection.size(); ++i) { + QString const modName = s_Collection[i]->internalName(); + QString const game = s_Collection[i]->gameName(); + int const modID = s_Collection[i]->nexusId(); + s_Collection[i]->m_Index = i; + s_ModsByName[modName] = i; + s_ModsByModID[std::pair(game, modID)].push_back(i); + } +} + +ModInfo::ModInfo(OrganizerCore& core) : m_Core(core) {} + +bool ModInfo::checkAllForUpdate(PluginContainer* pluginContainer, QObject* receiver) +{ + bool updatesAvailable = true; + + QDateTime earliest = QDateTime::currentDateTimeUtc(); + QDateTime latest = QDateTime::fromMSecsSinceEpoch(0); + std::set games; + for (const auto& mod : s_Collection) { + if (mod->canBeUpdated()) { + if (mod->getLastNexusUpdate() < earliest) + earliest = mod->getLastNexusUpdate(); + if (mod->getLastNexusUpdate() > latest) + latest = mod->getLastNexusUpdate(); + games.insert(mod->gameName().toLower()); + } + } + + // Detect invalid source games + for (auto itr = games.begin(); itr != games.end();) { + auto gamePlugins = pluginContainer->plugins(); + IPluginGame* gamePlugin = qApp->property("managed_game").value(); + for (auto *plugin : gamePlugins) { + if (plugin != nullptr && + plugin->gameShortName().compare(*itr, Qt::CaseInsensitive) == 0) { + gamePlugin = plugin; + break; + } + } + if (gamePlugin != nullptr && gamePlugin->gameNexusName().isEmpty()) { + log::warn("{}", tr("The update check has found a mod with a Nexus ID and source " + "game of %1, but this game is not a valid Nexus source.") + .arg(gamePlugin->gameName())); + itr = games.erase(itr); + } else { + ++itr; + } + } + + if (latest < QDateTime::currentDateTimeUtc().addMonths(-1)) { + std::set> organizedGames; + for (const auto& mod : s_Collection) { + if (mod->canBeUpdated() && + mod->getLastNexusUpdate() < QDateTime::currentDateTimeUtc().addMonths(-1)) { + organizedGames.insert( + std::make_pair(mod->gameName().toLower(), mod->nexusId())); + } + } + + if (organizedGames.empty()) { + log::warn("{}", + tr("All of your mods have been checked recently. We restrict update " + "checks to help preserve your available API requests.")); + updatesAvailable = false; + } else { + log::info("{}", tr("You have mods that haven't been checked within the last " + "month using the new API. These mods must be checked before " + "we can use the bulk update API. " + "This will consume significantly more API requests than " + "usual. You will need to rerun the update check once complete " + "in order to parse the remaining mods.")); + } + + for (const auto& game : organizedGames) + NexusInterface::instance().requestUpdates(game.second, receiver, QVariant(), + game.first, QString()); + } else if (earliest < QDateTime::currentDateTimeUtc().addMonths(-1)) { + for (const auto& gameName : games) + NexusInterface::instance().requestUpdateInfo(gameName, + NexusInterface::UpdatePeriod::MONTH, + receiver, QVariant(true), QString()); + } else if (earliest < QDateTime::currentDateTimeUtc().addDays(-7)) { + for (const auto& gameName : games) + NexusInterface::instance().requestUpdateInfo( + gameName, NexusInterface::UpdatePeriod::MONTH, receiver, QVariant(false), + QString()); + } else if (earliest < QDateTime::currentDateTimeUtc().addDays(-1)) { + for (const auto& gameName : games) + NexusInterface::instance().requestUpdateInfo( + gameName, NexusInterface::UpdatePeriod::WEEK, receiver, QVariant(false), + QString()); + } else { + for (const auto& gameName : games) + NexusInterface::instance().requestUpdateInfo( + gameName, NexusInterface::UpdatePeriod::DAY, receiver, QVariant(false), + QString()); + } + + return updatesAvailable; +} + +std::set> ModInfo::filteredMods(QString gameName, + QVariantList updateData, + bool addOldMods, + bool markUpdated) +{ + std::set> finalMods; + for (const QVariant& result : updateData) { + QVariantMap const update = result.toMap(); + std::copy_if(s_Collection.begin(), s_Collection.end(), + std::inserter(finalMods, finalMods.end()), + [=](QSharedPointer info) -> bool { + if (info->nexusId() == update["mod_id"].toInt() && + info->gameName().compare(gameName, Qt::CaseInsensitive) == 0) + if (info->getLastNexusUpdate().addSecs(-3600) < + QDateTime::fromSecsSinceEpoch( + update["latest_file_update"].toInt(), QTimeZone::UTC)) + return true; + return false; + }); + } + + if (addOldMods) + for (const auto& mod : s_Collection) + if (mod->getLastNexusUpdate() < QDateTime::currentDateTimeUtc().addMonths(-1) && + mod->gameName().compare(gameName, Qt::CaseInsensitive) == 0) + finalMods.insert(mod); + + if (markUpdated) { + std::set> updates; + std::copy_if(s_Collection.begin(), s_Collection.end(), + std::inserter(updates, updates.end()), + [=](QSharedPointer info) -> bool { + return info->gameName().compare(gameName, Qt::CaseInsensitive) == 0 && + info->canBeUpdated(); + }); + std::set> diff; + std::set_difference(updates.begin(), updates.end(), finalMods.begin(), + finalMods.end(), std::inserter(diff, diff.end())); + for (const auto& skipped : diff) { + skipped->setLastNexusUpdate(QDateTime::currentDateTimeUtc()); + } + } + return finalMods; +} + +void ModInfo::manualUpdateCheck(QObject* receiver, std::multimap IDs) +{ + std::vector> mods; + std::set> organizedGames; + + for (const auto& ID : IDs) { + for (const auto& matchedMod : getByModID(ID.first, ID.second)) { + bool alreadyMatched = false; + for (const auto& mod : mods) { + if (mod == matchedMod) { + alreadyMatched = true; + break; + } + } + if (!alreadyMatched) + mods.push_back(matchedMod); + } + } + mods.erase(std::remove_if(mods.begin(), mods.end(), + [](ModInfo::Ptr mod) -> bool { + return mod->nexusId() <= 0; + }), + mods.end()); + for (const auto& mod : mods) { + mod->setLastNexusUpdate(QDateTime()); + } + + std::sort(mods.begin(), mods.end(), + [](QSharedPointer a, QSharedPointer b) -> bool { + return a->getLastNexusUpdate() < b->getLastNexusUpdate(); + }); + + if (!mods.empty()) { + log::info("Checking updates for {} mods...", mods.size()); + + for (const auto& mod : mods) { + organizedGames.insert( + std::make_pair(mod->gameName().toLower(), mod->nexusId())); + } + + for (const auto& game : organizedGames) { + NexusInterface::instance().requestUpdates(game.second, receiver, QVariant(), + game.first, QString()); + } + } else { + log::info("None of the selected mods can be updated."); + } +} + +void ModInfo::setVersion(const VersionInfo& version) +{ + m_Version = version; +} + +void ModInfo::setPluginSelected(const bool& isSelected) +{ + m_PluginSelected = isSelected; +} + +void ModInfo::addCategory(const QString& categoryName) +{ + int id = CategoryFactory::instance().getCategoryID(categoryName); + if (id == -1) { + id = CategoryFactory::instance().addCategory( + categoryName, std::vector(), 0); + } + setCategory(id, true); +} + +bool ModInfo::removeCategory(const QString& categoryName) +{ + int const id = CategoryFactory::instance().getCategoryID(categoryName); + if (id == -1) { + return false; + } + if (!categorySet(id)) { + return false; + } + setCategory(id, false); + return true; +} + +QStringList ModInfo::categories() const +{ + QStringList result; + + CategoryFactory const& catFac = CategoryFactory::instance(); + for (int const id : m_Categories) { + result.append(catFac.getCategoryName(catFac.getCategoryIndex(id))); + } + + return result; +} + +bool ModInfo::hasFlag(ModInfo::EFlag flag) const +{ + std::vector flags = getFlags(); + return std::find(flags.begin(), flags.end(), flag) != flags.end(); +} + +bool ModInfo::hasAnyOfTheseFlags(std::vector flags) const +{ + std::vector const modFlags = getFlags(); + for (auto modFlag : modFlags) { + for (auto flag : flags) { + if (modFlag == flag) { + return true; + } + } + } + return false; +} + +bool ModInfo::categorySet(int categoryID) const +{ + for (std::set::const_iterator iter = m_Categories.begin(); + iter != m_Categories.end(); ++iter) { + if ((*iter == categoryID) || + (CategoryFactory::instance().isDescendantOf(*iter, categoryID))) { + return true; + } + } + + return false; +} + +QUrl ModInfo::parseCustomURL() const +{ + if (!hasCustomURL() || url().isEmpty()) { + return {}; + } + + const auto url = QUrl::fromUserInput(this->url()); + + if (!url.isValid()) { + log::error("mod '{}' has an invalid custom url '{}'", name(), this->url()); + return {}; + } + + return url; +} diff --git a/src/src/modinfodialog.cpp b/src/src/modinfodialog.cpp index 8cd1070..c883bc4 100644 --- a/src/src/modinfodialog.cpp +++ b/src/src/modinfodialog.cpp @@ -1,822 +1,822 @@ -/* -Copyright (C) 2012 Sebastian Herbord. All rights reserved. - -This file is part of Mod Organizer. - -Mod Organizer is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -Mod Organizer is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with Mod Organizer. If not, see . -*/ - -#include "modinfodialog.h" -#include "modinfodialogcategories.h" -#include "modinfodialogconflicts.h" -#include "modinfodialogesps.h" -#include "modinfodialogfiletree.h" -#include "modinfodialogimages.h" -#include "modinfodialognexus.h" -#include "modinfodialogtextfiles.h" -#include "modlistview.h" -#include "organizercore.h" -#include "plugincontainer.h" -#include "shared/directoryentry.h" -#include "shared/filesorigin.h" -#include "ui_modinfodialog.h" -#include - -using namespace MOBase; -using namespace MOShared; -namespace fs = std::filesystem; - -const int max_scan_for_context_menu = 50; - -bool canPreviewFile(const PluginContainer& pluginContainer, bool isArchive, - const QString& filename) -{ - const auto ext = QFileInfo(filename).suffix().toLower(); - return pluginContainer.previewGenerator().previewSupported(ext, isArchive); -} - -bool isExecutableFilename(const QString& filename) -{ - static const std::set exeExtensions = {"exe", "cmd", "bat"}; - - const auto ext = QFileInfo(filename).suffix().toLower(); - - return exeExtensions.contains(ext); -} - -bool canRunFile(bool isArchive, const QString& filename) -{ - // can run executables that are not archives - return !isArchive && isExecutableFilename(filename); -} - -bool canOpenFile(bool isArchive, const QString& filename) -{ - // can open non-executables that are not archives - return !isArchive && !isExecutableFilename(filename); -} - -bool canExploreFile(bool isArchive, const QString&) -{ - // can explore anything as long as it's not in an archive - return !isArchive; -} - -bool canHideFile(bool isArchive, const QString& filename) -{ - if (isArchive) { - // can't hide files from archives - return false; - } - - if (filename.endsWith(ModInfo::s_HiddenExt, Qt::CaseInsensitive)) { - // already hidden - return false; - } - - return true; -} - -bool canUnhideFile(bool isArchive, const QString& filename) -{ - if (isArchive) { - // can't unhide files from archives - return false; - } - - if (!filename.endsWith(ModInfo::s_HiddenExt, Qt::CaseInsensitive)) { - // already visible - return false; - } - - return true; -} - -FileRenamer::RenameResults hideFile(FileRenamer& renamer, const QString& oldName) -{ - const QString newName = oldName + ModInfo::s_HiddenExt; - return renamer.rename(oldName, newName); -} - -FileRenamer::RenameResults unhideFile(FileRenamer& renamer, const QString& oldName) -{ - QString const newName = oldName.left(oldName.length() - ModInfo::s_HiddenExt.length()); - return renamer.rename(oldName, newName); -} - -FileRenamer::RenameResults restoreHiddenFilesRecursive(FileRenamer& renamer, - const QString& targetDir) -{ - FileRenamer::RenameResults results = FileRenamer::RESULT_OK; - QDir const currentDir = targetDir; - for (const QString& hiddenFile : - currentDir.entryList((QStringList() << "*" + ModInfo::s_HiddenExt), - QDir::Dirs | QDir::Files | QDir::NoDotAndDotDot)) { - - QString const oldName = currentDir.absoluteFilePath(hiddenFile); - QString const newName = oldName.left(oldName.length() - ModInfo::s_HiddenExt.length()); - - auto partialResult = renamer.rename(oldName, newName); - - if (partialResult == FileRenamer::RESULT_CANCEL) { - return FileRenamer::RESULT_CANCEL; - } - - if (partialResult == FileRenamer::RESULT_SKIP) { - results = FileRenamer::RESULT_SKIP; - } - } - - for (const QString& dirName : currentDir.entryList(QDir::AllDirs | QDir::NoDotAndDotDot)) { - - const QString dirPath = currentDir.absoluteFilePath(dirName); - // recurse on childrend directories - auto partialResult = restoreHiddenFilesRecursive(renamer, dirPath); - - if (partialResult == FileRenamer::RESULT_CANCEL) { - return FileRenamer::RESULT_CANCEL; - } - - if (partialResult == FileRenamer::RESULT_SKIP) { - results = FileRenamer::RESULT_SKIP; - } - } - return results; -} - -ModInfoDialog::TabInfo::TabInfo(std::unique_ptr tab) - : tab(std::move(tab)) -{} - -bool ModInfoDialog::TabInfo::isVisible() const -{ - return (realPos != -1); -} - -ModInfoDialog::ModInfoDialog(OrganizerCore& core, PluginContainer& plugin, - ModInfo::Ptr mod, ModListView* modListView, - QWidget* parent) - : TutorableDialog("ModInfoDialog", parent), ui(new Ui::ModInfoDialog), m_core(core), - m_plugin(plugin), m_modListView(modListView) - -{ - ui->setupUi(this); - - { - auto* sc = new QShortcut(QKeySequence::Delete, this); - connect(sc, &QShortcut::activated, [&] { - onDeleteShortcut(); - }); - } - { - auto* sc = new QShortcut(QKeySequence::MoveToNextPage, this); - connect(sc, &QShortcut::activated, [&] { - onNextMod(); - }); - } - { - auto* sc = new QShortcut(QKeySequence::MoveToPreviousPage, this); - connect(sc, &QShortcut::activated, [&] { - onPreviousMod(); - }); - } - - setMod(mod); - createTabs(); - - connect(ui->tabWidget, &QTabWidget::currentChanged, [&] { - onTabSelectionChanged(); - }); - connect(ui->tabWidget->tabBar(), &QTabBar::tabMoved, [&] { - onTabMoved(); - }); - connect(ui->close, &QPushButton::clicked, [&] { - onCloseButton(); - }); - connect(ui->previousMod, &QPushButton::clicked, [&] { - onPreviousMod(); - }); - connect(ui->nextMod, &QPushButton::clicked, [&] { - onNextMod(); - }); -} - -ModInfoDialog::~ModInfoDialog() = default; - -template -std::unique_ptr createTab(ModInfoDialog& d, ModInfoTabIDs id) -{ - return std::make_unique(ModInfoDialogTabContext( - d.m_core, d.m_plugin, &d, d.ui.get(), id, d.m_mod, d.getOrigin())); -} - -void ModInfoDialog::createTabs() -{ - m_tabs.clear(); - - m_tabs.push_back(createTab(*this, ModInfoTabIDs::TextFiles)); - m_tabs.push_back(createTab(*this, ModInfoTabIDs::IniFiles)); - m_tabs.push_back(createTab(*this, ModInfoTabIDs::Images)); - m_tabs.push_back(createTab(*this, ModInfoTabIDs::Esps)); - m_tabs.push_back(createTab(*this, ModInfoTabIDs::Conflicts)); - m_tabs.push_back(createTab(*this, ModInfoTabIDs::Categories)); - m_tabs.push_back(createTab(*this, ModInfoTabIDs::Nexus)); - m_tabs.push_back(createTab(*this, ModInfoTabIDs::Notes)); - m_tabs.push_back(createTab(*this, ModInfoTabIDs::Filetree)); - - // check for tabs in the ui not having a corresponding tab in the list - int count = ui->tabWidget->count(); - if (count < 0 || count > static_cast(m_tabs.size())) { - log::error("mod info dialog has more tabs than expected"); - count = static_cast(m_tabs.size()); - } - - // for each tab in the widget; connects the widgets with the tab objects - // - for (int i = 0; i < count; ++i) { - auto& tabInfo = m_tabs[static_cast(i)]; - - // remembering tab information so tabs can be removed and re-added - tabInfo.widget = ui->tabWidget->widget(i); - tabInfo.caption = ui->tabWidget->tabText(i); - tabInfo.icon = ui->tabWidget->tabIcon(i); - - connect(tabInfo.tab.get(), &ModInfoDialogTab::originModified, [this](int originID) { - onOriginModified(originID); - }); - - connect(tabInfo.tab.get(), &ModInfoDialogTab::modOpen, [&](const QString& name) { - setMod(name); - update(); - }); - - connect(tabInfo.tab.get(), &ModInfoDialogTab::hasDataChanged, [&] { - setTabsColors(); - }); - - connect(tabInfo.tab.get(), &ModInfoDialogTab::wantsFocus, - [&, id = tabInfo.tab->tabID()] { - switchToTab(id); - }); - } -} - -int ModInfoDialog::exec() -{ - GeometrySaver const gs(Settings::instance(), this); - restoreState(); - - // whether to select the first tab; if the main window requested a specific - // tab, it is selected when encountered in update() - const auto noCustomTabRequested = (m_initialTab == ModInfoTabIDs::None); - const auto requestedTab = m_initialTab; - - update(true); - - if (noCustomTabRequested) { - m_core.settings().widgets().restoreIndex(ui->tabWidget); - } - - const int r = TutorableDialog::exec(); - saveState(); - - return r; -} - -void ModInfoDialog::setMod(ModInfo::Ptr mod) -{ - Q_ASSERT(mod); - m_mod = mod; - - // resetting the first activation flag so selecting tabs will trigger it - // again - for (auto& tabInfo : m_tabs) { - tabInfo.tab->resetFirstActivation(); - } -} - -void ModInfoDialog::setMod(const QString& name) -{ - unsigned int index = ModInfo::getIndex(name); - if (index == UINT_MAX) { - log::error("failed to resolve mod name {}", name); - return; - } - - auto mod = ModInfo::getByIndex(index); - if (!mod) { - log::error("mod by index {} is null", index); - return; - } - - setMod(mod); -} - -void ModInfoDialog::selectTab(ModInfoTabIDs id) -{ - if (!isVisible()) { - // can't select a tab if the dialog hasn't been properly updated yet - m_initialTab = id; - return; - } - - switchToTab(id); -} - -ModInfoDialog::TabInfo* ModInfoDialog::currentTab() -{ - const auto index = ui->tabWidget->currentIndex(); - if (index < 0) { - return nullptr; - } - - // looking for the actual tab at that position - for (auto& tabInfo : m_tabs) { - if (tabInfo.realPos == index) { - return &tabInfo; - } - } - - return nullptr; -} - -void ModInfoDialog::update(bool firstTime) -{ - // remembering the current selection, will be restored if the tab still - // exists - const int oldTab = ui->tabWidget->currentIndex(); - - setWindowTitle(m_mod->name()); - - // rebuilding the tab widget if needed depending on what tabs are valid for - // the current mod - setTabsVisibility(firstTime); - - // updating the data in all tabs - updateTabs(); - - // switching to the initial tab, if any - if (m_initialTab != ModInfoTabIDs::None) { - switchToTab(m_initialTab); - m_initialTab = ModInfoTabIDs::None; - } - - if (ui->tabWidget->currentIndex() == oldTab) { - if (auto* tabInfo = currentTab()) { - // activated() has to be fired manually because the tab index hasn't been - // changed - tabInfo->tab->activated(); - } else { - log::error("tab index {} not found", oldTab); - } - } -} - -void ModInfoDialog::setTabsVisibility(bool firstTime) -{ - // this flag is picked up by onTabSelectionChanged() to avoid triggering - // activation events while moving tabs around - QScopedValueRollback const arrangingTabs(m_arrangingTabs, true); - - // one bool per tab to indicate whether the tab should be visible - std::vector visibility(m_tabs.size()); - - bool changed = false; - - for (std::size_t i = 0; i < m_tabs.size(); ++i) { - const auto& tabInfo = m_tabs[i]; - - bool visible = true; - - // a tab is visible if it can handle the current mod - if (m_mod->hasFlag(ModInfo::FLAG_FOREIGN)) { - visible = tabInfo.tab->canHandleUnmanaged(); - } else if (m_mod->hasFlag(ModInfo::FLAG_SEPARATOR)) { - visible = tabInfo.tab->canHandleSeparators(); - } - - // if the visibility of this tab is changing, set changed to true because - // the tabs have to be rebuilt - const auto currentlyVisible = (ui->tabWidget->indexOf(tabInfo.widget) != -1); - if (visible != currentlyVisible) { - changed = true; - } - - visibility[i] = visible; - } - - // the tabs have to be rebuilt the first time the dialog is shown, or when - // the visibility of any tab has changed - if (!firstTime && !changed) { - return; - } - - // save the current order (if necessary) because some tabs will be removed and - // others added - if (!firstTime) { - // but don't do it the first time visibility is set because the tabs are - // in the default order, which will clobber the current settings - saveTabOrder(); - } - - // remember selection, if any - auto sel = ModInfoTabIDs::None; - if (const auto* tabInfo = currentTab()) { - sel = tabInfo->tab->tabID(); - } - - // removes all tabs and re-adds the visible ones - reAddTabs(visibility, sel); -} - -void ModInfoDialog::reAddTabs(const std::vector& visibility, ModInfoTabIDs sel) -{ - Q_ASSERT(visibility.size() == m_tabs.size()); - - // ordered tab names from settings - const auto orderedNames = m_core.settings().geometry().modInfoTabOrder(); - - // whether the tabs can be sorted - // - // if the object name of a tab widget is not found in orderedNames, the list - // cannot be sorted safely; if the list is empty, it's probably a first run - // and there's nothing to sort - bool canSort = !orderedNames.empty(); - - // gathering visible tabs - std::vector visibleTabs; - for (std::size_t i = 0; i < m_tabs.size(); ++i) { - if (!visibility[i]) { - // this tab is not visible, skip it - continue; - } - - // this tab is visible - visibleTabs.push_back(&m_tabs[i]); - - if (canSort) { - // make sure the widget object name is found in the list - const auto objectName = m_tabs[i].widget->objectName(); - auto itor = std::find(orderedNames.begin(), orderedNames.end(), objectName); - - if (itor == orderedNames.end()) { - // this shouldn't happen, it means there's a tab in the UI that's no - // in the list - log::error("can't sort tabs, '{}' not found", objectName); - canSort = false; - } - } - } - - // sorting tabs - if (canSort) { - std::sort(visibleTabs.begin(), visibleTabs.end(), [&](auto&& a, auto&& b) { - // looking the names in the ordered list - auto aItor = - std::find(orderedNames.begin(), orderedNames.end(), a->widget->objectName()); - - auto bItor = - std::find(orderedNames.begin(), orderedNames.end(), b->widget->objectName()); - - // this shouldn't happen, it was checked above - Q_ASSERT(aItor != orderedNames.end() && bItor != orderedNames.end()); - - return (aItor < bItor); - }); - } - - // removing all tabs - ui->tabWidget->clear(); - - // reset real positions - for (auto& tabInfo : m_tabs) { - tabInfo.realPos = -1; - } - - // add visible tabs - for (std::size_t i = 0; i < visibleTabs.size(); ++i) { - auto& tabInfo = *visibleTabs[i]; - - // remembering real position - tabInfo.realPos = static_cast(i); - - // adding tab - ui->tabWidget->addTab(tabInfo.widget, tabInfo.icon, tabInfo.caption); - - // selecting - if (tabInfo.tab->tabID() == sel) { - ui->tabWidget->setCurrentIndex(static_cast(i)); - } - } -} - -void ModInfoDialog::updateTabs(bool becauseOriginChanged) -{ - auto* origin = getOrigin(); - - // list of tabs that should be updated - std::vector interestedTabs; - - for (auto& tabInfo : m_tabs) { - // don't touch invisible tabs - if (!tabInfo.isVisible()) { - continue; - } - - // updateTabs() is also called from onOriginModified() to update all the - // tabs that depend on the origin; if updateTabs() is called because the - // origin changed, but the tab doesn't use origin files, it can be safely - // skipped - // - // this happens for tabs like notes and categories, which don't need to - // be updated when files change - if (becauseOriginChanged && !tabInfo.tab->usesOriginFiles()) { - continue; - } - - // this tab should be updated - interestedTabs.push_back(&tabInfo); - } - - for (auto* tabInfo : interestedTabs) { - // set the current mod - tabInfo->tab->setMod(m_mod, origin); - - // clear - tabInfo->tab->clear(); - } - - // feed all the files from the filesystem - feedFiles(interestedTabs); - - // call update() on all tabs - for (auto* tabInfo : interestedTabs) { - tabInfo->tab->update(); - } - - // update the text colours - setTabsColors(); -} - -void ModInfoDialog::feedFiles(std::vector& interestedTabs) -{ - const auto rootPath = - m_mod->absolutePath() + (m_core.managedGame()->modDataDirectory().isEmpty() - ? "" - : "/" + m_core.managedGame()->modDataDirectory()); - if (rootPath.isEmpty()) { - return; - } - - if (fs::exists(rootPath.toStdWString())) { - const fs::path fsPath(rootPath.toStdWString()); - - for (const auto& entry : fs::recursive_directory_iterator(fsPath)) { - if (!entry.is_regular_file()) { - // skip directories - continue; - } - - const auto filePath = QString::fromStdString(entry.path().native()); - - // for each tab - for (auto* tabInfo : interestedTabs) { - if (tabInfo->tab->feedFile(rootPath, filePath)) { - break; - } - } - } - } -} - -void ModInfoDialog::setTabsColors() -{ - const auto p = m_modListView->parentWidget()->palette(); - - for (const auto& tabInfo : m_tabs) { - if (!tabInfo.isVisible()) { - // don't bother with invisible tabs - continue; - } - - const QColor color = tabInfo.tab->hasData() - ? QColor::Invalid - : p.color(QPalette::Disabled, QPalette::WindowText); - - ui->tabWidget->tabBar()->setTabTextColor(tabInfo.realPos, color); - } -} - -void ModInfoDialog::switchToTab(ModInfoTabIDs id) -{ - // look a tab with the given id - for (const auto& tabInfo : m_tabs) { - if (tabInfo.tab->tabID() == id) { - // use realPos to select the proper tab in the widget - ui->tabWidget->setCurrentIndex(tabInfo.realPos); - return; - } - } - - // this could happen if the tab is not visible right now - log::debug("can't switch to tab ID {}, not available", static_cast(id)); -} - -MOShared::FilesOrigin* ModInfoDialog::getOrigin() -{ - auto* ds = m_core.directoryStructure(); - - if (!ds->originExists(m_mod->name().toStdWString())) { - return nullptr; - } - - auto* origin = &ds->getOriginByName(m_mod->name().toStdWString()); - if (origin->isDisabled()) { - return nullptr; - } - - return origin; -} - -void ModInfoDialog::saveState() const -{ - saveTabOrder(); - - // save state for each tab - for (const auto& tabInfo : m_tabs) { - tabInfo.tab->saveState(m_core.settings()); - } -} - -void ModInfoDialog::restoreState() -{ - // tab order is not restored here, it will be picked up if tabs have to be - // removed and re-added - - // restore state for each tab - for (const auto& tabInfo : m_tabs) { - tabInfo.tab->restoreState(m_core.settings()); - } -} - -void ModInfoDialog::saveTabOrder() const -{ - if (static_cast(m_tabs.size()) != ui->tabWidget->count()) { - // only save tab state when all tabs are visible - // - // if not all tabs are visible, it becomes very difficult to figure out in - // what order the user wants these tabs to be, so just avoid saving it - // completely - // - // this means that reordering tabs when not all tabs are visible is not - // saved, but it's better than breaking everything - return; - } - - QString names; - - for (int i = 0; i < ui->tabWidget->count(); ++i) { - if (!names.isEmpty()) { - names += " "; - } - - names += ui->tabWidget->widget(i)->objectName(); - } - - m_core.settings().geometry().setModInfoTabOrder(names); - // save last opened index - m_core.settings().widgets().saveIndex(ui->tabWidget); -} - -void ModInfoDialog::onOriginModified(int originID) -{ - // tell the main window the origin changed - emit originModified(originID); - - // update tabs that depend on the origin - updateTabs(true); -} - -void ModInfoDialog::onDeleteShortcut() -{ - // forward the request to the current tab - if (auto* tabInfo = currentTab()) { - tabInfo->tab->deleteRequested(); - } -} - -void ModInfoDialog::closeEvent(QCloseEvent* e) -{ - if (tryClose()) { - e->accept(); - } else { - e->ignore(); - } -} - -void ModInfoDialog::onCloseButton() -{ - if (tryClose()) { - close(); - } -} - -bool ModInfoDialog::tryClose() -{ - // cancel the close if any tab returns false; for example. this can happen if - // a tab has unsaved content, pops a confirmation dialog, and the user clicks - // cancel - - for (auto& tabInfo : m_tabs) { - if (!tabInfo.tab->canClose()) { - return false; - } - } - - return true; -} - -void ModInfoDialog::onTabSelectionChanged() -{ - if (m_arrangingTabs) { - // this can be fired while re-arranging tabs, which happens before mods - // are given to tabs, and might trigger first activation, which breaks all - // sorts of things - return; - } - - // this will call firstActivation() on the tab if needed - if (auto* tabInfo = currentTab()) { - tabInfo->tab->activated(); - } -} - -void ModInfoDialog::onTabMoved() -{ - // reset - for (auto& tabInfo : m_tabs) { - tabInfo.realPos = -1; - } - - // for each tab in the widget - for (int i = 0; i < ui->tabWidget->count(); ++i) { - const auto* w = ui->tabWidget->widget(i); - - bool found = false; - - // find the corresponding tab info - for (auto& tabInfo : m_tabs) { - if (tabInfo.widget == w) { - tabInfo.realPos = i; - found = true; - break; - } - } - - if (!found) { - log::error("unknown tab at index {}", i); - } - } -} - -void ModInfoDialog::onNextMod() -{ - auto index = m_modListView->nextMod(ModInfo::getIndex(m_mod->name())); - if (!index) { - return; - } - auto mod = ModInfo::getByIndex(*index); - if (!mod || mod == m_mod) { - return; - } - - setMod(mod); - update(); - - emit modChanged(*index); -} - -void ModInfoDialog::onPreviousMod() -{ - auto index = m_modListView->prevMod(ModInfo::getIndex(m_mod->name())); - if (!index) { - return; - } - auto mod = ModInfo::getByIndex(*index); - - setMod(mod); - update(); - - emit modChanged(*index); -} +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "modinfodialog.h" +#include "modinfodialogcategories.h" +#include "modinfodialogconflicts.h" +#include "modinfodialogesps.h" +#include "modinfodialogfiletree.h" +#include "modinfodialogimages.h" +#include "modinfodialognexus.h" +#include "modinfodialogtextfiles.h" +#include "modlistview.h" +#include "organizercore.h" +#include "plugincontainer.h" +#include "shared/directoryentry.h" +#include "shared/filesorigin.h" +#include "ui_modinfodialog.h" +#include +#include + +using namespace MOBase; +using namespace MOShared; +namespace fs = std::filesystem; + +const int max_scan_for_context_menu = 50; + +bool canPreviewFile(const PluginContainer& pluginContainer, bool isArchive, + const QString& filename) +{ + const auto ext = QFileInfo(filename).suffix().toLower(); + return pluginContainer.previewGenerator().previewSupported(ext, isArchive); +} + +bool isExecutableFilename(const QString& filename) +{ + static const std::set exeExtensions = {"exe", "cmd", "bat"}; + + const auto ext = QFileInfo(filename).suffix().toLower(); + + return exeExtensions.contains(ext); +} + +bool canRunFile(bool isArchive, const QString& filename) +{ + // can run executables that are not archives + return !isArchive && isExecutableFilename(filename); +} + +bool canOpenFile(bool isArchive, const QString& filename) +{ + // can open non-executables that are not archives + return !isArchive && !isExecutableFilename(filename); +} + +bool canExploreFile(bool isArchive, const QString&) +{ + // can explore anything as long as it's not in an archive + return !isArchive; +} + +bool canHideFile(bool isArchive, const QString& filename) +{ + if (isArchive) { + // can't hide files from archives + return false; + } + + if (filename.endsWith(ModInfo::s_HiddenExt, Qt::CaseInsensitive)) { + // already hidden + return false; + } + + return true; +} + +bool canUnhideFile(bool isArchive, const QString& filename) +{ + if (isArchive) { + // can't unhide files from archives + return false; + } + + if (!filename.endsWith(ModInfo::s_HiddenExt, Qt::CaseInsensitive)) { + // already visible + return false; + } + + return true; +} + +FileRenamer::RenameResults hideFile(FileRenamer& renamer, const QString& oldName) +{ + const QString newName = oldName + ModInfo::s_HiddenExt; + return renamer.rename(oldName, newName); +} + +FileRenamer::RenameResults unhideFile(FileRenamer& renamer, const QString& oldName) +{ + QString const newName = oldName.left(oldName.length() - ModInfo::s_HiddenExt.length()); + return renamer.rename(oldName, newName); +} + +FileRenamer::RenameResults restoreHiddenFilesRecursive(FileRenamer& renamer, + const QString& targetDir) +{ + FileRenamer::RenameResults results = FileRenamer::RESULT_OK; + QDir const currentDir = targetDir; + for (const QString& hiddenFile : + currentDir.entryList((QStringList() << "*" + ModInfo::s_HiddenExt), + QDir::Dirs | QDir::Files | QDir::NoDotAndDotDot)) { + + QString const oldName = currentDir.absoluteFilePath(hiddenFile); + QString const newName = oldName.left(oldName.length() - ModInfo::s_HiddenExt.length()); + + auto partialResult = renamer.rename(oldName, newName); + + if (partialResult == FileRenamer::RESULT_CANCEL) { + return FileRenamer::RESULT_CANCEL; + } + + if (partialResult == FileRenamer::RESULT_SKIP) { + results = FileRenamer::RESULT_SKIP; + } + } + + for (const QString& dirName : currentDir.entryList(QDir::AllDirs | QDir::NoDotAndDotDot)) { + + const QString dirPath = currentDir.absoluteFilePath(dirName); + // recurse on childrend directories + auto partialResult = restoreHiddenFilesRecursive(renamer, dirPath); + + if (partialResult == FileRenamer::RESULT_CANCEL) { + return FileRenamer::RESULT_CANCEL; + } + + if (partialResult == FileRenamer::RESULT_SKIP) { + results = FileRenamer::RESULT_SKIP; + } + } + return results; +} + +ModInfoDialog::TabInfo::TabInfo(std::unique_ptr tab) + : tab(std::move(tab)) +{} + +bool ModInfoDialog::TabInfo::isVisible() const +{ + return (realPos != -1); +} + +ModInfoDialog::ModInfoDialog(OrganizerCore& core, PluginContainer& plugin, + ModInfo::Ptr mod, ModListView* modListView, + QWidget* parent) + : TutorableDialog("ModInfoDialog", parent), ui(new Ui::ModInfoDialog), m_core(core), + m_plugin(plugin), m_modListView(modListView) + +{ + ui->setupUi(this); + + { + auto* sc = new QShortcut(QKeySequence::Delete, this); + connect(sc, &QShortcut::activated, [&] { + onDeleteShortcut(); + }); + } + { + auto* sc = new QShortcut(QKeySequence::MoveToNextPage, this); + connect(sc, &QShortcut::activated, [&] { + onNextMod(); + }); + } + { + auto* sc = new QShortcut(QKeySequence::MoveToPreviousPage, this); + connect(sc, &QShortcut::activated, [&] { + onPreviousMod(); + }); + } + + setMod(mod); + createTabs(); + + connect(ui->tabWidget, &QTabWidget::currentChanged, [&] { + onTabSelectionChanged(); + }); + connect(ui->tabWidget->tabBar(), &QTabBar::tabMoved, [&] { + onTabMoved(); + }); + connect(ui->close, &QPushButton::clicked, [&] { + onCloseButton(); + }); + connect(ui->previousMod, &QPushButton::clicked, [&] { + onPreviousMod(); + }); + connect(ui->nextMod, &QPushButton::clicked, [&] { + onNextMod(); + }); +} + +ModInfoDialog::~ModInfoDialog() = default; + +template +std::unique_ptr createTab(ModInfoDialog& d, ModInfoTabIDs id) +{ + return std::make_unique(ModInfoDialogTabContext( + d.m_core, d.m_plugin, &d, d.ui.get(), id, d.m_mod, d.getOrigin())); +} + +void ModInfoDialog::createTabs() +{ + m_tabs.clear(); + + m_tabs.emplace_back(createTab(*this, ModInfoTabIDs::TextFiles)); + m_tabs.emplace_back(createTab(*this, ModInfoTabIDs::IniFiles)); + m_tabs.emplace_back(createTab(*this, ModInfoTabIDs::Images)); + m_tabs.emplace_back(createTab(*this, ModInfoTabIDs::Esps)); + m_tabs.emplace_back(createTab(*this, ModInfoTabIDs::Conflicts)); + m_tabs.emplace_back(createTab(*this, ModInfoTabIDs::Categories)); + m_tabs.emplace_back(createTab(*this, ModInfoTabIDs::Nexus)); + m_tabs.emplace_back(createTab(*this, ModInfoTabIDs::Notes)); + m_tabs.emplace_back(createTab(*this, ModInfoTabIDs::Filetree)); + + // check for tabs in the ui not having a corresponding tab in the list + int count = ui->tabWidget->count(); + if (count < 0 || std::cmp_greater(count, m_tabs.size())) { + log::error("mod info dialog has more tabs than expected"); + count = static_cast(m_tabs.size()); + } + + // for each tab in the widget; connects the widgets with the tab objects + // + for (int i = 0; i < count; ++i) { + auto& tabInfo = m_tabs[static_cast(i)]; + + // remembering tab information so tabs can be removed and re-added + tabInfo.widget = ui->tabWidget->widget(i); + tabInfo.caption = ui->tabWidget->tabText(i); + tabInfo.icon = ui->tabWidget->tabIcon(i); + + connect(tabInfo.tab.get(), &ModInfoDialogTab::originModified, [this](int originID) { + onOriginModified(originID); + }); + + connect(tabInfo.tab.get(), &ModInfoDialogTab::modOpen, [&](const QString& name) { + setMod(name); + update(); + }); + + connect(tabInfo.tab.get(), &ModInfoDialogTab::hasDataChanged, [&] { + setTabsColors(); + }); + + connect(tabInfo.tab.get(), &ModInfoDialogTab::wantsFocus, + [&, id = tabInfo.tab->tabID()] { + switchToTab(id); + }); + } +} + +int ModInfoDialog::exec() +{ + GeometrySaver const gs(Settings::instance(), this); + restoreState(); + + // whether to select the first tab; if the main window requested a specific + // tab, it is selected when encountered in update() + const auto noCustomTabRequested = (m_initialTab == ModInfoTabIDs::None); + + update(true); + + if (noCustomTabRequested) { + m_core.settings().widgets().restoreIndex(ui->tabWidget); + } + + const int r = TutorableDialog::exec(); + saveState(); + + return r; +} + +void ModInfoDialog::setMod(ModInfo::Ptr mod) +{ + Q_ASSERT(mod); + m_mod = mod; + + // resetting the first activation flag so selecting tabs will trigger it + // again + for (auto& tabInfo : m_tabs) { + tabInfo.tab->resetFirstActivation(); + } +} + +void ModInfoDialog::setMod(const QString& name) +{ + unsigned int index = ModInfo::getIndex(name); + if (index == UINT_MAX) { + log::error("failed to resolve mod name {}", name); + return; + } + + auto mod = ModInfo::getByIndex(index); + if (!mod) { + log::error("mod by index {} is null", index); + return; + } + + setMod(mod); +} + +void ModInfoDialog::selectTab(ModInfoTabIDs id) +{ + if (!isVisible()) { + // can't select a tab if the dialog hasn't been properly updated yet + m_initialTab = id; + return; + } + + switchToTab(id); +} + +ModInfoDialog::TabInfo* ModInfoDialog::currentTab() +{ + const auto index = ui->tabWidget->currentIndex(); + if (index < 0) { + return nullptr; + } + + // looking for the actual tab at that position + for (auto& tabInfo : m_tabs) { + if (tabInfo.realPos == index) { + return &tabInfo; + } + } + + return nullptr; +} + +void ModInfoDialog::update(bool firstTime) +{ + // remembering the current selection, will be restored if the tab still + // exists + const int oldTab = ui->tabWidget->currentIndex(); + + setWindowTitle(m_mod->name()); + + // rebuilding the tab widget if needed depending on what tabs are valid for + // the current mod + setTabsVisibility(firstTime); + + // updating the data in all tabs + updateTabs(); + + // switching to the initial tab, if any + if (m_initialTab != ModInfoTabIDs::None) { + switchToTab(m_initialTab); + m_initialTab = ModInfoTabIDs::None; + } + + if (ui->tabWidget->currentIndex() == oldTab) { + if (auto* tabInfo = currentTab()) { + // activated() has to be fired manually because the tab index hasn't been + // changed + tabInfo->tab->activated(); + } else { + log::error("tab index {} not found", oldTab); + } + } +} + +void ModInfoDialog::setTabsVisibility(bool firstTime) +{ + // this flag is picked up by onTabSelectionChanged() to avoid triggering + // activation events while moving tabs around + QScopedValueRollback const arrangingTabs(m_arrangingTabs, true); + + // one bool per tab to indicate whether the tab should be visible + std::vector visibility(m_tabs.size()); + + bool changed = false; + + for (std::size_t i = 0; i < m_tabs.size(); ++i) { + const auto& tabInfo = m_tabs[i]; + + bool visible = true; + + // a tab is visible if it can handle the current mod + if (m_mod->hasFlag(ModInfo::FLAG_FOREIGN)) { + visible = tabInfo.tab->canHandleUnmanaged(); + } else if (m_mod->hasFlag(ModInfo::FLAG_SEPARATOR)) { + visible = tabInfo.tab->canHandleSeparators(); + } + + // if the visibility of this tab is changing, set changed to true because + // the tabs have to be rebuilt + const auto currentlyVisible = (ui->tabWidget->indexOf(tabInfo.widget) != -1); + if (visible != currentlyVisible) { + changed = true; + } + + visibility[i] = visible; + } + + // the tabs have to be rebuilt the first time the dialog is shown, or when + // the visibility of any tab has changed + if (!firstTime && !changed) { + return; + } + + // save the current order (if necessary) because some tabs will be removed and + // others added + if (!firstTime) { + // but don't do it the first time visibility is set because the tabs are + // in the default order, which will clobber the current settings + saveTabOrder(); + } + + // remember selection, if any + auto sel = ModInfoTabIDs::None; + if (const auto* tabInfo = currentTab()) { + sel = tabInfo->tab->tabID(); + } + + // removes all tabs and re-adds the visible ones + reAddTabs(visibility, sel); +} + +void ModInfoDialog::reAddTabs(const std::vector& visibility, ModInfoTabIDs sel) +{ + Q_ASSERT(visibility.size() == m_tabs.size()); + + // ordered tab names from settings + const auto orderedNames = m_core.settings().geometry().modInfoTabOrder(); + + // whether the tabs can be sorted + // + // if the object name of a tab widget is not found in orderedNames, the list + // cannot be sorted safely; if the list is empty, it's probably a first run + // and there's nothing to sort + bool canSort = !orderedNames.empty(); + + // gathering visible tabs + std::vector visibleTabs; + for (std::size_t i = 0; i < m_tabs.size(); ++i) { + if (!visibility[i]) { + // this tab is not visible, skip it + continue; + } + + // this tab is visible + visibleTabs.push_back(&m_tabs[i]); + + if (canSort) { + // make sure the widget object name is found in the list + const auto objectName = m_tabs[i].widget->objectName(); + auto itor = std::find(orderedNames.begin(), orderedNames.end(), objectName); + + if (itor == orderedNames.end()) { + // this shouldn't happen, it means there's a tab in the UI that's no + // in the list + log::error("can't sort tabs, '{}' not found", objectName); + canSort = false; + } + } + } + + // sorting tabs + if (canSort) { + std::sort(visibleTabs.begin(), visibleTabs.end(), [&](auto&& a, auto&& b) { + // looking the names in the ordered list + auto aItor = + std::find(orderedNames.begin(), orderedNames.end(), a->widget->objectName()); + + auto bItor = + std::find(orderedNames.begin(), orderedNames.end(), b->widget->objectName()); + + // this shouldn't happen, it was checked above + Q_ASSERT(aItor != orderedNames.end() && bItor != orderedNames.end()); + + return (aItor < bItor); + }); + } + + // removing all tabs + ui->tabWidget->clear(); + + // reset real positions + for (auto& tabInfo : m_tabs) { + tabInfo.realPos = -1; + } + + // add visible tabs + for (std::size_t i = 0; i < visibleTabs.size(); ++i) { + auto& tabInfo = *visibleTabs[i]; + + // remembering real position + tabInfo.realPos = static_cast(i); + + // adding tab + ui->tabWidget->addTab(tabInfo.widget, tabInfo.icon, tabInfo.caption); + + // selecting + if (tabInfo.tab->tabID() == sel) { + ui->tabWidget->setCurrentIndex(static_cast(i)); + } + } +} + +void ModInfoDialog::updateTabs(bool becauseOriginChanged) +{ + auto* origin = getOrigin(); + + // list of tabs that should be updated + std::vector interestedTabs; + + for (auto& tabInfo : m_tabs) { + // don't touch invisible tabs + if (!tabInfo.isVisible()) { + continue; + } + + // updateTabs() is also called from onOriginModified() to update all the + // tabs that depend on the origin; if updateTabs() is called because the + // origin changed, but the tab doesn't use origin files, it can be safely + // skipped + // + // this happens for tabs like notes and categories, which don't need to + // be updated when files change + if (becauseOriginChanged && !tabInfo.tab->usesOriginFiles()) { + continue; + } + + // this tab should be updated + interestedTabs.push_back(&tabInfo); + } + + for (auto* tabInfo : interestedTabs) { + // set the current mod + tabInfo->tab->setMod(m_mod, origin); + + // clear + tabInfo->tab->clear(); + } + + // feed all the files from the filesystem + feedFiles(interestedTabs); + + // call update() on all tabs + for (auto* tabInfo : interestedTabs) { + tabInfo->tab->update(); + } + + // update the text colours + setTabsColors(); +} + +void ModInfoDialog::feedFiles(std::vector& interestedTabs) +{ + const auto rootPath = + m_mod->absolutePath() + (m_core.managedGame()->modDataDirectory().isEmpty() + ? "" + : "/" + m_core.managedGame()->modDataDirectory()); + if (rootPath.isEmpty()) { + return; + } + + if (fs::exists(rootPath.toStdWString())) { + const fs::path fsPath(rootPath.toStdWString()); + + for (const auto& entry : fs::recursive_directory_iterator(fsPath)) { + if (!entry.is_regular_file()) { + // skip directories + continue; + } + + const auto filePath = QString::fromStdString(entry.path().native()); + + // for each tab + for (auto* tabInfo : interestedTabs) { + if (tabInfo->tab->feedFile(rootPath, filePath)) { + break; + } + } + } + } +} + +void ModInfoDialog::setTabsColors() +{ + const auto p = m_modListView->parentWidget()->palette(); + + for (const auto& tabInfo : m_tabs) { + if (!tabInfo.isVisible()) { + // don't bother with invisible tabs + continue; + } + + const QColor color = tabInfo.tab->hasData() + ? QColor::Invalid + : p.color(QPalette::Disabled, QPalette::WindowText); + + ui->tabWidget->tabBar()->setTabTextColor(tabInfo.realPos, color); + } +} + +void ModInfoDialog::switchToTab(ModInfoTabIDs id) +{ + // look a tab with the given id + for (const auto& tabInfo : m_tabs) { + if (tabInfo.tab->tabID() == id) { + // use realPos to select the proper tab in the widget + ui->tabWidget->setCurrentIndex(tabInfo.realPos); + return; + } + } + + // this could happen if the tab is not visible right now + log::debug("can't switch to tab ID {}, not available", static_cast(id)); +} + +MOShared::FilesOrigin* ModInfoDialog::getOrigin() +{ + auto* ds = m_core.directoryStructure(); + + if (!ds->originExists(m_mod->name().toStdWString())) { + return nullptr; + } + + auto* origin = &ds->getOriginByName(m_mod->name().toStdWString()); + if (origin->isDisabled()) { + return nullptr; + } + + return origin; +} + +void ModInfoDialog::saveState() const +{ + saveTabOrder(); + + // save state for each tab + for (const auto& tabInfo : m_tabs) { + tabInfo.tab->saveState(m_core.settings()); + } +} + +void ModInfoDialog::restoreState() +{ + // tab order is not restored here, it will be picked up if tabs have to be + // removed and re-added + + // restore state for each tab + for (const auto& tabInfo : m_tabs) { + tabInfo.tab->restoreState(m_core.settings()); + } +} + +void ModInfoDialog::saveTabOrder() const +{ + if (static_cast(m_tabs.size()) != ui->tabWidget->count()) { + // only save tab state when all tabs are visible + // + // if not all tabs are visible, it becomes very difficult to figure out in + // what order the user wants these tabs to be, so just avoid saving it + // completely + // + // this means that reordering tabs when not all tabs are visible is not + // saved, but it's better than breaking everything + return; + } + + QString names; + + for (int i = 0; i < ui->tabWidget->count(); ++i) { + if (!names.isEmpty()) { + names += " "; + } + + names += ui->tabWidget->widget(i)->objectName(); + } + + m_core.settings().geometry().setModInfoTabOrder(names); + // save last opened index + m_core.settings().widgets().saveIndex(ui->tabWidget); +} + +void ModInfoDialog::onOriginModified(int originID) +{ + // tell the main window the origin changed + emit originModified(originID); + + // update tabs that depend on the origin + updateTabs(true); +} + +void ModInfoDialog::onDeleteShortcut() +{ + // forward the request to the current tab + if (auto* tabInfo = currentTab()) { + tabInfo->tab->deleteRequested(); + } +} + +void ModInfoDialog::closeEvent(QCloseEvent* e) +{ + if (tryClose()) { + e->accept(); + } else { + e->ignore(); + } +} + +void ModInfoDialog::onCloseButton() +{ + if (tryClose()) { + close(); + } +} + +bool ModInfoDialog::tryClose() +{ + // cancel the close if any tab returns false; for example. this can happen if + // a tab has unsaved content, pops a confirmation dialog, and the user clicks + // cancel + + for (auto& tabInfo : m_tabs) { + if (!tabInfo.tab->canClose()) { + return false; + } + } + + return true; +} + +void ModInfoDialog::onTabSelectionChanged() +{ + if (m_arrangingTabs) { + // this can be fired while re-arranging tabs, which happens before mods + // are given to tabs, and might trigger first activation, which breaks all + // sorts of things + return; + } + + // this will call firstActivation() on the tab if needed + if (auto* tabInfo = currentTab()) { + tabInfo->tab->activated(); + } +} + +void ModInfoDialog::onTabMoved() +{ + // reset + for (auto& tabInfo : m_tabs) { + tabInfo.realPos = -1; + } + + // for each tab in the widget + for (int i = 0; i < ui->tabWidget->count(); ++i) { + const auto* w = ui->tabWidget->widget(i); + + bool found = false; + + // find the corresponding tab info + for (auto& tabInfo : m_tabs) { + if (tabInfo.widget == w) { + tabInfo.realPos = i; + found = true; + break; + } + } + + if (!found) { + log::error("unknown tab at index {}", i); + } + } +} + +void ModInfoDialog::onNextMod() +{ + auto index = m_modListView->nextMod(ModInfo::getIndex(m_mod->name())); + if (!index) { + return; + } + auto mod = ModInfo::getByIndex(*index); + if (!mod || mod == m_mod) { + return; + } + + setMod(mod); + update(); + + emit modChanged(*index); +} + +void ModInfoDialog::onPreviousMod() +{ + auto index = m_modListView->prevMod(ModInfo::getIndex(m_mod->name())); + if (!index) { + return; + } + auto mod = ModInfo::getByIndex(*index); + + setMod(mod); + update(); + + emit modChanged(*index); +} diff --git a/src/src/modinfodialogcategories.cpp b/src/src/modinfodialogcategories.cpp index dd91a0a..cf59c25 100644 --- a/src/src/modinfodialogcategories.cpp +++ b/src/src/modinfodialogcategories.cpp @@ -1,138 +1,140 @@ -#include "modinfodialogcategories.h" -#include "categories.h" -#include "modinfo.h" -#include "ui_modinfodialog.h" - -CategoriesTab::CategoriesTab(ModInfoDialogTabContext cx) - : ModInfoDialogTab(std::move(cx)) -{ - connect(ui->categories, &QTreeWidget::itemChanged, [&](auto* item, int col) { - onCategoryChanged(item, col); - }); - - connect(ui->primaryCategories, - static_cast(&QComboBox::currentIndexChanged), - [&](int index) { - onPrimaryChanged(index); - }); -} - -void CategoriesTab::clear() -{ - ui->categories->clear(); - ui->primaryCategories->clear(); - setHasData(false); -} - -void CategoriesTab::update() -{ - clear(); - - add(CategoryFactory::instance(), mod().getCategories(), - ui->categories->invisibleRootItem(), 0); - - updatePrimary(); -} - -bool CategoriesTab::canHandleSeparators() const -{ - return true; -} - -bool CategoriesTab::usesOriginFiles() const -{ - return false; -} - -void CategoriesTab::add(const CategoryFactory& factory, - const std::set& enabledCategories, QTreeWidgetItem* root, - int rootLevel) -{ - for (int i = 0; i < static_cast(factory.numCategories()); ++i) { - if (factory.getParentID(i) != rootLevel) { - continue; - } - - int const categoryID = factory.getCategoryID(i); - - QTreeWidgetItem* newItem = - new QTreeWidgetItem(QStringList(factory.getCategoryName(i))); - - newItem->setFlags(newItem->flags() | Qt::ItemIsUserCheckable); - - newItem->setCheckState(0, - enabledCategories.contains(categoryID) - ? Qt::Checked - : Qt::Unchecked); - - newItem->setData(0, Qt::UserRole, categoryID); - - if (factory.hasChildren(i)) { - add(factory, enabledCategories, newItem, categoryID); - } - - root->addChild(newItem); - } -} - -void CategoriesTab::updatePrimary() -{ - ui->primaryCategories->clear(); - - int const primaryCategory = mod().primaryCategory(); - - addChecked(ui->categories->invisibleRootItem()); - - for (int i = 0; i < ui->primaryCategories->count(); ++i) { - if (ui->primaryCategories->itemData(i).toInt() == primaryCategory) { - ui->primaryCategories->setCurrentIndex(i); - break; - } - } - - setHasData(ui->primaryCategories->count() > 0); -} - -void CategoriesTab::addChecked(QTreeWidgetItem* tree) -{ - for (int i = 0; i < tree->childCount(); ++i) { - QTreeWidgetItem* child = tree->child(i); - if (child->checkState(0) == Qt::Checked) { - ui->primaryCategories->addItem(child->text(0), child->data(0, Qt::UserRole)); - addChecked(child); - } - } -} - -void CategoriesTab::save(QTreeWidgetItem* currentNode) -{ - for (int i = 0; i < currentNode->childCount(); ++i) { - QTreeWidgetItem* childNode = currentNode->child(i); - - mod().setCategory(childNode->data(0, Qt::UserRole).toInt(), - childNode->checkState(0)); - - save(childNode); - } -} - -void CategoriesTab::onCategoryChanged(QTreeWidgetItem* item, int) -{ - QTreeWidgetItem* parent = item->parent(); - - while ((parent != nullptr) && ((parent->flags() & Qt::ItemIsUserCheckable) != 0) && - (parent->checkState(0) == Qt::Unchecked)) { - parent->setCheckState(0, Qt::Checked); - parent = parent->parent(); - } - - updatePrimary(); - save(ui->categories->invisibleRootItem()); -} - -void CategoriesTab::onPrimaryChanged(int index) -{ - if (index != -1) { - mod().setPrimaryCategory(ui->primaryCategories->itemData(index).toInt()); - } -} +#include "modinfodialogcategories.h" +#include "categories.h" + +#include +#include "modinfo.h" +#include "ui_modinfodialog.h" + +CategoriesTab::CategoriesTab(ModInfoDialogTabContext cx) + : ModInfoDialogTab(std::move(cx)) +{ + connect(ui->categories, &QTreeWidget::itemChanged, [&](auto* item, int col) { + onCategoryChanged(item, col); + }); + + connect(ui->primaryCategories, + static_cast(&QComboBox::currentIndexChanged), + [&](int index) { + onPrimaryChanged(index); + }); +} + +void CategoriesTab::clear() +{ + ui->categories->clear(); + ui->primaryCategories->clear(); + setHasData(false); +} + +void CategoriesTab::update() +{ + clear(); + + add(CategoryFactory::instance(), mod().getCategories(), + ui->categories->invisibleRootItem(), 0); + + updatePrimary(); +} + +bool CategoriesTab::canHandleSeparators() const +{ + return true; +} + +bool CategoriesTab::usesOriginFiles() const +{ + return false; +} + +void CategoriesTab::add(const CategoryFactory& factory, + const std::set& enabledCategories, QTreeWidgetItem* root, + int rootLevel) +{ + for (int i = 0; std::cmp_less(i, factory.numCategories()); ++i) { + if (factory.getParentID(i) != rootLevel) { + continue; + } + + int const categoryID = factory.getCategoryID(i); + + QTreeWidgetItem* newItem = + new QTreeWidgetItem(QStringList(factory.getCategoryName(i))); + + newItem->setFlags(newItem->flags() | Qt::ItemIsUserCheckable); + + newItem->setCheckState(0, + enabledCategories.contains(categoryID) + ? Qt::Checked + : Qt::Unchecked); + + newItem->setData(0, Qt::UserRole, categoryID); + + if (factory.hasChildren(i)) { + add(factory, enabledCategories, newItem, categoryID); + } + + root->addChild(newItem); + } +} + +void CategoriesTab::updatePrimary() +{ + ui->primaryCategories->clear(); + + int const primaryCategory = mod().primaryCategory(); + + addChecked(ui->categories->invisibleRootItem()); + + for (int i = 0; i < ui->primaryCategories->count(); ++i) { + if (ui->primaryCategories->itemData(i).toInt() == primaryCategory) { + ui->primaryCategories->setCurrentIndex(i); + break; + } + } + + setHasData(ui->primaryCategories->count() > 0); +} + +void CategoriesTab::addChecked(QTreeWidgetItem* tree) +{ + for (int i = 0; i < tree->childCount(); ++i) { + QTreeWidgetItem* child = tree->child(i); + if (child->checkState(0) == Qt::Checked) { + ui->primaryCategories->addItem(child->text(0), child->data(0, Qt::UserRole)); + addChecked(child); + } + } +} + +void CategoriesTab::save(QTreeWidgetItem* currentNode) +{ + for (int i = 0; i < currentNode->childCount(); ++i) { + QTreeWidgetItem* childNode = currentNode->child(i); + + mod().setCategory(childNode->data(0, Qt::UserRole).toInt(), + childNode->checkState(0)); + + save(childNode); + } +} + +void CategoriesTab::onCategoryChanged(QTreeWidgetItem* item, int) +{ + QTreeWidgetItem* parent = item->parent(); + + while ((parent != nullptr) && ((parent->flags() & Qt::ItemIsUserCheckable) != 0) && + (parent->checkState(0) == Qt::Unchecked)) { + parent->setCheckState(0, Qt::Checked); + parent = parent->parent(); + } + + updatePrimary(); + save(ui->categories->invisibleRootItem()); +} + +void CategoriesTab::onPrimaryChanged(int index) +{ + if (index != -1) { + mod().setPrimaryCategory(ui->primaryCategories->itemData(index).toInt()); + } +} diff --git a/src/src/modinfodialogconflicts.cpp b/src/src/modinfodialogconflicts.cpp index a03b9dc..05406f2 100644 --- a/src/src/modinfodialogconflicts.cpp +++ b/src/src/modinfodialogconflicts.cpp @@ -1,1130 +1,1131 @@ -#include "modinfodialogconflicts.h" -#include "modinfodialog.h" -#include "modinfodialogconflictsmodels.h" -#include "organizercore.h" -#include "settings.h" -#include "shared/directoryentry.h" -#include "shared/fileentry.h" -#include "shared/filesorigin.h" -#include "ui_modinfodialog.h" -#include "utility.h" - -using namespace MOShared; -using namespace MOBase; -namespace fs = std::filesystem; - -// if there are more than 50 selected items in the conflict tree, don't bother -// checking whether menu items apply to them, just show all of them -const std::size_t max_small_selection = 50; - -std::size_t smallSelectionSize(const QTreeView* tree) -{ - const std::size_t too_many = std::numeric_limits::max(); - - std::size_t n = 0; - const auto* sel = tree->selectionModel(); - - for (const auto& range : sel->selection()) { - n += range.height(); - - if (n >= max_small_selection) { - return too_many; - } - } - - return n; -} - -template -void forEachInSelection(QTreeView* tree, F&& f) -{ - const auto* sel = tree->selectionModel(); - - const auto* proxy = dynamic_cast(tree->model()); - - if (!proxy) { - log::error("tree doesn't have a SortProxyModel"); - return; - } - - const auto* model = dynamic_cast(proxy->sourceModel()); - - if (!model) { - log::error("tree doesn't have a ConflictListModel"); - return; - } - - for (const auto& rowIndex : sel->selectedRows()) { - auto modelRow = proxy->mapToSource(rowIndex).row(); - if (auto* item = model->getItem(static_cast(modelRow))) { - if (!f(item)) { - return; - } - } - } -} - -ConflictsTab::ConflictsTab(ModInfoDialogTabContext cx) - : ModInfoDialogTab(cx), // don't move, cx is used again - m_general(this, cx.ui, cx.core), m_advanced(this, cx.ui, cx.core) -{ - connect(&m_general, &GeneralConflictsTab::modOpen, [&](const QString& name) { - emitModOpen(name); - }); - - connect(&m_advanced, &AdvancedConflictsTab::modOpen, [&](const QString& name) { - emitModOpen(name); - }); -} - -void ConflictsTab::update() -{ - setHasData(m_general.update()); - m_advanced.update(); -} - -void ConflictsTab::clear() -{ - m_general.clear(); - m_advanced.clear(); - setHasData(false); -} - -void ConflictsTab::saveState(Settings& s) -{ - s.widgets().saveIndex(ui->tabConflictsTabs); - - m_general.saveState(s); - m_advanced.saveState(s); -} - -void ConflictsTab::restoreState(const Settings& s) -{ - s.widgets().restoreIndex(ui->tabConflictsTabs, 0); - - m_general.restoreState(s); - m_advanced.restoreState(s); -} - -bool ConflictsTab::canHandleUnmanaged() const -{ - return true; -} - -void ConflictsTab::hideItems(QTreeView* tree) -{ - bool changed = false; - bool stop = false; - - const auto n = smallSelectionSize(tree); - - // logging - { - QString files; - if (n > max_small_selection) - files = "a lot of"; - else - files = QString("%1").arg(n); - - log::debug("hiding {} conflict files", files); - } - - QFlags flags = FileRenamer::HIDE; - - if (n > 1) { - flags |= FileRenamer::MULTIPLE; - } - - FileRenamer renamer(parentWidget(), flags); - - const auto* proxy = dynamic_cast(tree->model()); - - if (!proxy) { - log::error("tree doesn't have a SortProxyModel"); - return; - } - - const auto* model = dynamic_cast(proxy->sourceModel()); - - if (!model) { - log::error("tree doesn't have a ConflictListModel"); - return; - } - - forEachInSelection(tree, [&](const ConflictItem* item) { - if (stop) { - return false; - } - - if (!item->canHide()) { - log::debug("cannot hide {}, skipping", item->relativeName()); - return true; - } - - auto result = hideFile(renamer, item->fileName()); - - switch (result) { - case FileRenamer::RESULT_OK: { - // will trigger a refresh at the end - changed = true; - break; - } - - case FileRenamer::RESULT_SKIP: { - // nop - break; - } - - case FileRenamer::RESULT_CANCEL: { - // stop right now, but make sure to refresh if needed - stop = true; - break; - } - } - - return true; - }); - - log::debug("hiding conflict files done"); - - if (changed) { - log::debug("triggering refresh"); - - if (origin()) { - emitOriginModified(); - } - - update(); - } -} - -void ConflictsTab::activateItems(QTreeView* tree) -{ - const auto tryPreview = core().settings().interface().doubleClicksOpenPreviews(); - - // the menu item is only shown for a single selection, but handle all of them - // in case this changes - forEachInSelection(tree, [&](const ConflictItem* item) { - const auto path = item->fileName(); - - if (tryPreview && canPreviewFile(plugin(), item->isArchive(), path)) { - previewItem(item); - } else { - openItem(item, false); - } - - return true; - }); -} - -void ConflictsTab::openItems(QTreeView* tree, bool hooked) -{ - // the menu item is only shown for a single selection, but handle all of them - // in case this changes - forEachInSelection(tree, [&](const ConflictItem* item) { - openItem(item, hooked); - return true; - }); -} - -void ConflictsTab::openItem(const ConflictItem* item, bool hooked) -{ - core() - .processRunner() - .setFromFile(parentWidget(), QFileInfo(item->fileName())) - .setHooked(hooked) - .setWaitForCompletion() - .run(); -} - -void ConflictsTab::previewItems(QTreeView* tree) -{ - // the menu item is only shown for a single selection, but handle all of them - // in case this changes - forEachInSelection(tree, [&](const ConflictItem* item) { - previewItem(item); - return true; - }); -} - -void ConflictsTab::previewItem(const ConflictItem* item) -{ - core().previewFileWithAlternatives(parentWidget(), item->fileName()); -} - -void ConflictsTab::exploreItems(QTreeView* tree) -{ - // the menu item is only shown for a single selection, but handle all of them - // in case this changes - forEachInSelection(tree, [&](const ConflictItem* item) { - shell::Explore(item->fileName()); - return true; - }); -} - -void ConflictsTab::showContextMenu(const QPoint& pos, QTreeView* tree) -{ - auto actions = createMenuActions(tree); - - QMenu menu; - - // open - if (actions.open) { - connect(actions.open, &QAction::triggered, [&] { - openItems(tree, false); - }); - } - - // preview - if (actions.preview) { - connect(actions.preview, &QAction::triggered, [&] { - previewItems(tree); - }); - } - - if ((actions.open && actions.open->isEnabled()) && - (actions.preview && actions.preview->isEnabled())) { - if (Settings::instance().interface().doubleClicksOpenPreviews()) { - menu.addAction(actions.preview); - menu.addAction(actions.open); - } else { - menu.addAction(actions.open); - menu.addAction(actions.preview); - } - } else { - if (actions.open) { - menu.addAction(actions.open); - } - - if (actions.preview) { - menu.addAction(actions.preview); - } - } - - // run hooked - if (actions.runHooked) { - connect(actions.runHooked, &QAction::triggered, [&] { - openItems(tree, true); - }); - - menu.addAction(actions.runHooked); - } - - // goto - if (actions.gotoMenu) { - menu.addMenu(actions.gotoMenu); - - for (auto* a : actions.gotoActions) { - connect(a, &QAction::triggered, [&, name = a->text()] { - emitModOpen(name); - }); - - actions.gotoMenu->addAction(a); - } - } - - // explore - if (actions.explore) { - connect(actions.explore, &QAction::triggered, [&] { - exploreItems(tree); - }); - - menu.addAction(actions.explore); - } - - menu.addSeparator(); - - // hide - if (actions.hide) { - connect(actions.hide, &QAction::triggered, [&] { - hideItems(tree); - }); - - menu.addAction(actions.hide); - } - - if (!menu.isEmpty()) { - if (actions.open || actions.preview || actions.runHooked) { - // bold the first option - auto* top = menu.actions()[0]; - auto f = top->font(); - f.setBold(true); - top->setFont(f); - } - - menu.exec(tree->viewport()->mapToGlobal(pos)); - } -} - -ConflictsTab::Actions ConflictsTab::createMenuActions(QTreeView* tree) -{ - if (tree->selectionModel()->selection().isEmpty()) { - return {}; - } - - bool enableHide = true; - bool enableRun = true; - bool enableOpen = true; - bool enablePreview = true; - bool enableExplore = true; - bool enableGoto = true; - - const auto n = smallSelectionSize(tree); - - const auto* proxy = dynamic_cast(tree->model()); - - if (!proxy) { - log::error("tree doesn't have a SortProxyModel"); - return {}; - } - - const auto* model = dynamic_cast(proxy->sourceModel()); - - if (!model) { - log::error("tree doesn't have a ConflictListModel"); - return {}; - } - - auto modelSel = proxy->mapSelectionToSource(tree->selectionModel()->selection()); - - if (n == 1) { - // this is a single selection - const auto* item = - model->getItem(static_cast(modelSel.indexes()[0].row())); - - if (!item) { - return {}; - } - - enableHide = item->canHide(); - enableRun = item->canRun(); - enableOpen = item->canOpen(); - enablePreview = item->canPreview(plugin()); - enableExplore = item->canExplore(); - enableGoto = item->hasAlts(); - } else { - // this is a multiple selection, don't show open/preview so users don't open - // a thousand files - enableRun = false; - enableOpen = false; - enablePreview = false; - - // can't explore multiple files - enableExplore = false; - - // don't bother with this on multiple selection, at least for now - enableGoto = false; - - if (n <= max_small_selection) { - // if the number of selected items is low, checking them to accurately - // show the menu items is worth it - enableHide = false; - - forEachInSelection(tree, [&](const ConflictItem* item) { - if (item->canHide()) { - enableHide = true; - } - - if (enableHide && enableGoto) { - // found all, no need to check more - return false; - } - - return true; - }); - } - } - - Actions actions; - - if (enableRun) { - actions.open = new QAction(tr("&Execute"), parentWidget()); - actions.runHooked = new QAction(tr("Execute with &VFS"), parentWidget()); - } else if (enableOpen) { - actions.open = new QAction(tr("&Open"), parentWidget()); - actions.runHooked = new QAction(tr("Open with &VFS"), parentWidget()); - } - - actions.preview = new QAction(tr("&Preview"), parentWidget()); - actions.preview->setEnabled(enablePreview); - - actions.gotoMenu = new QMenu(tr("&Go to..."), parentWidget()); - actions.gotoMenu->setEnabled(enableGoto); - - actions.explore = new QAction(tr("Open in &Explorer"), parentWidget()); - actions.explore->setEnabled(enableExplore); - - actions.hide = new QAction(tr("&Hide"), parentWidget()); - actions.hide->setEnabled(enableHide); - - if (enableGoto && n == 1) { - const auto* item = - model->getItem(static_cast(modelSel.indexes()[0].row())); - - actions.gotoActions = createGotoActions(item); - } - - return actions; -} - -std::vector ConflictsTab::createGotoActions(const ConflictItem* item) -{ - if (!origin()) { - return {}; - } - - auto file = origin()->findFile(item->fileIndex()); - if (!file) { - return {}; - } - - std::vector mods; - const auto& ds = *core().directoryStructure(); - - // add all alternatives - for (const auto& alt : file->getAlternatives()) { - const auto& o = ds.getOriginByID(alt.originID()); - if (o.getID() != origin()->getID()) { - mods.push_back(ToQString(o.getName())); - } - } - - // add the real origin if different from this mod - const FilesOrigin& realOrigin = ds.getOriginByID(file->getOrigin()); - if (realOrigin.getID() != origin()->getID()) { - mods.push_back(ToQString(realOrigin.getName())); - } - - std::sort(mods.begin(), mods.end(), [](const auto& a, const auto& b) { - return (QString::localeAwareCompare(a, b) < 0); - }); - - std::vector actions; - - for (const auto& name : mods) { - actions.push_back(new QAction(name, parentWidget())); - } - - return actions; -} - -GeneralConflictsTab::GeneralConflictsTab(ConflictsTab* tab, Ui::ModInfoDialog* pui, - OrganizerCore& oc) - : m_tab(tab), ui(pui), m_core(oc), - m_overwriteModel(new OverwriteConflictListModel(ui->overwriteTree)), - m_overwrittenModel(new OverwrittenConflictListModel(ui->overwrittenTree)), - m_noConflictModel(new NoConflictListModel(ui->noConflictTree)) -{ - m_expanders.overwrite.set(ui->overwriteExpander, ui->overwriteTree, true); - m_expanders.overwritten.set(ui->overwrittenExpander, ui->overwrittenTree, true); - m_expanders.nonconflict.set(ui->noConflictExpander, ui->noConflictTree); - - m_filterOverwrite.setEdit(ui->overwriteLineEdit); - m_filterOverwrite.setList(ui->overwriteTree); - m_filterOverwrite.setUseSourceSort(true); - - m_filterOverwritten.setEdit(ui->overwrittenLineEdit); - m_filterOverwritten.setList(ui->overwrittenTree); - m_filterOverwritten.setUseSourceSort(true); - - m_filterNoConflicts.setEdit(ui->noConflictLineEdit); - m_filterNoConflicts.setList(ui->noConflictTree); - m_filterNoConflicts.setUseSourceSort(true); - - QObject::connect(ui->overwriteTree, &QTreeView::doubleClicked, [&](auto&&) { - m_tab->activateItems(ui->overwriteTree); - }); - - QObject::connect(ui->overwrittenTree, &QTreeView::doubleClicked, [&](auto&& item) { - m_tab->activateItems(ui->overwrittenTree); - }); - - QObject::connect(ui->noConflictTree, &QTreeView::doubleClicked, [&](auto&& item) { - m_tab->activateItems(ui->noConflictTree); - }); - - QObject::connect(ui->overwriteTree, &QTreeView::customContextMenuRequested, - [&](const QPoint& p) { - m_tab->showContextMenu(p, ui->overwriteTree); - }); - - QObject::connect(ui->overwrittenTree, &QTreeView::customContextMenuRequested, - [&](const QPoint& p) { - m_tab->showContextMenu(p, ui->overwrittenTree); - }); - - QObject::connect(ui->noConflictTree, &QTreeView::customContextMenuRequested, - [&](const QPoint& p) { - m_tab->showContextMenu(p, ui->noConflictTree); - }); -} - -void GeneralConflictsTab::clear() -{ - m_counts.clear(); - - m_overwriteModel->clear(); - m_overwrittenModel->clear(); - m_noConflictModel->clear(); - - ui->overwriteCount->display(0); - ui->overwrittenCount->display(0); - ui->noConflictCount->display(0); -} - -void GeneralConflictsTab::saveState(Settings& s) -{ - s.geometry().saveState(&m_expanders.overwrite); - s.geometry().saveState(&m_expanders.overwritten); - s.geometry().saveState(&m_expanders.nonconflict); - s.geometry().saveState(ui->overwriteTree->header()); - s.geometry().saveState(ui->noConflictTree->header()); - s.geometry().saveState(ui->overwrittenTree->header()); -} - -void GeneralConflictsTab::restoreState(const Settings& s) -{ - s.geometry().restoreState(&m_expanders.overwrite); - s.geometry().restoreState(&m_expanders.overwritten); - s.geometry().restoreState(&m_expanders.nonconflict); - s.geometry().restoreState(ui->overwriteTree->header()); - s.geometry().restoreState(ui->noConflictTree->header()); - s.geometry().restoreState(ui->overwrittenTree->header()); -} - -bool GeneralConflictsTab::update() -{ - clear(); - - if (m_tab->origin() != nullptr) { - const auto rootPath = m_tab->mod().absolutePath(); - std::set checkedDirs; - - for (const auto& file : m_tab->origin()->getFiles()) { - if (QString::fromStdWString(file->getName()) - .endsWith(ModInfo::s_HiddenExt, Qt::CaseInsensitive)) { - // skip hidden file conflicts - continue; - } else { - const DirectoryEntry* parent = file->getParent(); - auto hidden = false; - // iterate on all parent directory entries to check for .mohiddden - while (parent != nullptr) { - auto insertResult = checkedDirs.insert(parent); - - if (insertResult.second == false) { - // if already present break as we can assume to have checked the parents as - // well - break; - } else { - if (QString::fromStdWString(parent->getName()) - .endsWith(ModInfo::s_HiddenExt, Qt::CaseInsensitive)) { - hidden = true; - break; - } - parent = parent->getParent(); - } - } - if (hidden) { - // skip hidden file conflicts - continue; - } - } - - // careful: these two strings are moved into createXItem() below - QString relativeName = - QDir::fromNativeSeparators(ToQString(file->getRelativePath())); - QString fileName = rootPath + relativeName; - - bool archive = false; - const int fileOrigin = file->getOrigin(archive); - - ++m_counts.numTotalFiles; - - const auto& alternatives = file->getAlternatives(); - - if (fileOrigin == m_tab->origin()->getID()) { - // current mod is primary origin, the winner - (archive) ? ++m_counts.numTotalArchive : ++m_counts.numTotalLoose; - - if (!alternatives.empty()) { - m_overwriteModel->add( - createOverwriteItem(file->getIndex(), archive, std::move(fileName), - std::move(relativeName), alternatives)); - - ++m_counts.numOverwrite; - if (archive) { - ++m_counts.numOverwriteArchive; - } else { - ++m_counts.numOverwriteLoose; - } - } else { - // otherwise, put the file in the noconflict tree - m_noConflictModel->add(createNoConflictItem( - file->getIndex(), archive, std::move(fileName), std::move(relativeName))); - - ++m_counts.numNonConflicting; - if (archive) { - ++m_counts.numNonConflictingArchive; - } else { - ++m_counts.numNonConflictingLoose; - } - } - } else { - auto currId = m_tab->origin()->getID(); - auto currModAlt = std::find_if(alternatives.begin(), alternatives.end(), - [&currId](auto const& alt) { - return currId == alt.originID(); - }); - - if (currModAlt == alternatives.end()) { - log::error("Mod {} not found in the list of origins for file {}", - m_tab->origin()->getName(), fileName); - continue; - } - - bool const currModFileArchive = currModAlt->isFromArchive(); - - m_overwrittenModel->add(createOverwrittenItem(file->getIndex(), fileOrigin, - archive, std::move(fileName), - std::move(relativeName))); - - ++m_counts.numOverwritten; - if (currModFileArchive) { - ++m_counts.numOverwrittenArchive; - ++m_counts.numTotalArchive; - } else { - ++m_counts.numOverwrittenLoose; - ++m_counts.numTotalLoose; - } - } - } - - m_overwriteModel->finished(); - m_overwrittenModel->finished(); - m_noConflictModel->finished(); - } - - updateUICounters(); - - return (m_counts.numOverwrite > 0 || m_counts.numOverwritten > 0); -} - -ConflictItem GeneralConflictsTab::createOverwriteItem( - FileIndex index, bool archive, QString fileName, QString relativeName, - const MOShared::AlternativesVector& alternatives) -{ - const auto& ds = *m_core.directoryStructure(); - std::wstring altString; - - for (const auto& alt : alternatives) { - if (!altString.empty()) { - altString += L", "; - } - - altString += ds.getOriginByID(alt.originID()).getName(); - } - - auto origin = ToQString(ds.getOriginByID(alternatives.back().originID()).getName()); - - return {ToQString(altString), std::move(relativeName), QString(), index, - std::move(fileName), true, std::move(origin), archive}; -} - -ConflictItem GeneralConflictsTab::createNoConflictItem(FileIndex index, bool archive, - QString fileName, - QString relativeName) -{ - return {QString(), std::move(relativeName), QString(), index, - std::move(fileName), false, QString(), archive}; -} - -ConflictItem GeneralConflictsTab::createOverwrittenItem(FileIndex index, int fileOrigin, - bool archive, QString fileName, - QString relativeName) -{ - const auto& ds = *m_core.directoryStructure(); - const FilesOrigin& realOrigin = ds.getOriginByID(fileOrigin); - - QString after = ToQString(realOrigin.getName()); - QString altOrigin = after; - - return {QString(), std::move(relativeName), std::move(after), index, - std::move(fileName), true, std::move(altOrigin), archive}; -} - -QString percent(int a, int b) -{ - if (b == 0) { - return QString::number(0, 'f', 2); - } - return QString::number((((float)a / (float)b) * 100), 'f', 2); -} - -void GeneralConflictsTab::updateUICounters() -{ - ui->overwriteCount->display(m_counts.numOverwrite); - ui->overwrittenCount->display(m_counts.numOverwritten); - ui->noConflictCount->display(m_counts.numNonConflicting); - - QString const tooltipBase = - tr("" - "" - "" - "" - "" - "" - "" - "" - "
    Type%1TotalPercent
    Loose files: %2%3%4%
    Archive files: %5%6%7%
    Combined: %8%9%10%
    "); - - QString const tooltipOverwrite = - tooltipBase.arg(tr("Winning")) - .arg(m_counts.numOverwriteLoose) - .arg(m_counts.numTotalLoose) - .arg(percent(m_counts.numOverwriteLoose, m_counts.numTotalLoose)) - .arg(m_counts.numOverwriteArchive) - .arg(m_counts.numTotalArchive) - .arg(percent(m_counts.numOverwriteArchive, m_counts.numTotalArchive)) - .arg(m_counts.numOverwrite) - .arg(m_counts.numTotalFiles) - .arg(percent(m_counts.numOverwrite, m_counts.numTotalFiles)); - - QString const tooltipOverwritten = - tooltipBase.arg(tr("Losing")) - .arg(m_counts.numOverwrittenLoose) - .arg(m_counts.numTotalLoose) - .arg(percent(m_counts.numOverwrittenLoose, m_counts.numTotalLoose)) - .arg(m_counts.numOverwrittenArchive) - .arg(m_counts.numTotalArchive) - .arg(percent(m_counts.numOverwrittenArchive, m_counts.numTotalArchive)) - .arg(m_counts.numOverwritten) - .arg(m_counts.numTotalFiles) - .arg(percent(m_counts.numOverwritten, m_counts.numTotalFiles)); - - QString const tooltipNonConflict = - tooltipBase.arg(tr("Non conflicting")) - .arg(m_counts.numNonConflictingLoose) - .arg(m_counts.numTotalLoose) - .arg(percent(m_counts.numNonConflictingLoose, m_counts.numTotalLoose)) - .arg(m_counts.numNonConflictingArchive) - .arg(m_counts.numTotalArchive) - .arg(percent(m_counts.numNonConflictingArchive, m_counts.numTotalArchive)) - .arg(m_counts.numNonConflicting) - .arg(m_counts.numTotalFiles) - .arg(percent(m_counts.numNonConflicting, m_counts.numTotalFiles)); - - ui->overwriteCount->setToolTip(tooltipOverwrite); - ui->overwrittenCount->setToolTip(tooltipOverwritten); - ui->noConflictCount->setToolTip(tooltipNonConflict); -} - -void GeneralConflictsTab::onOverwriteActivated(const QModelIndex& index) -{ - const auto* proxy = dynamic_cast(ui->overwriteTree->model()); - - if (!proxy) { - log::error("tree doesn't have a SortProxyModel"); - return; - } - - const auto* model = dynamic_cast(proxy->sourceModel()); - - if (!model) { - log::error("tree doesn't have a ConflictListModel"); - return; - } - - auto modelIndex = proxy->mapToSource(index); - - auto* item = model->getItem(static_cast(modelIndex.row())); - if (!item) { - return; - } - - const auto origin = item->altOrigin(); - if (!origin.isEmpty()) { - emit modOpen(origin); - } -} - -void GeneralConflictsTab::onOverwrittenActivated(const QModelIndex& index) -{ - const auto* proxy = dynamic_cast(ui->overwrittenTree->model()); - - if (!proxy) { - log::error("tree doesn't have a SortProxyModel"); - return; - } - - const auto* model = dynamic_cast(proxy->sourceModel()); - - if (!model) { - log::error("tree doesn't have a ConflictListModel"); - return; - } - - proxy->mapSelectionToSource(ui->overwrittenTree->selectionModel()->selection()); - - auto modelIndex = proxy->mapToSource(index); - - auto* item = model->getItem(static_cast(modelIndex.row())); - if (!item) { - return; - } - - const auto origin = item->altOrigin(); - if (!origin.isEmpty()) { - emit modOpen(origin); - } -} - -AdvancedConflictsTab::AdvancedConflictsTab(ConflictsTab* tab, Ui::ModInfoDialog* pui, - OrganizerCore& oc) - : m_tab(tab), ui(pui), m_core(oc), - m_model(new AdvancedConflictListModel(ui->conflictsAdvancedList)) -{ - m_filter.setEdit(ui->conflictsAdvancedFilter); - m_filter.setList(ui->conflictsAdvancedList); - m_filter.setUseSourceSort(true); - - // left-elide the overwrites column so that the nearest are visible - ui->conflictsAdvancedList->setItemDelegateForColumn( - 0, new ElideLeftDelegate(ui->conflictsAdvancedList)); - - // left-elide the file column to see filenames - ui->conflictsAdvancedList->setItemDelegateForColumn( - 1, new ElideLeftDelegate(ui->conflictsAdvancedList)); - - // don't elide the overwritten by column so that the nearest are visible - - QObject::connect(ui->conflictsAdvancedShowNoConflict, &QCheckBox::clicked, [&] { - update(); - }); - - QObject::connect(ui->conflictsAdvancedShowAll, &QRadioButton::clicked, [&] { - update(); - }); - - QObject::connect(ui->conflictsAdvancedShowNearest, &QRadioButton::clicked, [&] { - update(); - }); - - QObject::connect(ui->conflictsAdvancedList, &QTreeView::activated, [&] { - m_tab->activateItems(ui->conflictsAdvancedList); - }); - - QObject::connect(ui->conflictsAdvancedList, &QTreeView::customContextMenuRequested, - [&](const QPoint& p) { - m_tab->showContextMenu(p, ui->conflictsAdvancedList); - }); -} - -void AdvancedConflictsTab::clear() -{ - m_model->clear(); -} - -void AdvancedConflictsTab::saveState(Settings& s) -{ - s.geometry().saveState(ui->conflictsAdvancedList->header()); - s.widgets().saveChecked(ui->conflictsAdvancedShowNoConflict); - s.widgets().saveChecked(ui->conflictsAdvancedShowAll); - s.widgets().saveChecked(ui->conflictsAdvancedShowNearest); -} - -void AdvancedConflictsTab::restoreState(const Settings& s) -{ - s.geometry().restoreState(ui->conflictsAdvancedList->header()); - s.widgets().restoreChecked(ui->conflictsAdvancedShowNoConflict); - s.widgets().restoreChecked(ui->conflictsAdvancedShowAll); - s.widgets().restoreChecked(ui->conflictsAdvancedShowNearest); -} - -void AdvancedConflictsTab::update() -{ - clear(); - - if (m_tab->origin() != nullptr) { - const auto rootPath = m_tab->mod().absolutePath(); - - const auto& files = m_tab->origin()->getFiles(); - m_model->reserve(files.size()); - std::set checkedDirs; - - for (const auto& file : files) { - if (QString::fromStdWString(file->getName()) - .endsWith(ModInfo::s_HiddenExt, Qt::CaseInsensitive)) { - // skip hidden file conflicts - continue; - } else { - const DirectoryEntry* parent = file->getParent(); - auto hidden = false; - // iterate on all parent directory entries to check for .mohiddden - while (parent != nullptr) { - auto insertResult = checkedDirs.insert(parent); - - if (insertResult.second == false) { - // if already present break as we can assume to have checked the parents as - // well - break; - } else { - if (QString::fromStdWString(parent->getName()) - .endsWith(ModInfo::s_HiddenExt, Qt::CaseInsensitive)) { - hidden = true; - break; - } - parent = parent->getParent(); - } - } - if (hidden) { - // skip hidden file conflicts - continue; - } - } - // careful: these two strings are moved into createItem() below - QString relativeName = - QDir::fromNativeSeparators(ToQString(file->getRelativePath())); - QString fileName = rootPath + relativeName; - - bool archive = false; - const int fileOrigin = file->getOrigin(archive); - const auto& alternatives = file->getAlternatives(); - - auto item = createItem(file->getIndex(), fileOrigin, archive, std::move(fileName), - std::move(relativeName), alternatives); - - if (item) { - m_model->add(std::move(*item)); - } - } - - m_model->finished(); - } -} - -std::optional -AdvancedConflictsTab::createItem(FileIndex index, int fileOrigin, bool archive, - QString fileName, QString relativeName, - const MOShared::AlternativesVector& alternatives) -{ - const auto& ds = *m_core.directoryStructure(); - - std::wstring before, after; - - auto currOrigin = m_tab->origin(); - bool isCurrOrigArchive = archive; - - if (!alternatives.empty()) { - const bool showAllAlts = ui->conflictsAdvancedShowAll->isChecked(); - - if (currOrigin->getID() == fileOrigin) { - // current origin is the active winner, all alternatives go in 'before' - - if (showAllAlts) { - for (const auto& alt : alternatives) { - const auto& altOrigin = ds.getOriginByID(alt.originID()); - if (!before.empty()) { - before += L", "; - } - - before += altOrigin.getName(); - } - } else { - // only add nearest, which is the last element of alternatives - const auto& altOrigin = ds.getOriginByID(alternatives.back().originID()); - - before += altOrigin.getName(); - } - - } else { - // current mod is one of the alternatives, find its position - - auto currOrgId = currOrigin->getID(); - - auto currModIter = std::find_if(alternatives.begin(), alternatives.end(), - [&currOrgId](auto const& alt) { - return currOrgId == alt.originID(); - }); - - if (currModIter == alternatives.end()) { - log::error("Mod {} not found in the list of origins for file {}", - currOrigin->getName(), fileName); - return {}; - } - - isCurrOrigArchive = currModIter->isFromArchive(); - - if (showAllAlts) { - // fills 'before' and 'after' with all the alternatives that come - // before and after the current mod, trusting the alternatives vector to be - // already sorted correctly - - for (auto iter = alternatives.begin(); iter != alternatives.end(); iter++) { - - const auto& altOrigin = ds.getOriginByID(iter->originID()); - - if (iter < currModIter) { - // mod comes before current - - if (!before.empty()) { - before += L", "; - } - - before += altOrigin.getName(); - } else if (iter > currModIter) { - // mod comes after current - - if (!after.empty()) { - after += L", "; - } - - after += altOrigin.getName(); - } - } - - // also add the active winner origin (the one outside alternatives) to 'after' - if (!after.empty()) { - after += L", "; - } - after += ds.getOriginByID(fileOrigin).getName(); - - } else { - // only show nearest origins - - // before - if (currModIter > alternatives.begin()) { - auto previousOrigId = (currModIter - 1)->originID(); - before += ds.getOriginByID(previousOrigId).getName(); - } - - // after - if (currModIter < (alternatives.end() - 1)) { - auto followingOrigId = (currModIter + 1)->originID(); - after += ds.getOriginByID(followingOrigId).getName(); - } else { - // current mod is last of alternatives, so closest to the active winner - - after += ds.getOriginByID(fileOrigin).getName(); - } - } - } - } - - const bool hasAlts = !before.empty() || !after.empty(); - - if (!hasAlts) { - // if both before and after are empty, it means this file has no conflicts - // at all, only display it if the user wants it - if (!ui->conflictsAdvancedShowNoConflict->isChecked()) { - return {}; - } - } - - auto beforeQS = QString::fromStdWString(before); - auto afterQS = QString::fromStdWString(after); - - return ConflictItem(std::move(beforeQS), std::move(relativeName), std::move(afterQS), - index, std::move(fileName), hasAlts, QString(), - isCurrOrigArchive); -} +#include "modinfodialogconflicts.h" +#include "modinfodialog.h" +#include "modinfodialogconflictsmodels.h" +#include "organizercore.h" +#include "settings.h" +#include "shared/directoryentry.h" +#include "shared/fileentry.h" +#include "shared/filesorigin.h" +#include "ui_modinfodialog.h" +#include "utility.h" + +using namespace MOShared; +using namespace MOBase; +namespace fs = std::filesystem; + +// if there are more than 50 selected items in the conflict tree, don't bother +// checking whether menu items apply to them, just show all of them +const std::size_t max_small_selection = 50; + +std::size_t smallSelectionSize(const QTreeView* tree) +{ + const std::size_t too_many = std::numeric_limits::max(); + + std::size_t n = 0; + const auto* sel = tree->selectionModel(); + + for (const auto& range : sel->selection()) { + n += range.height(); + + if (n >= max_small_selection) { + return too_many; + } + } + + return n; +} + +template +void forEachInSelection(QTreeView* tree, F&& f) +{ + const auto* sel = tree->selectionModel(); + + const auto* proxy = dynamic_cast(tree->model()); + + if (!proxy) { + log::error("tree doesn't have a SortProxyModel"); + return; + } + + const auto* model = dynamic_cast(proxy->sourceModel()); + + if (!model) { + log::error("tree doesn't have a ConflictListModel"); + return; + } + + for (const auto& rowIndex : sel->selectedRows()) { + auto modelRow = proxy->mapToSource(rowIndex).row(); + if (const auto* item = model->getItem(static_cast(modelRow))) { + if (!f(item)) { + return; + } + } + } +} + +ConflictsTab::ConflictsTab(ModInfoDialogTabContext cx) + : ModInfoDialogTab(cx), // don't move, cx is used again + m_general(this, cx.ui, cx.core), m_advanced(this, cx.ui, cx.core) +{ + connect(&m_general, &GeneralConflictsTab::modOpen, [&](const QString& name) { + emitModOpen(name); + }); + + connect(&m_advanced, &AdvancedConflictsTab::modOpen, [&](const QString& name) { + emitModOpen(name); + }); +} + +void ConflictsTab::update() +{ + setHasData(m_general.update()); + m_advanced.update(); +} + +void ConflictsTab::clear() +{ + m_general.clear(); + m_advanced.clear(); + setHasData(false); +} + +void ConflictsTab::saveState(Settings& s) +{ + s.widgets().saveIndex(ui->tabConflictsTabs); + + m_general.saveState(s); + m_advanced.saveState(s); +} + +void ConflictsTab::restoreState(const Settings& s) +{ + s.widgets().restoreIndex(ui->tabConflictsTabs, 0); + + m_general.restoreState(s); + m_advanced.restoreState(s); +} + +bool ConflictsTab::canHandleUnmanaged() const +{ + return true; +} + +void ConflictsTab::hideItems(QTreeView* tree) +{ + bool changed = false; + bool stop = false; + + const auto n = smallSelectionSize(tree); + + // logging + { + QString files; + if (n > max_small_selection) + files = "a lot of"; + else + files = QString("%1").arg(n); + + log::debug("hiding {} conflict files", files); + } + + QFlags flags = FileRenamer::HIDE; + + if (n > 1) { + flags |= FileRenamer::MULTIPLE; + } + + FileRenamer renamer(parentWidget(), flags); + + const auto* proxy = dynamic_cast(tree->model()); + + if (!proxy) { + log::error("tree doesn't have a SortProxyModel"); + return; + } + + const auto* model = dynamic_cast(proxy->sourceModel()); + + if (!model) { + log::error("tree doesn't have a ConflictListModel"); + return; + } + + forEachInSelection(tree, [&](const ConflictItem* item) { + if (stop) { + return false; + } + + if (!item->canHide()) { + log::debug("cannot hide {}, skipping", item->relativeName()); + return true; + } + + auto result = hideFile(renamer, item->fileName()); + + switch (result) { + case FileRenamer::RESULT_OK: { + // will trigger a refresh at the end + changed = true; + break; + } + + case FileRenamer::RESULT_SKIP: { + // nop + break; + } + + case FileRenamer::RESULT_CANCEL: { + // stop right now, but make sure to refresh if needed + stop = true; + break; + } + } + + return true; + }); + + log::debug("hiding conflict files done"); + + if (changed) { + log::debug("triggering refresh"); + + if (origin()) { + emitOriginModified(); + } + + update(); + } +} + +void ConflictsTab::activateItems(QTreeView* tree) +{ + const auto tryPreview = core().settings().interface().doubleClicksOpenPreviews(); + + // the menu item is only shown for a single selection, but handle all of them + // in case this changes + forEachInSelection(tree, [&](const ConflictItem* item) { + const auto path = item->fileName(); + + if (tryPreview && canPreviewFile(plugin(), item->isArchive(), path)) { + previewItem(item); + } else { + openItem(item, false); + } + + return true; + }); +} + +void ConflictsTab::openItems(QTreeView* tree, bool hooked) +{ + // the menu item is only shown for a single selection, but handle all of them + // in case this changes + forEachInSelection(tree, [&](const ConflictItem* item) { + openItem(item, hooked); + return true; + }); +} + +void ConflictsTab::openItem(const ConflictItem* item, bool hooked) +{ + core() + .processRunner() + .setFromFile(parentWidget(), QFileInfo(item->fileName())) + .setHooked(hooked) + .setWaitForCompletion() + .run(); +} + +void ConflictsTab::previewItems(QTreeView* tree) +{ + // the menu item is only shown for a single selection, but handle all of them + // in case this changes + forEachInSelection(tree, [&](const ConflictItem* item) { + previewItem(item); + return true; + }); +} + +void ConflictsTab::previewItem(const ConflictItem* item) +{ + core().previewFileWithAlternatives(parentWidget(), item->fileName()); +} + +void ConflictsTab::exploreItems(QTreeView* tree) +{ + // the menu item is only shown for a single selection, but handle all of them + // in case this changes + forEachInSelection(tree, [&](const ConflictItem* item) { + shell::Explore(item->fileName()); + return true; + }); +} + +void ConflictsTab::showContextMenu(const QPoint& pos, QTreeView* tree) +{ + auto actions = createMenuActions(tree); + + QMenu menu; + + // open + if (actions.open) { + connect(actions.open, &QAction::triggered, [&] { + openItems(tree, false); + }); + } + + // preview + if (actions.preview) { + connect(actions.preview, &QAction::triggered, [&] { + previewItems(tree); + }); + } + + if ((actions.open && actions.open->isEnabled()) && + (actions.preview && actions.preview->isEnabled())) { + if (Settings::instance().interface().doubleClicksOpenPreviews()) { + menu.addAction(actions.preview); + menu.addAction(actions.open); + } else { + menu.addAction(actions.open); + menu.addAction(actions.preview); + } + } else { + if (actions.open) { + menu.addAction(actions.open); + } + + if (actions.preview) { + menu.addAction(actions.preview); + } + } + + // run hooked + if (actions.runHooked) { + connect(actions.runHooked, &QAction::triggered, [&] { + openItems(tree, true); + }); + + menu.addAction(actions.runHooked); + } + + // goto + if (actions.gotoMenu) { + menu.addMenu(actions.gotoMenu); + + for (auto* a : actions.gotoActions) { + connect(a, &QAction::triggered, [&, name = a->text()] { + emitModOpen(name); + }); + + actions.gotoMenu->addAction(a); + } + } + + // explore + if (actions.explore) { + connect(actions.explore, &QAction::triggered, [&] { + exploreItems(tree); + }); + + menu.addAction(actions.explore); + } + + menu.addSeparator(); + + // hide + if (actions.hide) { + connect(actions.hide, &QAction::triggered, [&] { + hideItems(tree); + }); + + menu.addAction(actions.hide); + } + + if (!menu.isEmpty()) { + if (actions.open || actions.preview || actions.runHooked) { + // bold the first option + auto* top = menu.actions()[0]; + auto f = top->font(); + f.setBold(true); + top->setFont(f); + } + + menu.exec(tree->viewport()->mapToGlobal(pos)); + } +} + +ConflictsTab::Actions ConflictsTab::createMenuActions(QTreeView* tree) +{ + if (tree->selectionModel()->selection().isEmpty()) { + return {}; + } + + bool enableHide = true; + bool enableRun = true; + bool enableOpen = true; + bool enablePreview = true; + bool enableExplore = true; + bool enableGoto = true; + + const auto n = smallSelectionSize(tree); + + const auto* proxy = dynamic_cast(tree->model()); + + if (!proxy) { + log::error("tree doesn't have a SortProxyModel"); + return {}; + } + + const auto* model = dynamic_cast(proxy->sourceModel()); + + if (!model) { + log::error("tree doesn't have a ConflictListModel"); + return {}; + } + + auto modelSel = proxy->mapSelectionToSource(tree->selectionModel()->selection()); + + if (n == 1) { + // this is a single selection + const auto* item = + model->getItem(static_cast(modelSel.indexes()[0].row())); + + if (!item) { + return {}; + } + + enableHide = item->canHide(); + enableRun = item->canRun(); + enableOpen = item->canOpen(); + enablePreview = item->canPreview(plugin()); + enableExplore = item->canExplore(); + enableGoto = item->hasAlts(); + } else { + // this is a multiple selection, don't show open/preview so users don't open + // a thousand files + enableRun = false; + enableOpen = false; + enablePreview = false; + + // can't explore multiple files + enableExplore = false; + + // don't bother with this on multiple selection, at least for now + enableGoto = false; + + if (n <= max_small_selection) { + // if the number of selected items is low, checking them to accurately + // show the menu items is worth it + enableHide = false; + + forEachInSelection(tree, [&](const ConflictItem* item) { + if (item->canHide()) { + enableHide = true; + } + + if (enableHide && enableGoto) { + // found all, no need to check more + return false; + } + + return true; + }); + } + } + + Actions actions; + + if (enableRun) { + actions.open = new QAction(tr("&Execute"), parentWidget()); + actions.runHooked = new QAction(tr("Execute with &VFS"), parentWidget()); + } else if (enableOpen) { + actions.open = new QAction(tr("&Open"), parentWidget()); + actions.runHooked = new QAction(tr("Open with &VFS"), parentWidget()); + } + + actions.preview = new QAction(tr("&Preview"), parentWidget()); + actions.preview->setEnabled(enablePreview); + + actions.gotoMenu = new QMenu(tr("&Go to..."), parentWidget()); + actions.gotoMenu->setEnabled(enableGoto); + + actions.explore = new QAction(tr("Open in &Explorer"), parentWidget()); + actions.explore->setEnabled(enableExplore); + + actions.hide = new QAction(tr("&Hide"), parentWidget()); + actions.hide->setEnabled(enableHide); + + if (enableGoto && n == 1) { + const auto* item = + model->getItem(static_cast(modelSel.indexes()[0].row())); + + actions.gotoActions = createGotoActions(item); + } + + return actions; +} + +std::vector ConflictsTab::createGotoActions(const ConflictItem* item) +{ + if (!origin()) { + return {}; + } + + auto file = origin()->findFile(item->fileIndex()); + if (!file) { + return {}; + } + + std::vector mods; + const auto& ds = *core().directoryStructure(); + + // add all alternatives + for (const auto& alt : file->getAlternatives()) { + const auto& o = ds.getOriginByID(alt.originID()); + if (o.getID() != origin()->getID()) { + mods.push_back(ToQString(o.getName())); + } + } + + // add the real origin if different from this mod + const FilesOrigin& realOrigin = ds.getOriginByID(file->getOrigin()); + if (realOrigin.getID() != origin()->getID()) { + mods.push_back(ToQString(realOrigin.getName())); + } + + std::sort(mods.begin(), mods.end(), [](const auto& a, const auto& b) { + return (QString::localeAwareCompare(a, b) < 0); + }); + + std::vector actions; + + for (const auto& name : mods) { + actions.push_back(new QAction(name, parentWidget())); + } + + return actions; +} + +GeneralConflictsTab::GeneralConflictsTab(ConflictsTab* tab, Ui::ModInfoDialog* pui, + OrganizerCore& oc) + : m_tab(tab), ui(pui), m_core(oc), + m_overwriteModel(new OverwriteConflictListModel(ui->overwriteTree)), + m_overwrittenModel(new OverwrittenConflictListModel(ui->overwrittenTree)), + m_noConflictModel(new NoConflictListModel(ui->noConflictTree)) +{ + m_expanders.overwrite.set(ui->overwriteExpander, ui->overwriteTree, true); + m_expanders.overwritten.set(ui->overwrittenExpander, ui->overwrittenTree, true); + m_expanders.nonconflict.set(ui->noConflictExpander, ui->noConflictTree); + + m_filterOverwrite.setEdit(ui->overwriteLineEdit); + m_filterOverwrite.setList(ui->overwriteTree); + m_filterOverwrite.setUseSourceSort(true); + + m_filterOverwritten.setEdit(ui->overwrittenLineEdit); + m_filterOverwritten.setList(ui->overwrittenTree); + m_filterOverwritten.setUseSourceSort(true); + + m_filterNoConflicts.setEdit(ui->noConflictLineEdit); + m_filterNoConflicts.setList(ui->noConflictTree); + m_filterNoConflicts.setUseSourceSort(true); + + QObject::connect(ui->overwriteTree, &QTreeView::doubleClicked, [&](auto&&) { + m_tab->activateItems(ui->overwriteTree); + }); + + QObject::connect(ui->overwrittenTree, &QTreeView::doubleClicked, [&](auto&& item) { + m_tab->activateItems(ui->overwrittenTree); + }); + + QObject::connect(ui->noConflictTree, &QTreeView::doubleClicked, [&](auto&& item) { + m_tab->activateItems(ui->noConflictTree); + }); + + QObject::connect(ui->overwriteTree, &QTreeView::customContextMenuRequested, + [&](const QPoint& p) { + m_tab->showContextMenu(p, ui->overwriteTree); + }); + + QObject::connect(ui->overwrittenTree, &QTreeView::customContextMenuRequested, + [&](const QPoint& p) { + m_tab->showContextMenu(p, ui->overwrittenTree); + }); + + QObject::connect(ui->noConflictTree, &QTreeView::customContextMenuRequested, + [&](const QPoint& p) { + m_tab->showContextMenu(p, ui->noConflictTree); + }); +} + +void GeneralConflictsTab::clear() +{ + m_counts.clear(); + + m_overwriteModel->clear(); + m_overwrittenModel->clear(); + m_noConflictModel->clear(); + + ui->overwriteCount->display(0); + ui->overwrittenCount->display(0); + ui->noConflictCount->display(0); +} + +void GeneralConflictsTab::saveState(Settings& s) +{ + s.geometry().saveState(&m_expanders.overwrite); + s.geometry().saveState(&m_expanders.overwritten); + s.geometry().saveState(&m_expanders.nonconflict); + s.geometry().saveState(ui->overwriteTree->header()); + s.geometry().saveState(ui->noConflictTree->header()); + s.geometry().saveState(ui->overwrittenTree->header()); +} + +void GeneralConflictsTab::restoreState(const Settings& s) +{ + s.geometry().restoreState(&m_expanders.overwrite); + s.geometry().restoreState(&m_expanders.overwritten); + s.geometry().restoreState(&m_expanders.nonconflict); + s.geometry().restoreState(ui->overwriteTree->header()); + s.geometry().restoreState(ui->noConflictTree->header()); + s.geometry().restoreState(ui->overwrittenTree->header()); +} + +bool GeneralConflictsTab::update() +{ + clear(); + + if (m_tab->origin() != nullptr) { + const auto rootPath = m_tab->mod().absolutePath(); + std::set checkedDirs; + + for (const auto& file : m_tab->origin()->getFiles()) { + if (QString::fromStdWString(file->getName()) + .endsWith(ModInfo::s_HiddenExt, Qt::CaseInsensitive)) { + // skip hidden file conflicts + continue; + } else { + const DirectoryEntry* parent = file->getParent(); + auto hidden = false; + // iterate on all parent directory entries to check for .mohiddden + while (parent != nullptr) { + auto insertResult = checkedDirs.insert(parent); + + if (!insertResult.second) { + // if already present break as we can assume to have checked the parents as + // well + break; + } else { + if (QString::fromStdWString(parent->getName()) + .endsWith(ModInfo::s_HiddenExt, Qt::CaseInsensitive)) { + hidden = true; + break; + } + parent = parent->getParent(); + } + } + if (hidden) { + // skip hidden file conflicts + continue; + } + } + + // careful: these two strings are moved into createXItem() below + QString relativeName = + QDir::fromNativeSeparators(ToQString(file->getRelativePath())); + QString fileName = rootPath + relativeName; + + bool archive = false; + const int fileOrigin = file->getOrigin(archive); + + ++m_counts.numTotalFiles; + + const auto& alternatives = file->getAlternatives(); + + if (fileOrigin == m_tab->origin()->getID()) { + // current mod is primary origin, the winner + (archive) ? ++m_counts.numTotalArchive : ++m_counts.numTotalLoose; + + if (!alternatives.empty()) { + m_overwriteModel->add( + createOverwriteItem(file->getIndex(), archive, std::move(fileName), + std::move(relativeName), alternatives)); + + ++m_counts.numOverwrite; + if (archive) { + ++m_counts.numOverwriteArchive; + } else { + ++m_counts.numOverwriteLoose; + } + } else { + // otherwise, put the file in the noconflict tree + m_noConflictModel->add(createNoConflictItem( + file->getIndex(), archive, std::move(fileName), std::move(relativeName))); + + ++m_counts.numNonConflicting; + if (archive) { + ++m_counts.numNonConflictingArchive; + } else { + ++m_counts.numNonConflictingLoose; + } + } + } else { + auto currId = m_tab->origin()->getID(); + auto currModAlt = std::find_if(alternatives.begin(), alternatives.end(), + [&currId](auto const& alt) { + return currId == alt.originID(); + }); + + if (currModAlt == alternatives.end()) { + log::error("Mod {} not found in the list of origins for file {}", + m_tab->origin()->getName(), fileName); + continue; + } + + bool const currModFileArchive = currModAlt->isFromArchive(); + + m_overwrittenModel->add(createOverwrittenItem(file->getIndex(), fileOrigin, + archive, std::move(fileName), + std::move(relativeName))); + + ++m_counts.numOverwritten; + if (currModFileArchive) { + ++m_counts.numOverwrittenArchive; + ++m_counts.numTotalArchive; + } else { + ++m_counts.numOverwrittenLoose; + ++m_counts.numTotalLoose; + } + } + } + + m_overwriteModel->finished(); + m_overwrittenModel->finished(); + m_noConflictModel->finished(); + } + + updateUICounters(); + + return (m_counts.numOverwrite > 0 || m_counts.numOverwritten > 0); +} + +ConflictItem GeneralConflictsTab::createOverwriteItem( + FileIndex index, bool archive, QString fileName, QString relativeName, + const MOShared::AlternativesVector& alternatives) +{ + const auto& ds = *m_core.directoryStructure(); + std::wstring altString; + + for (const auto& alt : alternatives) { + if (!altString.empty()) { + altString += L", "; + } + + altString += ds.getOriginByID(alt.originID()).getName(); + } + + auto origin = ToQString(ds.getOriginByID(alternatives.back().originID()).getName()); + + return {ToQString(altString), std::move(relativeName), QString(), index, + std::move(fileName), true, std::move(origin), archive}; +} + +ConflictItem GeneralConflictsTab::createNoConflictItem(FileIndex index, bool archive, + QString fileName, + QString relativeName) +{ + return {QString(), std::move(relativeName), QString(), index, + std::move(fileName), false, QString(), archive}; +} + +ConflictItem GeneralConflictsTab::createOverwrittenItem(FileIndex index, int fileOrigin, + bool archive, QString fileName, + QString relativeName) +{ + const auto& ds = *m_core.directoryStructure(); + const FilesOrigin& realOrigin = ds.getOriginByID(fileOrigin); + + QString after = ToQString(realOrigin.getName()); + QString altOrigin = after; + + return {QString(), std::move(relativeName), std::move(after), index, + std::move(fileName), true, std::move(altOrigin), archive}; +} + +QString percent(int a, int b) +{ + if (b == 0) { + return QString::number(0, 'f', 2); + } + return QString::number((((float)a / (float)b) * 100), 'f', 2); +} + +void GeneralConflictsTab::updateUICounters() +{ + ui->overwriteCount->display(m_counts.numOverwrite); + ui->overwrittenCount->display(m_counts.numOverwritten); + ui->noConflictCount->display(m_counts.numNonConflicting); + + QString const tooltipBase = + tr("" + "" + "" + "" + "" + "" + "" + "" + "
    Type%1TotalPercent
    Loose files: %2%3%4%
    Archive files: %5%6%7%
    Combined: %8%9%10%
    "); + + QString const tooltipOverwrite = + tooltipBase.arg(tr("Winning")) + .arg(m_counts.numOverwriteLoose) + .arg(m_counts.numTotalLoose) + .arg(percent(m_counts.numOverwriteLoose, m_counts.numTotalLoose)) + .arg(m_counts.numOverwriteArchive) + .arg(m_counts.numTotalArchive) + .arg(percent(m_counts.numOverwriteArchive, m_counts.numTotalArchive)) + .arg(m_counts.numOverwrite) + .arg(m_counts.numTotalFiles) + .arg(percent(m_counts.numOverwrite, m_counts.numTotalFiles)); + + QString const tooltipOverwritten = + tooltipBase.arg(tr("Losing")) + .arg(m_counts.numOverwrittenLoose) + .arg(m_counts.numTotalLoose) + .arg(percent(m_counts.numOverwrittenLoose, m_counts.numTotalLoose)) + .arg(m_counts.numOverwrittenArchive) + .arg(m_counts.numTotalArchive) + .arg(percent(m_counts.numOverwrittenArchive, m_counts.numTotalArchive)) + .arg(m_counts.numOverwritten) + .arg(m_counts.numTotalFiles) + .arg(percent(m_counts.numOverwritten, m_counts.numTotalFiles)); + + QString const tooltipNonConflict = + tooltipBase.arg(tr("Non conflicting")) + .arg(m_counts.numNonConflictingLoose) + .arg(m_counts.numTotalLoose) + .arg(percent(m_counts.numNonConflictingLoose, m_counts.numTotalLoose)) + .arg(m_counts.numNonConflictingArchive) + .arg(m_counts.numTotalArchive) + .arg(percent(m_counts.numNonConflictingArchive, m_counts.numTotalArchive)) + .arg(m_counts.numNonConflicting) + .arg(m_counts.numTotalFiles) + .arg(percent(m_counts.numNonConflicting, m_counts.numTotalFiles)); + + ui->overwriteCount->setToolTip(tooltipOverwrite); + ui->overwrittenCount->setToolTip(tooltipOverwritten); + ui->noConflictCount->setToolTip(tooltipNonConflict); +} + +void GeneralConflictsTab::onOverwriteActivated(const QModelIndex& index) +{ + const auto* proxy = dynamic_cast(ui->overwriteTree->model()); + + if (!proxy) { + log::error("tree doesn't have a SortProxyModel"); + return; + } + + const auto* model = dynamic_cast(proxy->sourceModel()); + + if (!model) { + log::error("tree doesn't have a ConflictListModel"); + return; + } + + auto modelIndex = proxy->mapToSource(index); + + const auto* item = model->getItem(static_cast(modelIndex.row())); + if (!item) { + return; + } + + const auto origin = item->altOrigin(); + if (!origin.isEmpty()) { + emit modOpen(origin); + } +} + +void GeneralConflictsTab::onOverwrittenActivated(const QModelIndex& index) +{ + const auto* proxy = dynamic_cast(ui->overwrittenTree->model()); + + if (!proxy) { + log::error("tree doesn't have a SortProxyModel"); + return; + } + + const auto* model = dynamic_cast(proxy->sourceModel()); + + if (!model) { + log::error("tree doesn't have a ConflictListModel"); + return; + } + + proxy->mapSelectionToSource(ui->overwrittenTree->selectionModel()->selection()); + + auto modelIndex = proxy->mapToSource(index); + + const auto* item = model->getItem(static_cast(modelIndex.row())); + if (!item) { + return; + } + + const auto origin = item->altOrigin(); + if (!origin.isEmpty()) { + emit modOpen(origin); + } +} + +AdvancedConflictsTab::AdvancedConflictsTab(ConflictsTab* tab, Ui::ModInfoDialog* pui, + OrganizerCore& oc) + : m_tab(tab), ui(pui), m_core(oc), + m_model(new AdvancedConflictListModel(ui->conflictsAdvancedList)) +{ + m_filter.setEdit(ui->conflictsAdvancedFilter); + m_filter.setList(ui->conflictsAdvancedList); + m_filter.setUseSourceSort(true); + + // left-elide the overwrites column so that the nearest are visible + ui->conflictsAdvancedList->setItemDelegateForColumn( + 0, new ElideLeftDelegate(ui->conflictsAdvancedList)); + + // left-elide the file column to see filenames + ui->conflictsAdvancedList->setItemDelegateForColumn( + 1, new ElideLeftDelegate(ui->conflictsAdvancedList)); + + // don't elide the overwritten by column so that the nearest are visible + + QObject::connect(ui->conflictsAdvancedShowNoConflict, &QCheckBox::clicked, [&] { + update(); + }); + + QObject::connect(ui->conflictsAdvancedShowAll, &QRadioButton::clicked, [&] { + update(); + }); + + QObject::connect(ui->conflictsAdvancedShowNearest, &QRadioButton::clicked, [&] { + update(); + }); + + QObject::connect(ui->conflictsAdvancedList, &QTreeView::activated, [&] { + m_tab->activateItems(ui->conflictsAdvancedList); + }); + + QObject::connect(ui->conflictsAdvancedList, &QTreeView::customContextMenuRequested, + [&](const QPoint& p) { + m_tab->showContextMenu(p, ui->conflictsAdvancedList); + }); +} + +void AdvancedConflictsTab::clear() +{ + m_model->clear(); +} + +void AdvancedConflictsTab::saveState(Settings& s) +{ + s.geometry().saveState(ui->conflictsAdvancedList->header()); + s.widgets().saveChecked(ui->conflictsAdvancedShowNoConflict); + s.widgets().saveChecked(ui->conflictsAdvancedShowAll); + s.widgets().saveChecked(ui->conflictsAdvancedShowNearest); +} + +void AdvancedConflictsTab::restoreState(const Settings& s) +{ + s.geometry().restoreState(ui->conflictsAdvancedList->header()); + s.widgets().restoreChecked(ui->conflictsAdvancedShowNoConflict); + s.widgets().restoreChecked(ui->conflictsAdvancedShowAll); + s.widgets().restoreChecked(ui->conflictsAdvancedShowNearest); +} + +void AdvancedConflictsTab::update() +{ + clear(); + + if (m_tab->origin() != nullptr) { + const auto rootPath = m_tab->mod().absolutePath(); + + const auto& files = m_tab->origin()->getFiles(); + m_model->reserve(files.size()); + std::set checkedDirs; + + for (const auto& file : files) { + if (QString::fromStdWString(file->getName()) + .endsWith(ModInfo::s_HiddenExt, Qt::CaseInsensitive)) { + // skip hidden file conflicts + continue; + } else { + const DirectoryEntry* parent = file->getParent(); + auto hidden = false; + // iterate on all parent directory entries to check for .mohiddden + while (parent != nullptr) { + auto insertResult = checkedDirs.insert(parent); + + if (!insertResult.second) { + // if already present break as we can assume to have checked the parents as + // well + break; + } else { + if (QString::fromStdWString(parent->getName()) + .endsWith(ModInfo::s_HiddenExt, Qt::CaseInsensitive)) { + hidden = true; + break; + } + parent = parent->getParent(); + } + } + if (hidden) { + // skip hidden file conflicts + continue; + } + } + // careful: these two strings are moved into createItem() below + QString relativeName = + QDir::fromNativeSeparators(ToQString(file->getRelativePath())); + QString fileName = rootPath + relativeName; + + bool archive = false; + const int fileOrigin = file->getOrigin(archive); + const auto& alternatives = file->getAlternatives(); + + auto item = createItem(file->getIndex(), fileOrigin, archive, std::move(fileName), + std::move(relativeName), alternatives); + + if (item) { + m_model->add(std::move(*item)); + } + } + + m_model->finished(); + } +} + +std::optional +AdvancedConflictsTab::createItem(FileIndex index, int fileOrigin, bool archive, + QString fileName, QString relativeName, + const MOShared::AlternativesVector& alternatives) +{ + const auto& ds = *m_core.directoryStructure(); + + std::wstring before; + std::wstring after; + + auto *currOrigin = m_tab->origin(); + bool isCurrOrigArchive = archive; + + if (!alternatives.empty()) { + const bool showAllAlts = ui->conflictsAdvancedShowAll->isChecked(); + + if (currOrigin->getID() == fileOrigin) { + // current origin is the active winner, all alternatives go in 'before' + + if (showAllAlts) { + for (const auto& alt : alternatives) { + const auto& altOrigin = ds.getOriginByID(alt.originID()); + if (!before.empty()) { + before += L", "; + } + + before += altOrigin.getName(); + } + } else { + // only add nearest, which is the last element of alternatives + const auto& altOrigin = ds.getOriginByID(alternatives.back().originID()); + + before += altOrigin.getName(); + } + + } else { + // current mod is one of the alternatives, find its position + + auto currOrgId = currOrigin->getID(); + + auto currModIter = std::find_if(alternatives.begin(), alternatives.end(), + [&currOrgId](auto const& alt) { + return currOrgId == alt.originID(); + }); + + if (currModIter == alternatives.end()) { + log::error("Mod {} not found in the list of origins for file {}", + currOrigin->getName(), fileName); + return {}; + } + + isCurrOrigArchive = currModIter->isFromArchive(); + + if (showAllAlts) { + // fills 'before' and 'after' with all the alternatives that come + // before and after the current mod, trusting the alternatives vector to be + // already sorted correctly + + for (auto iter = alternatives.begin(); iter != alternatives.end(); iter++) { + + const auto& altOrigin = ds.getOriginByID(iter->originID()); + + if (iter < currModIter) { + // mod comes before current + + if (!before.empty()) { + before += L", "; + } + + before += altOrigin.getName(); + } else if (iter > currModIter) { + // mod comes after current + + if (!after.empty()) { + after += L", "; + } + + after += altOrigin.getName(); + } + } + + // also add the active winner origin (the one outside alternatives) to 'after' + if (!after.empty()) { + after += L", "; + } + after += ds.getOriginByID(fileOrigin).getName(); + + } else { + // only show nearest origins + + // before + if (currModIter > alternatives.begin()) { + auto previousOrigId = (currModIter - 1)->originID(); + before += ds.getOriginByID(previousOrigId).getName(); + } + + // after + if (currModIter < (alternatives.end() - 1)) { + auto followingOrigId = (currModIter + 1)->originID(); + after += ds.getOriginByID(followingOrigId).getName(); + } else { + // current mod is last of alternatives, so closest to the active winner + + after += ds.getOriginByID(fileOrigin).getName(); + } + } + } + } + + const bool hasAlts = !before.empty() || !after.empty(); + + if (!hasAlts) { + // if both before and after are empty, it means this file has no conflicts + // at all, only display it if the user wants it + if (!ui->conflictsAdvancedShowNoConflict->isChecked()) { + return {}; + } + } + + auto beforeQS = QString::fromStdWString(before); + auto afterQS = QString::fromStdWString(after); + + return ConflictItem(std::move(beforeQS), std::move(relativeName), std::move(afterQS), + index, std::move(fileName), hasAlts, QString(), + isCurrOrigArchive); +} diff --git a/src/src/modinfodialogconflictsmodels.cpp b/src/src/modinfodialogconflictsmodels.cpp index e5790da..dc321c8 100644 --- a/src/src/modinfodialogconflictsmodels.cpp +++ b/src/src/modinfodialogconflictsmodels.cpp @@ -1,311 +1,311 @@ -#include "modinfodialogconflictsmodels.h" -#include "modinfodialog.h" -#include - -using MOBase::naturalCompare; - -ConflictItem::ConflictItem(QString before, QString relativeName, QString after, - MOShared::FileIndex index, QString fileName, - bool hasAltOrigins, QString altOrigin, bool archive) - : m_before(std::move(before)), m_relativeName(std::move(relativeName)), - m_after(std::move(after)), m_index(index), m_fileName(std::move(fileName)), - m_hasAltOrigins(hasAltOrigins), m_altOrigin(std::move(altOrigin)), - m_isArchive(archive) -{} - -const QString& ConflictItem::before() const -{ - return m_before; -} - -const QString& ConflictItem::relativeName() const -{ - return m_relativeName; -} - -const QString& ConflictItem::after() const -{ - return m_after; -} - -const QString& ConflictItem::fileName() const -{ - return m_fileName; -} - -const QString& ConflictItem::altOrigin() const -{ - return m_altOrigin; -} - -bool ConflictItem::hasAlts() const -{ - return m_hasAltOrigins; -} - -bool ConflictItem::isArchive() const -{ - return m_isArchive; -} - -MOShared::FileIndex ConflictItem::fileIndex() const -{ - return m_index; -} - -bool ConflictItem::canHide() const -{ - return canHideFile(isArchive(), fileName()); -} - -bool ConflictItem::canUnhide() const -{ - return canUnhideFile(isArchive(), fileName()); -} - -bool ConflictItem::canRun() const -{ - return canRunFile(isArchive(), fileName()); -} - -bool ConflictItem::canOpen() const -{ - return canOpenFile(isArchive(), fileName()); -} - -bool ConflictItem::canPreview(PluginContainer& pluginContainer) const -{ - return canPreviewFile(pluginContainer, isArchive(), fileName()); -} - -bool ConflictItem::canExplore() const -{ - return canExploreFile(isArchive(), fileName()); -} - -ConflictListModel::ConflictListModel(QTreeView* tree, std::vector columns) - : m_tree(tree), m_columns(std::move(columns)) - -{ - m_tree->setModel(this); -} - -void ConflictListModel::clear() -{ - beginResetModel(); - m_items.clear(); - endResetModel(); -} - -void ConflictListModel::reserve(std::size_t s) -{ - m_items.reserve(s); -} - -QModelIndex ConflictListModel::index(int row, int col, const QModelIndex&) const -{ - return createIndex(row, col); -} - -QModelIndex ConflictListModel::parent(const QModelIndex&) const -{ - return {}; -} - -int ConflictListModel::rowCount(const QModelIndex& parent) const -{ - if (parent.isValid()) { - return 0; - } - - return static_cast(m_items.size()); -} - -int ConflictListModel::columnCount(const QModelIndex&) const -{ - return static_cast(m_columns.size()); -} - -const ConflictItem* ConflictListModel::itemFromIndex(const QModelIndex& index) const -{ - const auto row = index.row(); - if (row < 0) { - return nullptr; - } - - const auto i = static_cast(row); - if (i >= m_items.size()) { - return nullptr; - } - - return &m_items[i]; -} - -QModelIndex ConflictListModel::indexFromItem(const ConflictItem* item, int col) -{ - for (std::size_t i = 0; i < m_items.size(); ++i) { - if (&m_items[i] == item) { - return createIndex(static_cast(i), col); - } - } - - return {}; -} - -QVariant ConflictListModel::data(const QModelIndex& index, int role) const -{ - if (role == Qt::DisplayRole || role == Qt::FontRole) { - const ConflictItem* item = itemFromIndex(index); - if (!item) { - return {}; - } - - const auto col = index.column(); - if (col < 0) { - return {}; - } - - const auto c = static_cast(col); - if (c >= m_columns.size()) { - return {}; - } - - if (role == Qt::DisplayRole) { - return (item->*m_columns[c].getText)(); - } else if (role == Qt::FontRole) { - if (item->isArchive()) { - QFont f = m_tree->font(); - f.setItalic(true); - return f; - } - } - } - - return {}; -} - -QVariant ConflictListModel::headerData(int col, Qt::Orientation, int role) const -{ - if (role == Qt::DisplayRole) { - if (col < 0) { - return {}; - } - - const auto i = static_cast(col); - if (i >= m_columns.size()) { - return {}; - } - - return m_columns[i].caption; - } - - return {}; -} - -void ConflictListModel::sort(int colIndex, Qt::SortOrder order) -{ - m_sortColumn = colIndex; - m_sortOrder = order; - - emit layoutAboutToBeChanged({}, QAbstractItemModel::VerticalSortHint); - - const auto oldList = persistentIndexList(); - std::vector> oldItems; - - const auto itemCount = oldList.size(); - oldItems.reserve(static_cast(itemCount)); - - for (int i = 0; i < itemCount; ++i) { - const QModelIndex& index = oldList[i]; - oldItems.push_back({itemFromIndex(index), index.column()}); - } - - doSort(); - - QModelIndexList newList; - newList.reserve(itemCount); - - for (int i = 0; i < itemCount; ++i) { - const auto& pair = oldItems[static_cast(i)]; - newList.append(indexFromItem(pair.first, pair.second)); - } - - changePersistentIndexList(oldList, newList); - - emit layoutChanged({}, QAbstractItemModel::VerticalSortHint); -} - -void ConflictListModel::add(ConflictItem item) -{ - m_items.emplace_back(std::move(item)); -} - -void ConflictListModel::finished() -{ - beginResetModel(); - endResetModel(); - - sort(m_sortColumn, m_sortOrder); -} - -const ConflictItem* ConflictListModel::getItem(std::size_t row) const -{ - if (row >= m_items.size()) { - return nullptr; - } - - return &m_items[row]; -} - -void ConflictListModel::doSort() -{ - if (m_items.empty()) { - return; - } - - if (m_sortColumn < 0) { - return; - } - - const auto c = static_cast(m_sortColumn); - if (c >= m_columns.size()) { - return; - } - - const auto& col = m_columns[c]; - - // avoids branching on sort order while sorting - auto sortAsc = [&](const auto& a, const auto& b) { - return (naturalCompare((a.*col.getText)(), (b.*col.getText)()) < 0); - }; - - auto sortDesc = [&](const auto& a, const auto& b) { - return (naturalCompare((a.*col.getText)(), (b.*col.getText)()) > 0); - }; - - if (m_sortOrder == Qt::AscendingOrder) { - std::sort(m_items.begin(), m_items.end(), sortAsc); - } else { - std::sort(m_items.begin(), m_items.end(), sortDesc); - } -} - -OverwriteConflictListModel::OverwriteConflictListModel(QTreeView* tree) - : ConflictListModel(tree, {{tr("File"), &ConflictItem::relativeName}, - {tr("Overwritten Mods"), &ConflictItem::before}}) -{} - -OverwrittenConflictListModel::OverwrittenConflictListModel(QTreeView* tree) - : ConflictListModel(tree, {{tr("File"), &ConflictItem::relativeName}, - {tr("Providing Mod"), &ConflictItem::after}}) -{} - -NoConflictListModel::NoConflictListModel(QTreeView* tree) - : ConflictListModel(tree, {{tr("File"), &ConflictItem::relativeName}}) -{} - -AdvancedConflictListModel::AdvancedConflictListModel(QTreeView* tree) - : ConflictListModel(tree, {{tr("Overwrites"), &ConflictItem::before}, - {tr("File"), &ConflictItem::relativeName}, - {tr("Overwritten By"), &ConflictItem::after}}) -{} +#include "modinfodialogconflictsmodels.h" +#include "modinfodialog.h" +#include + +using MOBase::naturalCompare; + +ConflictItem::ConflictItem(QString before, QString relativeName, QString after, + MOShared::FileIndex index, QString fileName, + bool hasAltOrigins, QString altOrigin, bool archive) + : m_before(std::move(before)), m_relativeName(std::move(relativeName)), + m_after(std::move(after)), m_index(index), m_fileName(std::move(fileName)), + m_hasAltOrigins(hasAltOrigins), m_altOrigin(std::move(altOrigin)), + m_isArchive(archive) +{} + +const QString& ConflictItem::before() const +{ + return m_before; +} + +const QString& ConflictItem::relativeName() const +{ + return m_relativeName; +} + +const QString& ConflictItem::after() const +{ + return m_after; +} + +const QString& ConflictItem::fileName() const +{ + return m_fileName; +} + +const QString& ConflictItem::altOrigin() const +{ + return m_altOrigin; +} + +bool ConflictItem::hasAlts() const +{ + return m_hasAltOrigins; +} + +bool ConflictItem::isArchive() const +{ + return m_isArchive; +} + +MOShared::FileIndex ConflictItem::fileIndex() const +{ + return m_index; +} + +bool ConflictItem::canHide() const +{ + return canHideFile(isArchive(), fileName()); +} + +bool ConflictItem::canUnhide() const +{ + return canUnhideFile(isArchive(), fileName()); +} + +bool ConflictItem::canRun() const +{ + return canRunFile(isArchive(), fileName()); +} + +bool ConflictItem::canOpen() const +{ + return canOpenFile(isArchive(), fileName()); +} + +bool ConflictItem::canPreview(PluginContainer& pluginContainer) const +{ + return canPreviewFile(pluginContainer, isArchive(), fileName()); +} + +bool ConflictItem::canExplore() const +{ + return canExploreFile(isArchive(), fileName()); +} + +ConflictListModel::ConflictListModel(QTreeView* tree, std::vector columns) + : m_tree(tree), m_columns(std::move(columns)) + +{ + m_tree->setModel(this); +} + +void ConflictListModel::clear() +{ + beginResetModel(); + m_items.clear(); + endResetModel(); +} + +void ConflictListModel::reserve(std::size_t s) +{ + m_items.reserve(s); +} + +QModelIndex ConflictListModel::index(int row, int col, const QModelIndex&) const +{ + return createIndex(row, col); +} + +QModelIndex ConflictListModel::parent(const QModelIndex&) const +{ + return {}; +} + +int ConflictListModel::rowCount(const QModelIndex& parent) const +{ + if (parent.isValid()) { + return 0; + } + + return static_cast(m_items.size()); +} + +int ConflictListModel::columnCount(const QModelIndex&) const +{ + return static_cast(m_columns.size()); +} + +const ConflictItem* ConflictListModel::itemFromIndex(const QModelIndex& index) const +{ + const auto row = index.row(); + if (row < 0) { + return nullptr; + } + + const auto i = static_cast(row); + if (i >= m_items.size()) { + return nullptr; + } + + return &m_items[i]; +} + +QModelIndex ConflictListModel::indexFromItem(const ConflictItem* item, int col) +{ + for (std::size_t i = 0; i < m_items.size(); ++i) { + if (&m_items[i] == item) { + return createIndex(static_cast(i), col); + } + } + + return {}; +} + +QVariant ConflictListModel::data(const QModelIndex& index, int role) const +{ + if (role == Qt::DisplayRole || role == Qt::FontRole) { + const ConflictItem* item = itemFromIndex(index); + if (!item) { + return {}; + } + + const auto col = index.column(); + if (col < 0) { + return {}; + } + + const auto c = static_cast(col); + if (c >= m_columns.size()) { + return {}; + } + + if (role == Qt::DisplayRole) { + return (item->*m_columns[c].getText)(); + } else if (role == Qt::FontRole) { + if (item->isArchive()) { + QFont f = m_tree->font(); + f.setItalic(true); + return f; + } + } + } + + return {}; +} + +QVariant ConflictListModel::headerData(int col, Qt::Orientation, int role) const +{ + if (role == Qt::DisplayRole) { + if (col < 0) { + return {}; + } + + const auto i = static_cast(col); + if (i >= m_columns.size()) { + return {}; + } + + return m_columns[i].caption; + } + + return {}; +} + +void ConflictListModel::sort(int colIndex, Qt::SortOrder order) +{ + m_sortColumn = colIndex; + m_sortOrder = order; + + emit layoutAboutToBeChanged({}, QAbstractItemModel::VerticalSortHint); + + const auto oldList = persistentIndexList(); + std::vector> oldItems; + + const auto itemCount = oldList.size(); + oldItems.reserve(static_cast(itemCount)); + + for (int i = 0; i < itemCount; ++i) { + const QModelIndex& index = oldList[i]; + oldItems.emplace_back(itemFromIndex(index), index.column()); + } + + doSort(); + + QModelIndexList newList; + newList.reserve(itemCount); + + for (int i = 0; i < itemCount; ++i) { + const auto& pair = oldItems[static_cast(i)]; + newList.append(indexFromItem(pair.first, pair.second)); + } + + changePersistentIndexList(oldList, newList); + + emit layoutChanged({}, QAbstractItemModel::VerticalSortHint); +} + +void ConflictListModel::add(ConflictItem item) +{ + m_items.emplace_back(std::move(item)); +} + +void ConflictListModel::finished() +{ + beginResetModel(); + endResetModel(); + + sort(m_sortColumn, m_sortOrder); +} + +const ConflictItem* ConflictListModel::getItem(std::size_t row) const +{ + if (row >= m_items.size()) { + return nullptr; + } + + return &m_items[row]; +} + +void ConflictListModel::doSort() +{ + if (m_items.empty()) { + return; + } + + if (m_sortColumn < 0) { + return; + } + + const auto c = static_cast(m_sortColumn); + if (c >= m_columns.size()) { + return; + } + + const auto& col = m_columns[c]; + + // avoids branching on sort order while sorting + auto sortAsc = [&](const auto& a, const auto& b) { + return (naturalCompare((a.*col.getText)(), (b.*col.getText)()) < 0); + }; + + auto sortDesc = [&](const auto& a, const auto& b) { + return (naturalCompare((a.*col.getText)(), (b.*col.getText)()) > 0); + }; + + if (m_sortOrder == Qt::AscendingOrder) { + std::sort(m_items.begin(), m_items.end(), sortAsc); + } else { + std::sort(m_items.begin(), m_items.end(), sortDesc); + } +} + +OverwriteConflictListModel::OverwriteConflictListModel(QTreeView* tree) + : ConflictListModel(tree, {{.caption=tr("File"), .getText=&ConflictItem::relativeName}, + {.caption=tr("Overwritten Mods"), .getText=&ConflictItem::before}}) +{} + +OverwrittenConflictListModel::OverwrittenConflictListModel(QTreeView* tree) + : ConflictListModel(tree, {{.caption=tr("File"), .getText=&ConflictItem::relativeName}, + {.caption=tr("Providing Mod"), .getText=&ConflictItem::after}}) +{} + +NoConflictListModel::NoConflictListModel(QTreeView* tree) + : ConflictListModel(tree, {{.caption=tr("File"), .getText=&ConflictItem::relativeName}}) +{} + +AdvancedConflictListModel::AdvancedConflictListModel(QTreeView* tree) + : ConflictListModel(tree, {{.caption=tr("Overwrites"), .getText=&ConflictItem::before}, + {.caption=tr("File"), .getText=&ConflictItem::relativeName}, + {.caption=tr("Overwritten By"), .getText=&ConflictItem::after}}) +{} diff --git a/src/src/modinfodialogesps.cpp b/src/src/modinfodialogesps.cpp index afce42a..b4152c5 100644 --- a/src/src/modinfodialogesps.cpp +++ b/src/src/modinfodialogesps.cpp @@ -1,371 +1,371 @@ -#include "modinfodialogesps.h" -#include "modinfodialog.h" -#include "settings.h" -#include "ui_modinfodialog.h" -#include -#include - -using namespace MOBase; - -class ESPItem -{ -public: - ESPItem(QString rootPath, QString relativePath) - : m_rootPath(std::move(rootPath)) - { - if (relativePath.contains('/') || relativePath.contains('\\')) { - m_inactivePath = relativePath; - } else { - m_activePath = relativePath; - m_active = true; - } - - pathChanged(); - } - - const QString& rootPath() const { return m_rootPath; } - - const QString& relativePath() const - { - if (m_active) { - return m_activePath; - } else { - return m_inactivePath; - } - } - - const QString& filename() const { return m_filename; } - - const QString& activePath() const { return m_activePath; } - - const QString& inactivePath() const { return m_inactivePath; } - - const QFileInfo& fileInfo() const { return m_fileInfo; } - - bool isActive() const { return m_active; } - - bool activate(const QString& newName) - { - QDir root(m_rootPath); - - if (root.rename(m_inactivePath, newName)) { - m_active = true; - m_activePath = newName; - - if (QFileInfo(m_inactivePath).fileName() != newName) { - // file was renamed - m_inactivePath = QFileInfo(m_inactivePath).path() + QDir::separator() + newName; - } - - pathChanged(); - - return true; - } - - return false; - } - - bool deactivate(const QString& newName) - { - QDir root(m_rootPath); - - if (root.rename(m_activePath, newName)) { - m_active = false; - m_inactivePath = newName; - pathChanged(); - return true; - } - - return false; - } - -private: - QString m_rootPath; - QString m_activePath; - QString m_inactivePath; - QString m_filename; - QFileInfo m_fileInfo; - bool m_active{false}; - - void pathChanged() - { - m_fileInfo.setFile(m_rootPath + QDir::separator() + relativePath()); - m_filename = m_fileInfo.fileName(); - } -}; - -class ESPListModel : public QAbstractItemModel -{ -public: - void clear() - { - beginResetModel(); - m_esps.clear(); - endResetModel(); - } - - QModelIndex index(int row, int col, const QModelIndex& = {}) const override - { - return createIndex(row, col); - } - - QModelIndex parent(const QModelIndex&) const override { return {}; } - - int rowCount(const QModelIndex& = {}) const override - { - return static_cast(m_esps.size()); - } - - int columnCount(const QModelIndex& = {}) const override { return 1; } - - QVariant data(const QModelIndex& index, int role) const override - { - if (role == Qt::DisplayRole) { - if (auto* esp = getESP(index)) { - return esp->filename(); - } - } - - return {}; - } - - void add(ESPItem esp) { m_esps.emplace_back(std::move(esp)); } - - void addOne(ESPItem esp) - { - const auto i = m_esps.size(); - - beginInsertRows({}, static_cast(i), static_cast(i)); - add(std::move(esp)); - endInsertRows(); - } - - bool removeRows(int row, int count, const QModelIndex& = {}) override - { - if (row < 0) { - return false; - } - - const auto start = static_cast(row); - if (start >= m_esps.size()) { - return false; - } - - const auto end = std::min(start + static_cast(count), m_esps.size()); - - beginRemoveRows({}, static_cast(start), static_cast(end)); - m_esps.erase(m_esps.begin() + start, m_esps.begin() + end); - endRemoveRows(); - - return true; - } - - void finished() - { - beginResetModel(); - - std::sort(m_esps.begin(), m_esps.end(), [](const auto& a, const auto& b) { - return (naturalCompare(a.filename(), b.filename()) < 0); - }); - - endResetModel(); - } - - const ESPItem* getESP(const QModelIndex& index) const - { - const auto row = index.row(); - if (row < 0) { - return nullptr; - } - - const auto i = static_cast(row); - if (i >= m_esps.size()) { - return nullptr; - } - - return &m_esps[i]; - } - - ESPItem* getESP(const QModelIndex& index) - { - return const_cast(std::as_const(*this).getESP(index)); - } - -private: - std::deque m_esps; -}; - -ESPsTab::ESPsTab(ModInfoDialogTabContext cx) - : ModInfoDialogTab(std::move(cx)), m_inactiveModel(new ESPListModel), - m_activeModel(new ESPListModel) -{ - ui->inactiveESPList->setModel(m_inactiveModel); - ui->activeESPList->setModel(m_activeModel); - - QObject::connect(ui->activateESP, &QToolButton::clicked, [&] { - onActivate(); - }); - - QObject::connect(ui->deactivateESP, &QToolButton::clicked, [&] { - onDeactivate(); - }); -} - -void ESPsTab::clear() -{ - m_inactiveModel->clear(); - m_activeModel->clear(); - setHasData(false); -} - -bool ESPsTab::feedFile(const QString& rootPath, const QString& fullPath) -{ - static const QString extensions[] = {".esp", ".esm", ".esl"}; - - for (const auto& e : extensions) { - if (fullPath.endsWith(e, Qt::CaseInsensitive)) { - ESPItem esp(rootPath, fullPath.mid(rootPath.length() + 1)); - - if (esp.isActive()) { - m_activeModel->add(std::move(esp)); - } else { - m_inactiveModel->add(std::move(esp)); - } - - return true; - } - } - - return false; -} - -void ESPsTab::update() -{ - m_inactiveModel->finished(); - m_activeModel->finished(); - - setHasData(m_inactiveModel->rowCount() > 0 || m_activeModel->rowCount() > 0); -} - -void ESPsTab::saveState(Settings& s) -{ - s.geometry().saveState(ui->ESPsSplitter); -} - -void ESPsTab::restoreState(const Settings& s) -{ - s.geometry().restoreState(ui->ESPsSplitter); -} - -void ESPsTab::onActivate() -{ - const auto index = ui->inactiveESPList->currentIndex(); - if (!index.isValid()) { - return; - } - - auto* esp = m_inactiveModel->getESP(index); - if (!esp) { - return; - } - - if (esp->isActive()) { - log::warn("ESPsTab::onActive(): item is already active"); - return; - } - - QDir const root(esp->rootPath()); - const QFileInfo file(esp->fileInfo()); - - QString newName = file.fileName(); - - while (root.exists(newName)) { - bool okClicked = false; - - newName = QInputDialog::getText( - parentWidget(), QObject::tr("File Exists"), - QObject::tr("A file with that name exists, please enter a new one"), - QLineEdit::Normal, file.fileName(), &okClicked); - - if (!okClicked) { - return; - } - - if (newName.isEmpty()) { - newName = file.fileName(); - } - } - - if (esp->activate(newName)) { - // copy esp, original will be destroyed - auto copy = *esp; - m_inactiveModel->removeRow(index.row()); - m_activeModel->addOne(std::move(copy)); - selectRow(ui->inactiveESPList, index.row()); - } else { - reportError(QObject::tr("Failed to move file")); - } -} - -void ESPsTab::onDeactivate() -{ - const auto index = ui->activeESPList->currentIndex(); - if (!index.isValid()) { - return; - } - - auto* esp = m_activeModel->getESP(index); - if (!esp) { - return; - } - - if (!esp->isActive()) { - log::warn("ESPsTab::onDeactivate(): item is already inactive"); - return; - } - - QDir const root(esp->rootPath()); - - // if we moved the file from optional to active in this session, we move the - // file back to where it came from. Otherwise, it is moved to the new folder - // "optional" - - QString newName = esp->inactivePath(); - - if (newName.isEmpty()) { - if (!root.exists("optional")) { - if (!root.mkdir("optional")) { - reportError(QObject::tr("Failed to create directory \"optional\"")); - return; - } - } - - newName = QString("optional") + QDir::separator() + esp->fileInfo().fileName(); - } - - if (esp->deactivate(newName)) { - // copy esp, original will be destroyed - auto copy = *esp; - - m_activeModel->removeRow(index.row()); - m_inactiveModel->addOne(std::move(copy)); - selectRow(ui->activeESPList, index.row()); - } else { - reportError(QObject::tr("Failed to move file")); - } -} - -void ESPsTab::selectRow(QListView* list, int row) -{ - const auto* model = list->model(); - const auto count = model->rowCount(); - if (count == 0) { - return; - } - - if (row >= count) { - list->setCurrentIndex(model->index(count - 1, 0)); - } else { - list->setCurrentIndex(model->index(row, 0)); - } -} +#include "modinfodialogesps.h" +#include "modinfodialog.h" +#include "settings.h" +#include "ui_modinfodialog.h" +#include +#include + +using namespace MOBase; + +class ESPItem +{ +public: + ESPItem(QString rootPath, QString relativePath) + : m_rootPath(std::move(rootPath)) + { + if (relativePath.contains('/') || relativePath.contains('\\')) { + m_inactivePath = relativePath; + } else { + m_activePath = relativePath; + m_active = true; + } + + pathChanged(); + } + + const QString& rootPath() const { return m_rootPath; } + + const QString& relativePath() const + { + if (m_active) { + return m_activePath; + } else { + return m_inactivePath; + } + } + + const QString& filename() const { return m_filename; } + + const QString& activePath() const { return m_activePath; } + + const QString& inactivePath() const { return m_inactivePath; } + + const QFileInfo& fileInfo() const { return m_fileInfo; } + + bool isActive() const { return m_active; } + + bool activate(const QString& newName) + { + QDir root(m_rootPath); + + if (root.rename(m_inactivePath, newName)) { + m_active = true; + m_activePath = newName; + + if (QFileInfo(m_inactivePath).fileName() != newName) { + // file was renamed + m_inactivePath = QFileInfo(m_inactivePath).path() + QDir::separator() + newName; + } + + pathChanged(); + + return true; + } + + return false; + } + + bool deactivate(const QString& newName) + { + QDir root(m_rootPath); + + if (root.rename(m_activePath, newName)) { + m_active = false; + m_inactivePath = newName; + pathChanged(); + return true; + } + + return false; + } + +private: + QString m_rootPath; + QString m_activePath; + QString m_inactivePath; + QString m_filename; + QFileInfo m_fileInfo; + bool m_active{false}; + + void pathChanged() + { + m_fileInfo.setFile(m_rootPath + QDir::separator() + relativePath()); + m_filename = m_fileInfo.fileName(); + } +}; + +class ESPListModel : public QAbstractItemModel +{ +public: + void clear() + { + beginResetModel(); + m_esps.clear(); + endResetModel(); + } + + QModelIndex index(int row, int col, const QModelIndex& = {}) const override + { + return createIndex(row, col); + } + + QModelIndex parent(const QModelIndex&) const override { return {}; } + + int rowCount(const QModelIndex& = {}) const override + { + return static_cast(m_esps.size()); + } + + int columnCount(const QModelIndex& = {}) const override { return 1; } + + QVariant data(const QModelIndex& index, int role) const override + { + if (role == Qt::DisplayRole) { + if (const auto* esp = getESP(index)) { + return esp->filename(); + } + } + + return {}; + } + + void add(ESPItem esp) { m_esps.emplace_back(std::move(esp)); } + + void addOne(ESPItem esp) + { + const auto i = m_esps.size(); + + beginInsertRows({}, static_cast(i), static_cast(i)); + add(std::move(esp)); + endInsertRows(); + } + + bool removeRows(int row, int count, const QModelIndex& = {}) override + { + if (row < 0) { + return false; + } + + const auto start = static_cast(row); + if (start >= m_esps.size()) { + return false; + } + + const auto end = std::min(start + static_cast(count), m_esps.size()); + + beginRemoveRows({}, static_cast(start), static_cast(end)); + m_esps.erase(m_esps.begin() + start, m_esps.begin() + end); + endRemoveRows(); + + return true; + } + + void finished() + { + beginResetModel(); + + std::sort(m_esps.begin(), m_esps.end(), [](const auto& a, const auto& b) { + return (naturalCompare(a.filename(), b.filename()) < 0); + }); + + endResetModel(); + } + + const ESPItem* getESP(const QModelIndex& index) const + { + const auto row = index.row(); + if (row < 0) { + return nullptr; + } + + const auto i = static_cast(row); + if (i >= m_esps.size()) { + return nullptr; + } + + return &m_esps[i]; + } + + ESPItem* getESP(const QModelIndex& index) + { + return const_cast(std::as_const(*this).getESP(index)); + } + +private: + std::deque m_esps; +}; + +ESPsTab::ESPsTab(ModInfoDialogTabContext cx) + : ModInfoDialogTab(std::move(cx)), m_inactiveModel(new ESPListModel), + m_activeModel(new ESPListModel) +{ + ui->inactiveESPList->setModel(m_inactiveModel); + ui->activeESPList->setModel(m_activeModel); + + QObject::connect(ui->activateESP, &QToolButton::clicked, [&] { + onActivate(); + }); + + QObject::connect(ui->deactivateESP, &QToolButton::clicked, [&] { + onDeactivate(); + }); +} + +void ESPsTab::clear() +{ + m_inactiveModel->clear(); + m_activeModel->clear(); + setHasData(false); +} + +bool ESPsTab::feedFile(const QString& rootPath, const QString& fullPath) +{ + static const QString extensions[] = {".esp", ".esm", ".esl"}; + + for (const auto& e : extensions) { + if (fullPath.endsWith(e, Qt::CaseInsensitive)) { + ESPItem esp(rootPath, fullPath.mid(rootPath.length() + 1)); + + if (esp.isActive()) { + m_activeModel->add(std::move(esp)); + } else { + m_inactiveModel->add(std::move(esp)); + } + + return true; + } + } + + return false; +} + +void ESPsTab::update() +{ + m_inactiveModel->finished(); + m_activeModel->finished(); + + setHasData(m_inactiveModel->rowCount() > 0 || m_activeModel->rowCount() > 0); +} + +void ESPsTab::saveState(Settings& s) +{ + s.geometry().saveState(ui->ESPsSplitter); +} + +void ESPsTab::restoreState(const Settings& s) +{ + s.geometry().restoreState(ui->ESPsSplitter); +} + +void ESPsTab::onActivate() +{ + const auto index = ui->inactiveESPList->currentIndex(); + if (!index.isValid()) { + return; + } + + auto* esp = m_inactiveModel->getESP(index); + if (!esp) { + return; + } + + if (esp->isActive()) { + log::warn("ESPsTab::onActive(): item is already active"); + return; + } + + QDir const root(esp->rootPath()); + const QFileInfo file(esp->fileInfo()); + + QString newName = file.fileName(); + + while (root.exists(newName)) { + bool okClicked = false; + + newName = QInputDialog::getText( + parentWidget(), QObject::tr("File Exists"), + QObject::tr("A file with that name exists, please enter a new one"), + QLineEdit::Normal, file.fileName(), &okClicked); + + if (!okClicked) { + return; + } + + if (newName.isEmpty()) { + newName = file.fileName(); + } + } + + if (esp->activate(newName)) { + // copy esp, original will be destroyed + auto copy = *esp; + m_inactiveModel->removeRow(index.row()); + m_activeModel->addOne(std::move(copy)); + selectRow(ui->inactiveESPList, index.row()); + } else { + reportError(QObject::tr("Failed to move file")); + } +} + +void ESPsTab::onDeactivate() +{ + const auto index = ui->activeESPList->currentIndex(); + if (!index.isValid()) { + return; + } + + auto* esp = m_activeModel->getESP(index); + if (!esp) { + return; + } + + if (!esp->isActive()) { + log::warn("ESPsTab::onDeactivate(): item is already inactive"); + return; + } + + QDir const root(esp->rootPath()); + + // if we moved the file from optional to active in this session, we move the + // file back to where it came from. Otherwise, it is moved to the new folder + // "optional" + + QString newName = esp->inactivePath(); + + if (newName.isEmpty()) { + if (!root.exists("optional")) { + if (!root.mkdir("optional")) { + reportError(QObject::tr("Failed to create directory \"optional\"")); + return; + } + } + + newName = QString("optional") + QDir::separator() + esp->fileInfo().fileName(); + } + + if (esp->deactivate(newName)) { + // copy esp, original will be destroyed + auto copy = *esp; + + m_activeModel->removeRow(index.row()); + m_inactiveModel->addOne(std::move(copy)); + selectRow(ui->activeESPList, index.row()); + } else { + reportError(QObject::tr("Failed to move file")); + } +} + +void ESPsTab::selectRow(QListView* list, int row) +{ + const auto* model = list->model(); + const auto count = model->rowCount(); + if (count == 0) { + return; + } + + if (row >= count) { + list->setCurrentIndex(model->index(count - 1, 0)); + } else { + list->setCurrentIndex(model->index(row, 0)); + } +} diff --git a/src/src/modinfodialogimages.cpp b/src/src/modinfodialogimages.cpp index 3f02024..7e59e72 100644 --- a/src/src/modinfodialogimages.cpp +++ b/src/src/modinfodialogimages.cpp @@ -1,1095 +1,1097 @@ -#include "modinfodialogimages.h" -#include "settings.h" -#include "ui_modinfodialog.h" -#include "utility.h" -#include - -using namespace MOBase; -using namespace ImagesTabHelpers; - -QSize resizeWithAspectRatio(const QSize& original, const QSize& available) -{ - const auto ratio = - std::min({1.0, static_cast(available.width()) / original.width(), - static_cast(available.height()) / original.height()}); - - const QSize scaledSize(static_cast(std::round(original.width() * ratio)), - static_cast(std::round(original.height() * ratio))); - - return scaledSize; -} - -QRect centeredRect(const QRect& rect, const QSize& size) -{ - return {(rect.left() + rect.width() / 2) - size.width() / 2, - (rect.top() + rect.height() / 2) - size.height() / 2, size.width(), - size.height()}; -} - -QString dimensionString(const QSize& s) -{ - return QString::fromUtf8("%1 \xc3\x97 %2").arg(s.width()).arg(s.height()); -} - -ImagesTab::ImagesTab(ModInfoDialogTabContext cx) - : ModInfoDialogTab(std::move(cx)), m_image(new ScalableImage) -{ - getSupportedFormats(); - - auto* ly = new QVBoxLayout(ui->imagesImage); - ly->setContentsMargins({0, 0, 0, 0}); - ly->addWidget(m_image); - - delete ui->imagesThumbnails->layout(); - - ui->tabImagesSplitter->setSizes({128, 1}); - ui->tabImagesSplitter->setStretchFactor(0, 0); - ui->tabImagesSplitter->setStretchFactor(1, 1); - - ui->previewPluginButton->setEnabled(false); - - ui->imagesThumbnails->setTab(this); - - ui->imagesScrollerVBar->setTab(this); - connect(ui->imagesScrollerVBar, &QScrollBar::valueChanged, [&] { - onScrolled(); - }); - - ui->imagesShowDDS->setEnabled(m_ddsAvailable); - - m_filter.setEdit(ui->imagesFilter); - connect(&m_filter, &FilterWidget::changed, [&] { - onFilterChanged(); - }); - - connect(ui->imagesExplore, &QAbstractButton::clicked, [&] { - onExplore(); - }); - connect(ui->imagesShowDDS, &QCheckBox::toggled, [&] { - onShowDDS(); - }); - connect(ui->previewPluginButton, &QAbstractButton::clicked, [&] { - onPreviewButton(); - }); - - ui->imagesShowDDS->setEnabled(m_ddsAvailable); - - ui->imagesThumbnails->setAutoFillBackground(false); - - if (ui->imagesThumbnails->palette().color(QPalette::Base) != Qt::transparent) { - ui->imagesThumbnails->setAttribute(Qt::WA_OpaquePaintEvent, true); - } - - { - auto list = std::make_unique(); - parentWidget()->style()->polish(list.get()); - - m_theme.borderColor = QColor(Qt::black); - m_theme.backgroundColor = QColor(Qt::black); - m_theme.textColor = list->palette().color(QPalette::WindowText); - - m_theme.highlightBackgroundColor = list->palette().color(QPalette::Highlight); - m_theme.highlightTextColor = list->palette().color(QPalette::HighlightedText); - - m_theme.font = list->font(); - - const QFontMetrics fm(m_theme.font); - m_metrics.textHeight = fm.height(); - - m_image->setColors(m_theme.borderColor, m_theme.backgroundColor); - } -} - -void ImagesTab::clear() -{ - m_files.clear(); - ui->imagesScrollerVBar->setValue(0); - select(BadIndex); - setHasData(false); -} - -bool ImagesTab::feedFile(const QString& rootPath, const QString& fullPath) -{ - for (const auto& ext : m_supportedFormats) { - if (fullPath.endsWith(ext, Qt::CaseInsensitive)) { - m_files.add({fullPath}); - return true; - } - } - - return false; -} - -void ImagesTab::update() -{ - checkFiltering(); - updateScrollbar(); - - // visibility needs to be rechecked here because the scrollbar configuration - // may have changed in updateScrollbar(), in which case any ensureVisible() - // calls in checkFiltering() might have been incorrect - if (m_files.selectedIndex() != BadIndex) { - ensureVisible(m_files.selectedIndex(), Visibility::Partial); - } - - ui->imagesThumbnails->update(); - - setHasData(!m_files.empty()); -} - -void ImagesTab::saveState(Settings& s) -{ - s.widgets().saveChecked(ui->imagesShowDDS); - s.geometry().saveState(ui->tabImagesSplitter); -} - -void ImagesTab::restoreState(const Settings& s) -{ - s.widgets().restoreChecked(ui->imagesShowDDS); - s.geometry().restoreState(ui->tabImagesSplitter); -} - -void ImagesTab::checkFiltering() -{ - if (m_filter.empty() && m_ddsEnabled) { - // no filtering needed - - if (m_files.isFiltered()) { - // was filtered, needs switch - switchToAll(); - } - } else { - // filtering is needed - switchToFiltered(); - } -} - -void ImagesTab::switchToAll() -{ - // remember selection - const auto* oldSelection = m_files.selectedFile(); - - // switch - m_files.switchToAll(); - - // reselect old - if (oldSelection) { - select(m_files.indexOf(oldSelection)); - } else { - select(BadIndex); - } -} - -void ImagesTab::switchToFiltered() -{ - // remember old selection, will be checked when building the filtered list - // below - const auto* oldSelection = m_files.selectedFile(); - std::size_t newSelection = BadIndex; - - // switch, also clears list - m_files.switchToFiltered(); - - const bool hasTextFilter = !m_filter.empty(); - - for (File& f : m_files.allFiles()) { - if (hasTextFilter) { - // check filter widget - const auto m = m_filter.matches([&](const QRegularExpression& regex) { - return regex.match(f.filename()).hasMatch(); - }); - - if (!m) { - // no match, skip - continue; - } - } - - if (!m_ddsEnabled) { - // skip .dds files - if (f.filename().endsWith(".dds", Qt::CaseInsensitive)) { - continue; - } - } - - if (&f == oldSelection) { - // found the old selection, remember its index - newSelection = m_files.size(); - } - - m_files.addFiltered(&f); - } - - // reselect old, or clear if it wasn't found - select(newSelection); -} - -void ImagesTab::getSupportedFormats() -{ - m_ddsAvailable = false; - - for (const auto& entry : QImageReader::supportedImageFormats()) { - QString s(entry); - if (s.isNull() || s.isEmpty()) { - continue; - } - - // used to enable the checkbox - if (s.compare("dds", Qt::CaseInsensitive) == 0) { - m_ddsAvailable = true; - } - - // make sure it starts with a dot - if (s[0] != '.') { - s = '.' + s; - } - - m_supportedFormats.emplace_back(std::move(s)); - } -} - -void ImagesTab::select(std::size_t i, Visibility v) -{ - m_files.select(i); - - if (auto* f = m_files.selectedFile()) { - // when jumping elsewhere in the list, such as with page down/up, the file - // might not be visible yet, which means it hasn't been loaded and would - // pass a null image in setImage() below - f->ensureOriginalLoaded(); - - ui->imagesPath->setText(QDir::toNativeSeparators(f->path())); - ui->imagesExplore->setEnabled(true); - if (plugin().previewGenerator().previewSupported( - QFileInfo(f->path()).suffix().toLower(), false)) - ui->previewPluginButton->setEnabled(true); - else - ui->previewPluginButton->setEnabled(false); - ui->imagesSize->setText(dimensionString(f->original().size())); - - if (f->original().isNull()) { - m_image->clear(); - - QImage image(300, 100, QImage::Format_RGBA64); - QPainter paint; - paint.begin(&image); - paint.fillRect(0, 0, 300, 100, QBrush(QColor(0, 0, 0, 255))); - paint.setPen(m_theme.textColor); - paint.setFont(m_theme.font); - paint.drawImage(QPoint(150 - 16, 50 - 20 - 16), QImage(":/MO/gui/warning")); - const auto flags = Qt::AlignHCenter | Qt::AlignVCenter | Qt::TextWordWrap; - paint.drawText(0, 46, 300, 54, flags, - "This image format is not supported by Qt, but the preview plugin " - "may be able to display it. Use the button above."); - paint.end(); - - m_image->setImage(image); - } else - m_image->setImage(f->original()); - ensureVisible(i, v); - } else { - ui->imagesPath->clear(); - ui->imagesExplore->setEnabled(false); - ui->previewPluginButton->setEnabled(false); - ui->imagesSize->clear(); - m_image->clear(); - } - - ui->imagesThumbnails->update(); -} - -void ImagesTab::moveSelection(int by) -{ - if (m_files.empty()) { - return; - } - - auto i = m_files.selectedIndex(); - if (i == BadIndex) { - i = 0; - } - - if (by > 0) { - // moving down - i += static_cast(by); - - if (i >= m_files.size()) { - i = (m_files.size() - 1); - } - } else if (by < 0) { - // moving up - const auto abs_by = static_cast(std::abs(by)); - - if (abs_by > i) { - i = 0; - } else { - i -= abs_by; - } - } - - select(i); -} - -void ImagesTab::ensureVisible(std::size_t i, Visibility v) -{ - if (v == Visibility::Ignore) { - return; - } - - const auto geo = makeGeometry(); - - const auto fullyVisible = geo.fullyVisibleCount(); - const auto partiallyVisible = fullyVisible + 1; - - const auto first = ui->imagesScrollerVBar->value(); - const auto last = - (v == Visibility::Full ? first + fullyVisible : first + partiallyVisible); - - if (i < first) { - // go up - ui->imagesScrollerVBar->setValue(static_cast(i)); - } else if (i >= last) { - // go down - - if (i >= fullyVisible) { - ui->imagesScrollerVBar->setValue(static_cast(i - fullyVisible + 1)); - } - } -} - -std::size_t ImagesTab::fileIndexAtPos(const QPoint& p) const -{ - const auto geo = makeGeometry(); - - // this is the index relative to the top - const auto offset = geo.indexAt(p); - if (offset == BadIndex) { - return BadIndex; - } - - const auto first = ui->imagesScrollerVBar->value(); - if (first < 0) { - return BadIndex; - } - - const auto i = static_cast(first) + offset; - if (i >= m_files.size()) { - return BadIndex; - } - - return i; -} - -const File* ImagesTab::fileAtPos(const QPoint& p) const -{ - const auto i = fileIndexAtPos(p); - if (i >= m_files.size()) { - return nullptr; - } - - return m_files.get(i); -} - -Geometry ImagesTab::makeGeometry() const -{ - return {ui->imagesThumbnails->size(), m_metrics}; -} - -void ImagesTab::paintThumbnailsArea(QPaintEvent* e) -{ - PaintContext cx(ui->imagesThumbnails, makeGeometry()); - - cx.painter.fillRect(ui->imagesThumbnails->rect(), - ui->imagesThumbnails->palette().color(QPalette::Window)); - - const auto visible = cx.geo.fullyVisibleCount() + 1; - const auto first = ui->imagesScrollerVBar->value(); - - for (std::size_t i = 0; i < visible; ++i) { - const auto fileIndex = first + i; - auto* file = m_files.get(fileIndex); - if (!file) { - break; - } - - cx.file = file; - cx.thumbIndex = i; - cx.fileIndex = fileIndex; - - paintThumbnail(cx); - } -} - -void ImagesTab::paintThumbnail(const PaintContext& cx) -{ - paintThumbnailBackground(cx); - paintThumbnailBorder(cx); - paintThumbnailImage(cx); - paintThumbnailText(cx); -} - -void ImagesTab::paintThumbnailBackground(const PaintContext& cx) -{ - if (m_files.selectedIndex() == cx.fileIndex) { - const auto rect = cx.geo.thumbRect(cx.thumbIndex); - cx.painter.fillRect(rect, m_theme.highlightBackgroundColor); - } -} - -void ImagesTab::paintThumbnailBorder(const PaintContext& cx) const -{ - auto borderRect = cx.geo.borderRect(cx.thumbIndex); - - // rects don't include the bottom right corner, but drawRect() does, so - // resize it - borderRect.setRight(borderRect.right() - 1); - borderRect.setBottom(borderRect.bottom() - 1); - - cx.painter.setPen(m_theme.borderColor); - cx.painter.drawRect(borderRect); -} - -void ImagesTab::paintThumbnailImage(const PaintContext& cx) const -{ - if (cx.file->failed()) { - return; - } - - cx.file->loadIfNeeded(cx.geo); - - const auto imageRect = cx.geo.imageRect(cx.thumbIndex); - const auto scaledThumbRect = centeredRect(imageRect, cx.file->thumbnail().size()); - - cx.painter.fillRect(scaledThumbRect, m_theme.backgroundColor); - cx.painter.drawImage(scaledThumbRect, cx.file->thumbnail()); -} - -void ImagesTab::paintThumbnailText(const PaintContext& cx) -{ - const auto tr = cx.geo.textRect(cx.thumbIndex); - - if (cx.fileIndex == m_files.selectedIndex()) { - cx.painter.setPen(m_theme.highlightTextColor); - } else { - cx.painter.setPen(m_theme.textColor); - } - - cx.painter.setFont(m_theme.font); - - QFontMetrics const fm(m_theme.font); - - const auto text = fm.elidedText(cx.file->filename(), Qt::ElideRight, tr.width()); - - const auto flags = Qt::AlignHCenter | Qt::AlignVCenter | Qt::TextSingleLine; - - cx.painter.drawText(tr, flags, text); -} - -void ImagesTab::scrollAreaResized(const QSize&) -{ - updateScrollbar(); -} - -void ImagesTab::thumbnailAreaMouseEvent(QMouseEvent* e) -{ - if (e->button() != Qt::LeftButton) { - return; - } - - const auto i = fileIndexAtPos(e->pos()); - if (i != BadIndex) { - // the only way to click on a thumbnail is if it's already visible, so the - // only thing that can happen is a click on a partially visible thumbnail, - // which would scroll so it is fully visible, and that's just annoying - select(i, Visibility::Ignore); - } -} - -void ImagesTab::thumbnailAreaWheelEvent(QWheelEvent* e) -{ - const auto d = (e->angleDelta() / 8).y(); - - ui->imagesScrollerVBar->setValue(ui->imagesScrollerVBar->value() + (d > 0 ? -1 : 1)); -} - -bool ImagesTab::thumbnailAreaKeyPressEvent(QKeyEvent* e) -{ - switch (e->key()) { - case Qt::Key_Down: { - moveSelection(ui->imagesScrollerVBar->singleStep()); - return true; - } - - case Qt::Key_Up: { - moveSelection(-ui->imagesScrollerVBar->singleStep()); - return true; - } - - case Qt::Key_PageDown: { - moveSelection(ui->imagesScrollerVBar->pageStep()); - return true; - } - - case Qt::Key_PageUp: { - moveSelection(-ui->imagesScrollerVBar->pageStep()); - return true; - } - - case Qt::Key_Home: { - select(0); - return true; - } - - case Qt::Key_End: { - if (!m_files.empty()) { - select(m_files.size() - 1); - } - - return true; - } - } - - return false; -} - -void ImagesTab::onScrolled() -{ - ui->imagesThumbnails->update(); -} - -void ImagesTab::showTooltip(QHelpEvent* e) -{ - const auto* f = fileAtPos(e->pos()); - if (!f) { - QToolTip::hideText(); - e->ignore(); - return; - } - - const auto s = QString("%1 (%2)") - .arg(QDir::toNativeSeparators(f->path())) - .arg(dimensionString(f->original().size())); - - QToolTip::showText(e->globalPos(), s, ui->imagesThumbnails); -} - -void ImagesTab::onExplore() -{ - if (auto* f = m_files.selectedFile()) { - shell::Explore(f->path()); - } -} - -void ImagesTab::onShowDDS() -{ - const auto b = ui->imagesShowDDS->isChecked(); - if (b != m_ddsEnabled) { - m_ddsEnabled = b; - update(); - } -} - -void ImagesTab::onPreviewButton() -{ - core().previewFileWithAlternatives(parentWidget(), m_files.selectedFile()->path()); -} - -void ImagesTab::onFilterChanged() -{ - update(); -} - -void ImagesTab::updateScrollbar() -{ - if (m_files.empty()) { - ui->imagesScrollerVBar->setRange(0, 0); - ui->imagesScrollerVBar->setEnabled(false); - return; - } - - const auto geo = makeGeometry(); - const auto availableSize = ui->imagesThumbnails->size(); - const auto fullyVisible = geo.fullyVisibleCount(); - - if (fullyVisible >= m_files.size()) { - ui->imagesScrollerVBar->setRange(0, 0); - ui->imagesScrollerVBar->setEnabled(false); - } else { - const auto d = m_files.size() - fullyVisible; - ui->imagesScrollerVBar->setRange(0, static_cast(d)); - ui->imagesScrollerVBar->setSingleStep(1); - ui->imagesScrollerVBar->setPageStep(static_cast(fullyVisible - 1)); - ui->imagesScrollerVBar->setEnabled(true); - } -} - -namespace ImagesTabHelpers -{ - -void Scrollbar::setTab(ImagesTab* tab) -{ - m_tab = tab; -} - -void Scrollbar::wheelEvent(QWheelEvent* e) -{ - if (m_tab) { - m_tab->thumbnailAreaWheelEvent(e); - } -} - -void ThumbnailsWidget::setTab(ImagesTab* tab) -{ - m_tab = tab; -} - -void ThumbnailsWidget::paintEvent(QPaintEvent* e) -{ - if (m_tab) { - m_tab->paintThumbnailsArea(e); - } -} - -void ThumbnailsWidget::mousePressEvent(QMouseEvent* e) -{ - if (m_tab) { - m_tab->thumbnailAreaMouseEvent(e); - } -} - -void ThumbnailsWidget::wheelEvent(QWheelEvent* e) -{ - if (m_tab) { - m_tab->thumbnailAreaWheelEvent(e); - } -} - -void ThumbnailsWidget::resizeEvent(QResizeEvent* e) -{ - if (m_tab) { - m_tab->scrollAreaResized(e->size()); - } -} - -void ThumbnailsWidget::keyPressEvent(QKeyEvent* e) -{ - if (m_tab) { - if (m_tab->thumbnailAreaKeyPressEvent(e)) { - return; - } - } - - QWidget::keyPressEvent(e); -} - -bool ThumbnailsWidget::event(QEvent* e) -{ - if (e->type() == QEvent::ToolTip) { - m_tab->showTooltip(static_cast(e)); - return true; - } - - return QWidget::event(e); -} - -ScalableImage::ScalableImage(QString path) : m_path(std::move(path)) -{ - auto sp = sizePolicy(); - sp.setHeightForWidth(true); - setSizePolicy(sp); -} - -void ScalableImage::setImage(const QString& path) -{ - m_path = path; - m_original = {}; - m_scaled = {}; - - update(); -} - -void ScalableImage::setImage(QImage image) -{ - m_path.clear(); - m_original = std::move(image); - m_scaled = {}; - - update(); -} - -void ScalableImage::clear() -{ - setImage(QImage()); -} - -bool ScalableImage::hasHeightForWidth() const -{ - return true; -} - -int ScalableImage::heightForWidth(int w) const -{ - return w; -} - -void ScalableImage::setColors(const QColor& border, const QColor& background) -{ - m_borderColor = border; - m_backgroundColor = background; -} - -void ScalableImage::paintEvent(QPaintEvent* e) -{ - if (m_original.isNull()) { - if (m_path.isNull()) { - return; - } - - m_original.load(m_path); - - if (m_original.isNull()) { - return; - } - } - - const QRect widgetRect = rect(); - const QRect imageRect = widgetRect.adjusted(m_border, m_border, -m_border, -m_border); - - const QSize scaledSize = resizeWithAspectRatio(m_original.size(), imageRect.size()); - - if (m_scaled.isNull() || m_scaled.size() != scaledSize) { - m_scaled = m_original.scaled(scaledSize.width(), scaledSize.height(), - Qt::IgnoreAspectRatio, Qt::SmoothTransformation); - } - - const QRect drawBorderRect = widgetRect.adjusted(0, 0, -1, -1); - const QRect drawImageRect = centeredRect(imageRect, m_scaled.size()); - - QPainter painter(this); - - // border - painter.setPen(m_borderColor); - painter.drawRect(drawBorderRect); - - // background - painter.fillRect(drawImageRect, m_backgroundColor); - - // image - painter.drawImage(drawImageRect, m_scaled); -} - -Metrics::Metrics() - -= default; - -Geometry::Geometry(QSize widgetSize, Metrics metrics) - : m_widgetSize(widgetSize), m_metrics(metrics), m_topRect(calcTopRect()) -{} - -QRect Geometry::calcTopRect() const -{ - const auto thumbWidth = m_widgetSize.width(); - const auto& m = m_metrics; - - const auto imageSize = - thumbWidth - (m.margins * 2) - (m.border * 2) - (m.padding * 2); - - const auto thumbHeight = m.margins + m.border + m.padding + imageSize + m.padding + - m.border + m.textSpacing + m.textHeight + m.margins; - - return {0, 0, thumbWidth, thumbHeight}; -} - -std::size_t Geometry::fullyVisibleCount() const -{ - const auto r = thumbRect(0); - - const auto thumbWithSpacing = r.height() + m_metrics.spacing; - const auto visible = (m_widgetSize.height() / thumbWithSpacing); - - return static_cast(visible); -} - -QRect Geometry::thumbRect(std::size_t i) const -{ - // rect for the top thumbnail - QRect r = m_topRect; - - // move down - const auto thumbWithSpacing = m_metrics.spacing + r.height(); - r.translate(0, static_cast(i * thumbWithSpacing)); - - return r; -} - -QRect Geometry::borderRect(std::size_t i) const -{ - auto r = thumbRect(i); - const auto& m = m_metrics; - - // remove margins and text - r.adjust(m.margins, m.margins, -m.margins, -m.margins); - - // remove text - r.adjust(0, 0, 0, -(m.textSpacing + m.textHeight)); - - return r; -} - -QRect Geometry::imageRect(std::size_t i) const -{ - auto r = borderRect(i); - - // remove border and padding - const auto m = m_metrics.border + m_metrics.padding; - r.adjust(m, m, -m, -m); - - return r; -} - -QRect Geometry::textRect(std::size_t i) const -{ - const auto r = borderRect(i); - - return {r.left(), r.bottom() + m_metrics.textSpacing, r.width(), - m_metrics.textHeight}; -} - -QRect Geometry::selectionRect(std::size_t i) const -{ - const auto br = borderRect(i); - const auto tr = textRect(i); - - return {br.left(), br.top(), br.width(), tr.bottom() - br.top()}; -} - -std::size_t Geometry::indexAt(const QPoint& p) const -{ - // calculate index purely based on y position - const std::size_t offset = p.y() / (m_topRect.height() + m_metrics.spacing); - - if (!selectionRect(offset).contains(p)) { - return BadIndex; - } - - return offset; -} - -QSize Geometry::scaledImageSize(const QSize& originalSize) const -{ - const auto availableSize = imageRect(0).size(); - return resizeWithAspectRatio(originalSize, availableSize); -} - -File::File(QString path) : m_path(std::move(path)) {} - -void File::ensureOriginalLoaded() -{ - if (!m_original.isNull()) { - // already loaded - return; - } - - QImageReader reader(m_path); - - if (!reader.read(&m_original)) { - log::error("failed to load '{}'\n{} (error {})", m_path, reader.errorString(), - static_cast(reader.error())); - - m_failed = true; - } -} - -const QString& File::path() const -{ - return m_path; -} - -const QString& File::filename() const -{ - if (m_filename.isEmpty()) { - m_filename = QFileInfo(m_path).fileName(); - } - - return m_filename; -} - -const QImage& File::original() const -{ - return m_original; -} - -const QImage& File::thumbnail() const -{ - return m_thumbnail; -} - -bool File::failed() const -{ - return m_failed; -} - -void File::loadIfNeeded(const Geometry& geo) -{ - if (needsLoad(geo)) { - load(geo); - } -} - -bool File::needsLoad(const Geometry& geo) const -{ - if (m_failed) { - return false; - } - - if (m_original.isNull() || m_thumbnail.isNull()) { - return true; - } - - const auto scaledSize = geo.scaledImageSize(m_original.size()); - return (m_thumbnail.size() != scaledSize); -} - -void File::load(const Geometry& geo) -{ - m_failed = false; - ensureOriginalLoaded(); - - if (m_failed) { - QImage const warning(":/MO/gui/warning"); - const auto scaledSize = geo.scaledImageSize(warning.size()); - - m_thumbnail = - warning.scaled(scaledSize, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); - } else { - const auto scaledSize = geo.scaledImageSize(m_original.size()); - - m_thumbnail = - m_original.scaled(scaledSize, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); - } -} - -Files::Files() : m_selection(BadIndex) {} - -void Files::clear() -{ - m_allFiles.clear(); - m_filteredFiles.clear(); - m_selection = BadIndex; - m_filtered = false; -} - -void Files::add(File f) -{ - m_allFiles.emplace_back(std::move(f)); -} - -void Files::addFiltered(File* f) -{ - m_filteredFiles.push_back(f); -} - -bool Files::empty() const -{ - if (m_filtered) { - return m_filteredFiles.empty(); - } else { - return m_allFiles.empty(); - } -} - -std::size_t Files::size() const -{ - if (m_filtered) { - return m_filteredFiles.size(); - } else { - return m_allFiles.size(); - } -} - -void Files::switchToAll() -{ - m_filtered = false; - m_filteredFiles.clear(); -} - -void Files::switchToFiltered() -{ - m_filtered = true; - m_filteredFiles.clear(); -} - -const File* Files::get(std::size_t i) const -{ - if (m_filtered) { - if (i < m_filteredFiles.size()) { - return m_filteredFiles[i]; - } - } else { - if (i < m_allFiles.size()) { - return &m_allFiles[i]; - } - } - - return nullptr; -} - -File* Files::get(std::size_t i) -{ - return const_cast(std::as_const(*this).get(i)); -} - -std::size_t Files::indexOf(const File* f) const -{ - if (m_filtered) { - for (std::size_t i = 0; i < m_filteredFiles.size(); ++i) { - if (m_filteredFiles[i] == f) { - return i; - } - } - } else { - for (std::size_t i = 0; i < m_allFiles.size(); ++i) { - if (&m_allFiles[i] == f) { - return i; - } - } - } - - return BadIndex; -} - -const File* Files::selectedFile() const -{ - return get(m_selection); -} - -File* Files::selectedFile() -{ - return get(m_selection); -} - -std::size_t Files::selectedIndex() const -{ - return m_selection; -} - -void Files::select(std::size_t i) -{ - m_selection = i; -} - -std::vector& Files::allFiles() -{ - return m_allFiles; -} - -bool Files::isFiltered() const -{ - return m_filtered; -} - -PaintContext::PaintContext(QWidget* w, Geometry geo) - : painter(w), geo(geo) -{} - -} // namespace ImagesTabHelpers +#include "modinfodialogimages.h" +#include "settings.h" +#include "ui_modinfodialog.h" +#include "utility.h" +#include + +#include + +using namespace MOBase; +using namespace ImagesTabHelpers; + +QSize resizeWithAspectRatio(const QSize& original, const QSize& available) +{ + const auto ratio = + std::min({1.0, static_cast(available.width()) / original.width(), + static_cast(available.height()) / original.height()}); + + const QSize scaledSize(static_cast(std::round(original.width() * ratio)), + static_cast(std::round(original.height() * ratio))); + + return scaledSize; +} + +QRect centeredRect(const QRect& rect, const QSize& size) +{ + return {(rect.left() + rect.width() / 2) - size.width() / 2, + (rect.top() + rect.height() / 2) - size.height() / 2, size.width(), + size.height()}; +} + +QString dimensionString(const QSize& s) +{ + return QString::fromUtf8("%1 \xc3\x97 %2").arg(s.width()).arg(s.height()); +} + +ImagesTab::ImagesTab(ModInfoDialogTabContext cx) + : ModInfoDialogTab(std::move(cx)), m_image(new ScalableImage) +{ + getSupportedFormats(); + + auto* ly = new QVBoxLayout(ui->imagesImage); + ly->setContentsMargins({0, 0, 0, 0}); + ly->addWidget(m_image); + + delete ui->imagesThumbnails->layout(); + + ui->tabImagesSplitter->setSizes({128, 1}); + ui->tabImagesSplitter->setStretchFactor(0, 0); + ui->tabImagesSplitter->setStretchFactor(1, 1); + + ui->previewPluginButton->setEnabled(false); + + ui->imagesThumbnails->setTab(this); + + ui->imagesScrollerVBar->setTab(this); + connect(ui->imagesScrollerVBar, &QScrollBar::valueChanged, [&] { + onScrolled(); + }); + + ui->imagesShowDDS->setEnabled(m_ddsAvailable); + + m_filter.setEdit(ui->imagesFilter); + connect(&m_filter, &FilterWidget::changed, [&] { + onFilterChanged(); + }); + + connect(ui->imagesExplore, &QAbstractButton::clicked, [&] { + onExplore(); + }); + connect(ui->imagesShowDDS, &QCheckBox::toggled, [&] { + onShowDDS(); + }); + connect(ui->previewPluginButton, &QAbstractButton::clicked, [&] { + onPreviewButton(); + }); + + ui->imagesShowDDS->setEnabled(m_ddsAvailable); + + ui->imagesThumbnails->setAutoFillBackground(false); + + if (ui->imagesThumbnails->palette().color(QPalette::Base) != Qt::transparent) { + ui->imagesThumbnails->setAttribute(Qt::WA_OpaquePaintEvent, true); + } + + { + auto list = std::make_unique(); + parentWidget()->style()->polish(list.get()); + + m_theme.borderColor = QColor(Qt::black); + m_theme.backgroundColor = QColor(Qt::black); + m_theme.textColor = list->palette().color(QPalette::WindowText); + + m_theme.highlightBackgroundColor = list->palette().color(QPalette::Highlight); + m_theme.highlightTextColor = list->palette().color(QPalette::HighlightedText); + + m_theme.font = list->font(); + + const QFontMetrics fm(m_theme.font); + m_metrics.textHeight = fm.height(); + + m_image->setColors(m_theme.borderColor, m_theme.backgroundColor); + } +} + +void ImagesTab::clear() +{ + m_files.clear(); + ui->imagesScrollerVBar->setValue(0); + select(BadIndex); + setHasData(false); +} + +bool ImagesTab::feedFile(const QString& rootPath, const QString& fullPath) +{ + for (const auto& ext : m_supportedFormats) { + if (fullPath.endsWith(ext, Qt::CaseInsensitive)) { + m_files.add({fullPath}); + return true; + } + } + + return false; +} + +void ImagesTab::update() +{ + checkFiltering(); + updateScrollbar(); + + // visibility needs to be rechecked here because the scrollbar configuration + // may have changed in updateScrollbar(), in which case any ensureVisible() + // calls in checkFiltering() might have been incorrect + if (m_files.selectedIndex() != BadIndex) { + ensureVisible(m_files.selectedIndex(), Visibility::Partial); + } + + ui->imagesThumbnails->update(); + + setHasData(!m_files.empty()); +} + +void ImagesTab::saveState(Settings& s) +{ + s.widgets().saveChecked(ui->imagesShowDDS); + s.geometry().saveState(ui->tabImagesSplitter); +} + +void ImagesTab::restoreState(const Settings& s) +{ + s.widgets().restoreChecked(ui->imagesShowDDS); + s.geometry().restoreState(ui->tabImagesSplitter); +} + +void ImagesTab::checkFiltering() +{ + if (m_filter.empty() && m_ddsEnabled) { + // no filtering needed + + if (m_files.isFiltered()) { + // was filtered, needs switch + switchToAll(); + } + } else { + // filtering is needed + switchToFiltered(); + } +} + +void ImagesTab::switchToAll() +{ + // remember selection + const auto* oldSelection = m_files.selectedFile(); + + // switch + m_files.switchToAll(); + + // reselect old + if (oldSelection) { + select(m_files.indexOf(oldSelection)); + } else { + select(BadIndex); + } +} + +void ImagesTab::switchToFiltered() +{ + // remember old selection, will be checked when building the filtered list + // below + const auto* oldSelection = m_files.selectedFile(); + std::size_t newSelection = BadIndex; + + // switch, also clears list + m_files.switchToFiltered(); + + const bool hasTextFilter = !m_filter.empty(); + + for (File& f : m_files.allFiles()) { + if (hasTextFilter) { + // check filter widget + const auto m = m_filter.matches([&](const QRegularExpression& regex) { + return regex.match(f.filename()).hasMatch(); + }); + + if (!m) { + // no match, skip + continue; + } + } + + if (!m_ddsEnabled) { + // skip .dds files + if (f.filename().endsWith(".dds", Qt::CaseInsensitive)) { + continue; + } + } + + if (&f == oldSelection) { + // found the old selection, remember its index + newSelection = m_files.size(); + } + + m_files.addFiltered(&f); + } + + // reselect old, or clear if it wasn't found + select(newSelection); +} + +void ImagesTab::getSupportedFormats() +{ + m_ddsAvailable = false; + + for (const auto& entry : QImageReader::supportedImageFormats()) { + QString s(entry); + if (s.isNull() || s.isEmpty()) { + continue; + } + + // used to enable the checkbox + if (s.compare("dds", Qt::CaseInsensitive) == 0) { + m_ddsAvailable = true; + } + + // make sure it starts with a dot + if (s[0] != '.') { + s = '.' + s; + } + + m_supportedFormats.emplace_back(std::move(s)); + } +} + +void ImagesTab::select(std::size_t i, Visibility v) +{ + m_files.select(i); + + if (auto* f = m_files.selectedFile()) { + // when jumping elsewhere in the list, such as with page down/up, the file + // might not be visible yet, which means it hasn't been loaded and would + // pass a null image in setImage() below + f->ensureOriginalLoaded(); + + ui->imagesPath->setText(QDir::toNativeSeparators(f->path())); + ui->imagesExplore->setEnabled(true); + if (plugin().previewGenerator().previewSupported( + QFileInfo(f->path()).suffix().toLower(), false)) + ui->previewPluginButton->setEnabled(true); + else + ui->previewPluginButton->setEnabled(false); + ui->imagesSize->setText(dimensionString(f->original().size())); + + if (f->original().isNull()) { + m_image->clear(); + + QImage image(300, 100, QImage::Format_RGBA64); + QPainter paint; + paint.begin(&image); + paint.fillRect(0, 0, 300, 100, QBrush(QColor(0, 0, 0, 255))); + paint.setPen(m_theme.textColor); + paint.setFont(m_theme.font); + paint.drawImage(QPoint(150 - 16, 50 - 20 - 16), QImage(":/MO/gui/warning")); + const auto flags = Qt::AlignHCenter | Qt::AlignVCenter | Qt::TextWordWrap; + paint.drawText(0, 46, 300, 54, flags, + "This image format is not supported by Qt, but the preview plugin " + "may be able to display it. Use the button above."); + paint.end(); + + m_image->setImage(image); + } else + m_image->setImage(f->original()); + ensureVisible(i, v); + } else { + ui->imagesPath->clear(); + ui->imagesExplore->setEnabled(false); + ui->previewPluginButton->setEnabled(false); + ui->imagesSize->clear(); + m_image->clear(); + } + + ui->imagesThumbnails->update(); +} + +void ImagesTab::moveSelection(int by) +{ + if (m_files.empty()) { + return; + } + + auto i = m_files.selectedIndex(); + if (i == BadIndex) { + i = 0; + } + + if (by > 0) { + // moving down + i += static_cast(by); + + if (i >= m_files.size()) { + i = (m_files.size() - 1); + } + } else if (by < 0) { + // moving up + const auto abs_by = static_cast(std::abs(by)); + + if (abs_by > i) { + i = 0; + } else { + i -= abs_by; + } + } + + select(i); +} + +void ImagesTab::ensureVisible(std::size_t i, Visibility v) +{ + if (v == Visibility::Ignore) { + return; + } + + const auto geo = makeGeometry(); + + const auto fullyVisible = geo.fullyVisibleCount(); + const auto partiallyVisible = fullyVisible + 1; + + const auto first = ui->imagesScrollerVBar->value(); + const auto last = + (v == Visibility::Full ? first + fullyVisible : first + partiallyVisible); + + if (std::cmp_less(i , first)) { + // go up + ui->imagesScrollerVBar->setValue(static_cast(i)); + } else if (i >= last) { + // go down + + if (i >= fullyVisible) { + ui->imagesScrollerVBar->setValue(static_cast(i - fullyVisible + 1)); + } + } +} + +std::size_t ImagesTab::fileIndexAtPos(const QPoint& p) const +{ + const auto geo = makeGeometry(); + + // this is the index relative to the top + const auto offset = geo.indexAt(p); + if (offset == BadIndex) { + return BadIndex; + } + + const auto first = ui->imagesScrollerVBar->value(); + if (first < 0) { + return BadIndex; + } + + const auto i = static_cast(first) + offset; + if (i >= m_files.size()) { + return BadIndex; + } + + return i; +} + +const File* ImagesTab::fileAtPos(const QPoint& p) const +{ + const auto i = fileIndexAtPos(p); + if (i >= m_files.size()) { + return nullptr; + } + + return m_files.get(i); +} + +Geometry ImagesTab::makeGeometry() const +{ + return {ui->imagesThumbnails->size(), m_metrics}; +} + +void ImagesTab::paintThumbnailsArea(QPaintEvent* e) +{ + PaintContext cx(ui->imagesThumbnails, makeGeometry()); + + cx.painter.fillRect(ui->imagesThumbnails->rect(), + ui->imagesThumbnails->palette().color(QPalette::Window)); + + const auto visible = cx.geo.fullyVisibleCount() + 1; + const auto first = ui->imagesScrollerVBar->value(); + + for (std::size_t i = 0; i < visible; ++i) { + const auto fileIndex = first + i; + auto* file = m_files.get(fileIndex); + if (!file) { + break; + } + + cx.file = file; + cx.thumbIndex = i; + cx.fileIndex = fileIndex; + + paintThumbnail(cx); + } +} + +void ImagesTab::paintThumbnail(const PaintContext& cx) +{ + paintThumbnailBackground(cx); + paintThumbnailBorder(cx); + paintThumbnailImage(cx); + paintThumbnailText(cx); +} + +void ImagesTab::paintThumbnailBackground(const PaintContext& cx) +{ + if (m_files.selectedIndex() == cx.fileIndex) { + const auto rect = cx.geo.thumbRect(cx.thumbIndex); + cx.painter.fillRect(rect, m_theme.highlightBackgroundColor); + } +} + +void ImagesTab::paintThumbnailBorder(const PaintContext& cx) const +{ + auto borderRect = cx.geo.borderRect(cx.thumbIndex); + + // rects don't include the bottom right corner, but drawRect() does, so + // resize it + borderRect.setRight(borderRect.right() - 1); + borderRect.setBottom(borderRect.bottom() - 1); + + cx.painter.setPen(m_theme.borderColor); + cx.painter.drawRect(borderRect); +} + +void ImagesTab::paintThumbnailImage(const PaintContext& cx) const +{ + if (cx.file->failed()) { + return; + } + + cx.file->loadIfNeeded(cx.geo); + + const auto imageRect = cx.geo.imageRect(cx.thumbIndex); + const auto scaledThumbRect = centeredRect(imageRect, cx.file->thumbnail().size()); + + cx.painter.fillRect(scaledThumbRect, m_theme.backgroundColor); + cx.painter.drawImage(scaledThumbRect, cx.file->thumbnail()); +} + +void ImagesTab::paintThumbnailText(const PaintContext& cx) +{ + const auto tr = cx.geo.textRect(cx.thumbIndex); + + if (cx.fileIndex == m_files.selectedIndex()) { + cx.painter.setPen(m_theme.highlightTextColor); + } else { + cx.painter.setPen(m_theme.textColor); + } + + cx.painter.setFont(m_theme.font); + + QFontMetrics const fm(m_theme.font); + + const auto text = fm.elidedText(cx.file->filename(), Qt::ElideRight, tr.width()); + + const auto flags = Qt::AlignHCenter | Qt::AlignVCenter | Qt::TextSingleLine; + + cx.painter.drawText(tr, flags, text); +} + +void ImagesTab::scrollAreaResized(const QSize&) +{ + updateScrollbar(); +} + +void ImagesTab::thumbnailAreaMouseEvent(QMouseEvent* e) +{ + if (e->button() != Qt::LeftButton) { + return; + } + + const auto i = fileIndexAtPos(e->pos()); + if (i != BadIndex) { + // the only way to click on a thumbnail is if it's already visible, so the + // only thing that can happen is a click on a partially visible thumbnail, + // which would scroll so it is fully visible, and that's just annoying + select(i, Visibility::Ignore); + } +} + +void ImagesTab::thumbnailAreaWheelEvent(QWheelEvent* e) +{ + const auto d = (e->angleDelta() / 8).y(); + + ui->imagesScrollerVBar->setValue(ui->imagesScrollerVBar->value() + (d > 0 ? -1 : 1)); +} + +bool ImagesTab::thumbnailAreaKeyPressEvent(QKeyEvent* e) +{ + switch (e->key()) { + case Qt::Key_Down: { + moveSelection(ui->imagesScrollerVBar->singleStep()); + return true; + } + + case Qt::Key_Up: { + moveSelection(-ui->imagesScrollerVBar->singleStep()); + return true; + } + + case Qt::Key_PageDown: { + moveSelection(ui->imagesScrollerVBar->pageStep()); + return true; + } + + case Qt::Key_PageUp: { + moveSelection(-ui->imagesScrollerVBar->pageStep()); + return true; + } + + case Qt::Key_Home: { + select(0); + return true; + } + + case Qt::Key_End: { + if (!m_files.empty()) { + select(m_files.size() - 1); + } + + return true; + } + } + + return false; +} + +void ImagesTab::onScrolled() +{ + ui->imagesThumbnails->update(); +} + +void ImagesTab::showTooltip(QHelpEvent* e) +{ + const auto* f = fileAtPos(e->pos()); + if (!f) { + QToolTip::hideText(); + e->ignore(); + return; + } + + const auto s = QString("%1 (%2)") + .arg(QDir::toNativeSeparators(f->path())) + .arg(dimensionString(f->original().size())); + + QToolTip::showText(e->globalPos(), s, ui->imagesThumbnails); +} + +void ImagesTab::onExplore() +{ + if (auto* f = m_files.selectedFile()) { + shell::Explore(f->path()); + } +} + +void ImagesTab::onShowDDS() +{ + const auto b = ui->imagesShowDDS->isChecked(); + if (b != m_ddsEnabled) { + m_ddsEnabled = b; + update(); + } +} + +void ImagesTab::onPreviewButton() +{ + core().previewFileWithAlternatives(parentWidget(), m_files.selectedFile()->path()); +} + +void ImagesTab::onFilterChanged() +{ + update(); +} + +void ImagesTab::updateScrollbar() +{ + if (m_files.empty()) { + ui->imagesScrollerVBar->setRange(0, 0); + ui->imagesScrollerVBar->setEnabled(false); + return; + } + + const auto geo = makeGeometry(); + const auto availableSize = ui->imagesThumbnails->size(); + const auto fullyVisible = geo.fullyVisibleCount(); + + if (fullyVisible >= m_files.size()) { + ui->imagesScrollerVBar->setRange(0, 0); + ui->imagesScrollerVBar->setEnabled(false); + } else { + const auto d = m_files.size() - fullyVisible; + ui->imagesScrollerVBar->setRange(0, static_cast(d)); + ui->imagesScrollerVBar->setSingleStep(1); + ui->imagesScrollerVBar->setPageStep(static_cast(fullyVisible - 1)); + ui->imagesScrollerVBar->setEnabled(true); + } +} + +namespace ImagesTabHelpers +{ + +void Scrollbar::setTab(ImagesTab* tab) +{ + m_tab = tab; +} + +void Scrollbar::wheelEvent(QWheelEvent* e) +{ + if (m_tab) { + m_tab->thumbnailAreaWheelEvent(e); + } +} + +void ThumbnailsWidget::setTab(ImagesTab* tab) +{ + m_tab = tab; +} + +void ThumbnailsWidget::paintEvent(QPaintEvent* e) +{ + if (m_tab) { + m_tab->paintThumbnailsArea(e); + } +} + +void ThumbnailsWidget::mousePressEvent(QMouseEvent* e) +{ + if (m_tab) { + m_tab->thumbnailAreaMouseEvent(e); + } +} + +void ThumbnailsWidget::wheelEvent(QWheelEvent* e) +{ + if (m_tab) { + m_tab->thumbnailAreaWheelEvent(e); + } +} + +void ThumbnailsWidget::resizeEvent(QResizeEvent* e) +{ + if (m_tab) { + m_tab->scrollAreaResized(e->size()); + } +} + +void ThumbnailsWidget::keyPressEvent(QKeyEvent* e) +{ + if (m_tab) { + if (m_tab->thumbnailAreaKeyPressEvent(e)) { + return; + } + } + + QWidget::keyPressEvent(e); +} + +bool ThumbnailsWidget::event(QEvent* e) +{ + if (e->type() == QEvent::ToolTip) { + m_tab->showTooltip(static_cast(e)); + return true; + } + + return QWidget::event(e); +} + +ScalableImage::ScalableImage(QString path) : m_path(std::move(path)) +{ + auto sp = sizePolicy(); + sp.setHeightForWidth(true); + setSizePolicy(sp); +} + +void ScalableImage::setImage(const QString& path) +{ + m_path = path; + m_original = {}; + m_scaled = {}; + + update(); +} + +void ScalableImage::setImage(QImage image) +{ + m_path.clear(); + m_original = std::move(image); + m_scaled = {}; + + update(); +} + +void ScalableImage::clear() +{ + setImage(QImage()); +} + +bool ScalableImage::hasHeightForWidth() const +{ + return true; +} + +int ScalableImage::heightForWidth(int w) const +{ + return w; +} + +void ScalableImage::setColors(const QColor& border, const QColor& background) +{ + m_borderColor = border; + m_backgroundColor = background; +} + +void ScalableImage::paintEvent(QPaintEvent* e) +{ + if (m_original.isNull()) { + if (m_path.isNull()) { + return; + } + + m_original.load(m_path); + + if (m_original.isNull()) { + return; + } + } + + const QRect widgetRect = rect(); + const QRect imageRect = widgetRect.adjusted(m_border, m_border, -m_border, -m_border); + + const QSize scaledSize = resizeWithAspectRatio(m_original.size(), imageRect.size()); + + if (m_scaled.isNull() || m_scaled.size() != scaledSize) { + m_scaled = m_original.scaled(scaledSize.width(), scaledSize.height(), + Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + } + + const QRect drawBorderRect = widgetRect.adjusted(0, 0, -1, -1); + const QRect drawImageRect = centeredRect(imageRect, m_scaled.size()); + + QPainter painter(this); + + // border + painter.setPen(m_borderColor); + painter.drawRect(drawBorderRect); + + // background + painter.fillRect(drawImageRect, m_backgroundColor); + + // image + painter.drawImage(drawImageRect, m_scaled); +} + +Metrics::Metrics() + += default; + +Geometry::Geometry(QSize widgetSize, Metrics metrics) + : m_widgetSize(widgetSize), m_metrics(metrics), m_topRect(calcTopRect()) +{} + +QRect Geometry::calcTopRect() const +{ + const auto thumbWidth = m_widgetSize.width(); + const auto& m = m_metrics; + + const auto imageSize = + thumbWidth - (m.margins * 2) - (m.border * 2) - (m.padding * 2); + + const auto thumbHeight = m.margins + m.border + m.padding + imageSize + m.padding + + m.border + m.textSpacing + m.textHeight + m.margins; + + return {0, 0, thumbWidth, thumbHeight}; +} + +std::size_t Geometry::fullyVisibleCount() const +{ + const auto r = thumbRect(0); + + const auto thumbWithSpacing = r.height() + m_metrics.spacing; + const auto visible = (m_widgetSize.height() / thumbWithSpacing); + + return static_cast(visible); +} + +QRect Geometry::thumbRect(std::size_t i) const +{ + // rect for the top thumbnail + QRect r = m_topRect; + + // move down + const auto thumbWithSpacing = m_metrics.spacing + r.height(); + r.translate(0, static_cast(i * thumbWithSpacing)); + + return r; +} + +QRect Geometry::borderRect(std::size_t i) const +{ + auto r = thumbRect(i); + const auto& m = m_metrics; + + // remove margins and text + r.adjust(m.margins, m.margins, -m.margins, -m.margins); + + // remove text + r.adjust(0, 0, 0, -(m.textSpacing + m.textHeight)); + + return r; +} + +QRect Geometry::imageRect(std::size_t i) const +{ + auto r = borderRect(i); + + // remove border and padding + const auto m = m_metrics.border + m_metrics.padding; + r.adjust(m, m, -m, -m); + + return r; +} + +QRect Geometry::textRect(std::size_t i) const +{ + const auto r = borderRect(i); + + return {r.left(), r.bottom() + m_metrics.textSpacing, r.width(), + m_metrics.textHeight}; +} + +QRect Geometry::selectionRect(std::size_t i) const +{ + const auto br = borderRect(i); + const auto tr = textRect(i); + + return {br.left(), br.top(), br.width(), tr.bottom() - br.top()}; +} + +std::size_t Geometry::indexAt(const QPoint& p) const +{ + // calculate index purely based on y position + const std::size_t offset = p.y() / (m_topRect.height() + m_metrics.spacing); + + if (!selectionRect(offset).contains(p)) { + return BadIndex; + } + + return offset; +} + +QSize Geometry::scaledImageSize(const QSize& originalSize) const +{ + const auto availableSize = imageRect(0).size(); + return resizeWithAspectRatio(originalSize, availableSize); +} + +File::File(QString path) : m_path(std::move(path)) {} + +void File::ensureOriginalLoaded() +{ + if (!m_original.isNull()) { + // already loaded + return; + } + + QImageReader reader(m_path); + + if (!reader.read(&m_original)) { + log::error("failed to load '{}'\n{} (error {})", m_path, reader.errorString(), + static_cast(reader.error())); + + m_failed = true; + } +} + +const QString& File::path() const +{ + return m_path; +} + +const QString& File::filename() const +{ + if (m_filename.isEmpty()) { + m_filename = QFileInfo(m_path).fileName(); + } + + return m_filename; +} + +const QImage& File::original() const +{ + return m_original; +} + +const QImage& File::thumbnail() const +{ + return m_thumbnail; +} + +bool File::failed() const +{ + return m_failed; +} + +void File::loadIfNeeded(const Geometry& geo) +{ + if (needsLoad(geo)) { + load(geo); + } +} + +bool File::needsLoad(const Geometry& geo) const +{ + if (m_failed) { + return false; + } + + if (m_original.isNull() || m_thumbnail.isNull()) { + return true; + } + + const auto scaledSize = geo.scaledImageSize(m_original.size()); + return (m_thumbnail.size() != scaledSize); +} + +void File::load(const Geometry& geo) +{ + m_failed = false; + ensureOriginalLoaded(); + + if (m_failed) { + QImage const warning(":/MO/gui/warning"); + const auto scaledSize = geo.scaledImageSize(warning.size()); + + m_thumbnail = + warning.scaled(scaledSize, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + } else { + const auto scaledSize = geo.scaledImageSize(m_original.size()); + + m_thumbnail = + m_original.scaled(scaledSize, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + } +} + +Files::Files() : m_selection(BadIndex) {} + +void Files::clear() +{ + m_allFiles.clear(); + m_filteredFiles.clear(); + m_selection = BadIndex; + m_filtered = false; +} + +void Files::add(File f) +{ + m_allFiles.emplace_back(std::move(f)); +} + +void Files::addFiltered(File* f) +{ + m_filteredFiles.push_back(f); +} + +bool Files::empty() const +{ + if (m_filtered) { + return m_filteredFiles.empty(); + } else { + return m_allFiles.empty(); + } +} + +std::size_t Files::size() const +{ + if (m_filtered) { + return m_filteredFiles.size(); + } else { + return m_allFiles.size(); + } +} + +void Files::switchToAll() +{ + m_filtered = false; + m_filteredFiles.clear(); +} + +void Files::switchToFiltered() +{ + m_filtered = true; + m_filteredFiles.clear(); +} + +const File* Files::get(std::size_t i) const +{ + if (m_filtered) { + if (i < m_filteredFiles.size()) { + return m_filteredFiles[i]; + } + } else { + if (i < m_allFiles.size()) { + return &m_allFiles[i]; + } + } + + return nullptr; +} + +File* Files::get(std::size_t i) +{ + return const_cast(std::as_const(*this).get(i)); +} + +std::size_t Files::indexOf(const File* f) const +{ + if (m_filtered) { + for (std::size_t i = 0; i < m_filteredFiles.size(); ++i) { + if (m_filteredFiles[i] == f) { + return i; + } + } + } else { + for (std::size_t i = 0; i < m_allFiles.size(); ++i) { + if (&m_allFiles[i] == f) { + return i; + } + } + } + + return BadIndex; +} + +const File* Files::selectedFile() const +{ + return get(m_selection); +} + +File* Files::selectedFile() +{ + return get(m_selection); +} + +std::size_t Files::selectedIndex() const +{ + return m_selection; +} + +void Files::select(std::size_t i) +{ + m_selection = i; +} + +std::vector& Files::allFiles() +{ + return m_allFiles; +} + +bool Files::isFiltered() const +{ + return m_filtered; +} + +PaintContext::PaintContext(QWidget* w, Geometry geo) + : painter(w), geo(geo) +{} + +} // namespace ImagesTabHelpers diff --git a/src/src/modinfodialognexus.cpp b/src/src/modinfodialognexus.cpp index 149abcb..5e94d16 100644 --- a/src/src/modinfodialognexus.cpp +++ b/src/src/modinfodialognexus.cpp @@ -1,468 +1,468 @@ -#include "modinfodialognexus.h" -#include "bbcode.h" -#include "iplugingame.h" -#include "organizercore.h" -#include "settings.h" -#include "ui_modinfodialog.h" -#include -#include -#include - -using namespace MOBase; - -bool isValidModID(int id) -{ - return (id > 0); -} - -NexusTab::NexusTab(ModInfoDialogTabContext cx) - : ModInfoDialogTab(std::move(cx)) -{ - ui->modID->setValidator(new QIntValidator(ui->modID)); - ui->endorse->setVisible(core().settings().nexus().endorsementIntegration()); - ui->track->setVisible(core().settings().nexus().trackedIntegration()); - - connect(ui->modID, &QLineEdit::editingFinished, [&] { - onModIDChanged(); - }); - connect(ui->sourceGame, - static_cast(&QComboBox::currentIndexChanged), [&] { - onSourceGameChanged(); - }); - connect(ui->version, &QLineEdit::editingFinished, [&] { - onVersionChanged(); - }); - connect(ui->category, &QLineEdit::editingFinished, [&] { - onCategoryChanged(); - }); - - connect(ui->refresh, &QPushButton::clicked, [&] { - onRefreshBrowser(); - }); - connect(ui->visitNexus, &QPushButton::clicked, [&] { - onVisitNexus(); - }); - connect(ui->endorse, &QPushButton::clicked, [&] { - onEndorse(); - }); - connect(ui->track, &QPushButton::clicked, [&] { - onTrack(); - }); - - connect(ui->hasCustomURL, &QCheckBox::toggled, [&] { - onCustomURLToggled(); - }); - connect(ui->customURL, &QLineEdit::editingFinished, [&] { - onCustomURLChanged(); - }); - connect(ui->visitCustomURL, &QPushButton::clicked, [&] { - onVisitCustomURL(); - }); -} - -NexusTab::~NexusTab() -{ - cleanup(); -} - -void NexusTab::cleanup() -{ - if (m_modConnection) { - disconnect(m_modConnection); - m_modConnection = {}; - } -} - -void NexusTab::clear() -{ - ui->modID->clear(); - ui->sourceGame->clear(); - ui->version->clear(); - ui->category->clear(); -#ifdef MO2_WEBENGINE - ui->browser->setPage(new NexusTabWebpage(ui->browser)); -#else - ui->browser->setHtml(""); -#endif - ui->hasCustomURL->setChecked(false); - ui->customURL->clear(); - setHasData(false); -} - -void NexusTab::update() -{ - QScopedValueRollback const loading(m_loading, true); - - clear(); - - ui->modID->setText(QString("%1").arg(mod().nexusId())); - - QString const gameName = mod().gameName(); - ui->sourceGame->addItem(core().managedGame()->gameName(), - core().managedGame()->gameShortName()); - - if (core().managedGame()->validShortNames().empty()) { - ui->sourceGame->setDisabled(true); - } else { - for (auto game : plugin().plugins()) { - for (const QString& gameName : core().managedGame()->validShortNames()) { - if (game->gameShortName().compare(gameName, Qt::CaseInsensitive) == 0) { - ui->sourceGame->addItem(game->gameName(), game->gameShortName()); - break; - } - } - } - } - - ui->sourceGame->setCurrentIndex(ui->sourceGame->findData(gameName)); - - ui->category->setText(QString("%1").arg(mod().getNexusCategory())); - -#ifdef MO2_WEBENGINE - auto* page = new NexusTabWebpage(ui->browser); - ui->browser->setPage(page); - - connect(page, &NexusTabWebpage::linkClicked, [&](const QUrl& url) { - shell::Open(url); - }); -#else - ui->browser->setOpenExternalLinks(true); -#endif - - ui->endorse->setEnabled((mod().endorsedState() == EndorsedState::ENDORSED_FALSE) || - (mod().endorsedState() == EndorsedState::ENDORSED_NEVER)); - - setHasData(mod().nexusId() >= 0); -} - -void NexusTab::firstActivation() -{ - updateWebpage(); -} - -void NexusTab::setMod(ModInfoPtr mod, MOShared::FilesOrigin* origin) -{ - cleanup(); - - ModInfoDialogTab::setMod(mod, origin); - - m_modConnection = connect(mod.data(), &ModInfo::modDetailsUpdated, [&] { - onModChanged(); - }); -} - -bool NexusTab::usesOriginFiles() const -{ - return false; -} - -void NexusTab::updateVersionColor() -{ - if (mod().version() != mod().newestVersion()) { - ui->version->setStyleSheet("color: red"); - ui->version->setToolTip( - tr("Current Version: %1").arg(mod().newestVersion().canonicalString())); - } else { - ui->version->setStyleSheet("color: green"); - ui->version->setToolTip(tr("No update available")); - } -} - -void NexusTab::updateWebpage() -{ - const int modID = mod().nexusId(); - - if (isValidModID(modID)) { - const QString nexusLink = - NexusInterface::instance().getModURL(modID, mod().gameName()); - - ui->visitNexus->setToolTip(nexusLink); - refreshData(modID); - } else { - onModChanged(); - } - - ui->version->setText(mod().version().displayString()); - ui->hasCustomURL->setChecked(mod().hasCustomURL()); - ui->customURL->setText(mod().url()); - ui->customURL->setEnabled(mod().hasCustomURL()); - ui->visitCustomURL->setEnabled(mod().hasCustomURL()); - ui->visitCustomURL->setToolTip(mod().parseCustomURL().toString()); - - updateTracking(); -} - -void NexusTab::updateTracking() -{ - if (mod().trackedState() == TrackedState::TRACKED_TRUE) { - ui->track->setChecked(true); - ui->track->setText(tr("Tracked")); - } else { - ui->track->setChecked(false); - ui->track->setText(tr("Untracked")); - } -} - -void NexusTab::refreshData(int modID) -{ - if (tryRefreshData(modID)) { - m_requestStarted = true; - } else { - onModChanged(); - } -} - -bool NexusTab::tryRefreshData(int modID) -{ - if (isValidModID(modID) && !m_requestStarted) { - if (mod().updateNXMInfo()) { - ui->browser->setHtml(""); - return true; - } - } - - return false; -} - -void NexusTab::onModChanged() -{ - m_requestStarted = false; - - const QString nexusDescription = mod().getNexusDescription(); - - QString descriptionAsHTML = R"( - - - - - %1 -)"; - - if (nexusDescription.isEmpty()) { - descriptionAsHTML = descriptionAsHTML.arg(tr(R"( -
    -

    This mod does not have a valid Nexus ID. You can add a custom web - page for it in the "Custom URL" box below.

    -
    )")); - } else { - descriptionAsHTML = descriptionAsHTML.arg(BBCode::convertToHTML(nexusDescription)); - } - -#ifdef MO2_WEBENGINE - ui->browser->page()->setHtml(descriptionAsHTML); -#else - ui->browser->setHtml(descriptionAsHTML); -#endif - updateVersionColor(); - updateTracking(); -} - -void NexusTab::onModIDChanged() -{ - if (m_loading) { - return; - } - - const int oldID = mod().nexusId(); - const int newID = ui->modID->text().toInt(); - - if (oldID != newID) { - mod().setNexusID(newID); - mod().setLastNexusQuery(QDateTime::fromSecsSinceEpoch(0)); - -#ifdef MO2_WEBENGINE - ui->browser->page()->setHtml(""); -#else - ui->browser->setHtml(""); -#endif - - if (isValidModID(newID)) { - refreshData(newID); - } - } -} - -void NexusTab::onSourceGameChanged() -{ - if (m_loading) { - return; - } - - for (auto game : plugin().plugins()) { - if (game->gameName() == ui->sourceGame->currentText()) { - mod().setGameName(game->gameShortName()); - mod().setLastNexusQuery(QDateTime::fromSecsSinceEpoch(0)); - refreshData(mod().nexusId()); - return; - } - } -} - -void NexusTab::onVersionChanged() -{ - if (m_loading) { - return; - } - - MOBase::VersionInfo const version(ui->version->text()); - mod().setVersion(version); - updateVersionColor(); -} - -void NexusTab::onCategoryChanged() -{ - if (m_loading) { - return; - } - - int const category = ui->category->text().toInt(); - mod().setNexusCategory(category); -} - -void NexusTab::onRefreshBrowser() -{ - const auto modID = mod().nexusId(); - - if (isValidModID(modID)) { - mod().setLastNexusQuery(QDateTime::fromSecsSinceEpoch(0)); - updateWebpage(); - } else { - log::info("Mod has no valid Nexus ID, info can't be updated."); - } -} - -void NexusTab::onVisitNexus() -{ - const int modID = mod().nexusId(); - - if (isValidModID(modID)) { - const QString nexusLink = - NexusInterface::instance().getModURL(modID, mod().gameName()); - - shell::Open(QUrl(nexusLink)); - } -} - -void NexusTab::onEndorse() -{ - // use modPtr() instead of mod() or this because the callback may be - // executed after the dialog is closed - core().loggedInAction(parentWidget(), [m = modPtr()] { - m->endorse(true); - }); -} - -void NexusTab::onTrack() -{ - // use modPtr() instead of mod() or this because the callback may be - // executed after the dialog is closed - core().loggedInAction(parentWidget(), [m = modPtr()] { - if (m->trackedState() == TrackedState::TRACKED_TRUE) { - m->track(false); - } else { - m->track(true); - } - }); -} - -void NexusTab::onCustomURLToggled() -{ - if (m_loading) { - return; - } - - mod().setHasCustomURL(ui->hasCustomURL->isChecked()); - ui->customURL->setEnabled(mod().hasCustomURL()); - ui->visitCustomURL->setEnabled(mod().hasCustomURL()); -} - -void NexusTab::onCustomURLChanged() -{ - if (m_loading) { - return; - } - - mod().setCustomURL(ui->customURL->text()); - ui->visitCustomURL->setToolTip(mod().parseCustomURL().toString()); -} - -void NexusTab::onVisitCustomURL() -{ - const QUrl url = mod().parseCustomURL(); - if (url.isValid()) { - shell::Open(url); - } -} +#include "modinfodialognexus.h" +#include "bbcode.h" +#include "iplugingame.h" +#include "organizercore.h" +#include "settings.h" +#include "ui_modinfodialog.h" +#include +#include +#include + +using namespace MOBase; + +bool isValidModID(int id) +{ + return (id > 0); +} + +NexusTab::NexusTab(ModInfoDialogTabContext cx) + : ModInfoDialogTab(std::move(cx)) +{ + ui->modID->setValidator(new QIntValidator(ui->modID)); + ui->endorse->setVisible(core().settings().nexus().endorsementIntegration()); + ui->track->setVisible(core().settings().nexus().trackedIntegration()); + + connect(ui->modID, &QLineEdit::editingFinished, [&] { + onModIDChanged(); + }); + connect(ui->sourceGame, + static_cast(&QComboBox::currentIndexChanged), [&] { + onSourceGameChanged(); + }); + connect(ui->version, &QLineEdit::editingFinished, [&] { + onVersionChanged(); + }); + connect(ui->category, &QLineEdit::editingFinished, [&] { + onCategoryChanged(); + }); + + connect(ui->refresh, &QPushButton::clicked, [&] { + onRefreshBrowser(); + }); + connect(ui->visitNexus, &QPushButton::clicked, [&] { + onVisitNexus(); + }); + connect(ui->endorse, &QPushButton::clicked, [&] { + onEndorse(); + }); + connect(ui->track, &QPushButton::clicked, [&] { + onTrack(); + }); + + connect(ui->hasCustomURL, &QCheckBox::toggled, [&] { + onCustomURLToggled(); + }); + connect(ui->customURL, &QLineEdit::editingFinished, [&] { + onCustomURLChanged(); + }); + connect(ui->visitCustomURL, &QPushButton::clicked, [&] { + onVisitCustomURL(); + }); +} + +NexusTab::~NexusTab() +{ + cleanup(); +} + +void NexusTab::cleanup() +{ + if (m_modConnection) { + disconnect(m_modConnection); + m_modConnection = {}; + } +} + +void NexusTab::clear() +{ + ui->modID->clear(); + ui->sourceGame->clear(); + ui->version->clear(); + ui->category->clear(); +#ifdef MO2_WEBENGINE + ui->browser->setPage(new NexusTabWebpage(ui->browser)); +#else + ui->browser->setHtml(""); +#endif + ui->hasCustomURL->setChecked(false); + ui->customURL->clear(); + setHasData(false); +} + +void NexusTab::update() +{ + QScopedValueRollback const loading(m_loading, true); + + clear(); + + ui->modID->setText(QString("%1").arg(mod().nexusId())); + + QString const gameName = mod().gameName(); + ui->sourceGame->addItem(core().managedGame()->gameName(), + core().managedGame()->gameShortName()); + + if (core().managedGame()->validShortNames().empty()) { + ui->sourceGame->setDisabled(true); + } else { + for (auto *game : plugin().plugins()) { + for (const QString& gameName : core().managedGame()->validShortNames()) { + if (game->gameShortName().compare(gameName, Qt::CaseInsensitive) == 0) { + ui->sourceGame->addItem(game->gameName(), game->gameShortName()); + break; + } + } + } + } + + ui->sourceGame->setCurrentIndex(ui->sourceGame->findData(gameName)); + + ui->category->setText(QString("%1").arg(mod().getNexusCategory())); + +#ifdef MO2_WEBENGINE + auto* page = new NexusTabWebpage(ui->browser); + ui->browser->setPage(page); + + connect(page, &NexusTabWebpage::linkClicked, [&](const QUrl& url) { + shell::Open(url); + }); +#else + ui->browser->setOpenExternalLinks(true); +#endif + + ui->endorse->setEnabled((mod().endorsedState() == EndorsedState::ENDORSED_FALSE) || + (mod().endorsedState() == EndorsedState::ENDORSED_NEVER)); + + setHasData(mod().nexusId() >= 0); +} + +void NexusTab::firstActivation() +{ + updateWebpage(); +} + +void NexusTab::setMod(ModInfoPtr mod, MOShared::FilesOrigin* origin) +{ + cleanup(); + + ModInfoDialogTab::setMod(mod, origin); + + m_modConnection = connect(mod.data(), &ModInfo::modDetailsUpdated, [&] { + onModChanged(); + }); +} + +bool NexusTab::usesOriginFiles() const +{ + return false; +} + +void NexusTab::updateVersionColor() +{ + if (mod().version() != mod().newestVersion()) { + ui->version->setStyleSheet("color: red"); + ui->version->setToolTip( + tr("Current Version: %1").arg(mod().newestVersion().canonicalString())); + } else { + ui->version->setStyleSheet("color: green"); + ui->version->setToolTip(tr("No update available")); + } +} + +void NexusTab::updateWebpage() +{ + const int modID = mod().nexusId(); + + if (isValidModID(modID)) { + const QString nexusLink = + NexusInterface::instance().getModURL(modID, mod().gameName()); + + ui->visitNexus->setToolTip(nexusLink); + refreshData(modID); + } else { + onModChanged(); + } + + ui->version->setText(mod().version().displayString()); + ui->hasCustomURL->setChecked(mod().hasCustomURL()); + ui->customURL->setText(mod().url()); + ui->customURL->setEnabled(mod().hasCustomURL()); + ui->visitCustomURL->setEnabled(mod().hasCustomURL()); + ui->visitCustomURL->setToolTip(mod().parseCustomURL().toString()); + + updateTracking(); +} + +void NexusTab::updateTracking() +{ + if (mod().trackedState() == TrackedState::TRACKED_TRUE) { + ui->track->setChecked(true); + ui->track->setText(tr("Tracked")); + } else { + ui->track->setChecked(false); + ui->track->setText(tr("Untracked")); + } +} + +void NexusTab::refreshData(int modID) +{ + if (tryRefreshData(modID)) { + m_requestStarted = true; + } else { + onModChanged(); + } +} + +bool NexusTab::tryRefreshData(int modID) +{ + if (isValidModID(modID) && !m_requestStarted) { + if (mod().updateNXMInfo()) { + ui->browser->setHtml(""); + return true; + } + } + + return false; +} + +void NexusTab::onModChanged() +{ + m_requestStarted = false; + + const QString nexusDescription = mod().getNexusDescription(); + + QString descriptionAsHTML = R"( + + + + + %1 +)"; + + if (nexusDescription.isEmpty()) { + descriptionAsHTML = descriptionAsHTML.arg(tr(R"( +
    +

    This mod does not have a valid Nexus ID. You can add a custom web + page for it in the "Custom URL" box below.

    +
    )")); + } else { + descriptionAsHTML = descriptionAsHTML.arg(BBCode::convertToHTML(nexusDescription)); + } + +#ifdef MO2_WEBENGINE + ui->browser->page()->setHtml(descriptionAsHTML); +#else + ui->browser->setHtml(descriptionAsHTML); +#endif + updateVersionColor(); + updateTracking(); +} + +void NexusTab::onModIDChanged() +{ + if (m_loading) { + return; + } + + const int oldID = mod().nexusId(); + const int newID = ui->modID->text().toInt(); + + if (oldID != newID) { + mod().setNexusID(newID); + mod().setLastNexusQuery(QDateTime::fromSecsSinceEpoch(0)); + +#ifdef MO2_WEBENGINE + ui->browser->page()->setHtml(""); +#else + ui->browser->setHtml(""); +#endif + + if (isValidModID(newID)) { + refreshData(newID); + } + } +} + +void NexusTab::onSourceGameChanged() +{ + if (m_loading) { + return; + } + + for (auto *game : plugin().plugins()) { + if (game->gameName() == ui->sourceGame->currentText()) { + mod().setGameName(game->gameShortName()); + mod().setLastNexusQuery(QDateTime::fromSecsSinceEpoch(0)); + refreshData(mod().nexusId()); + return; + } + } +} + +void NexusTab::onVersionChanged() +{ + if (m_loading) { + return; + } + + MOBase::VersionInfo const version(ui->version->text()); + mod().setVersion(version); + updateVersionColor(); +} + +void NexusTab::onCategoryChanged() +{ + if (m_loading) { + return; + } + + int const category = ui->category->text().toInt(); + mod().setNexusCategory(category); +} + +void NexusTab::onRefreshBrowser() +{ + const auto modID = mod().nexusId(); + + if (isValidModID(modID)) { + mod().setLastNexusQuery(QDateTime::fromSecsSinceEpoch(0)); + updateWebpage(); + } else { + log::info("Mod has no valid Nexus ID, info can't be updated."); + } +} + +void NexusTab::onVisitNexus() +{ + const int modID = mod().nexusId(); + + if (isValidModID(modID)) { + const QString nexusLink = + NexusInterface::instance().getModURL(modID, mod().gameName()); + + shell::Open(QUrl(nexusLink)); + } +} + +void NexusTab::onEndorse() +{ + // use modPtr() instead of mod() or this because the callback may be + // executed after the dialog is closed + core().loggedInAction(parentWidget(), [m = modPtr()] { + m->endorse(true); + }); +} + +void NexusTab::onTrack() +{ + // use modPtr() instead of mod() or this because the callback may be + // executed after the dialog is closed + core().loggedInAction(parentWidget(), [m = modPtr()] { + if (m->trackedState() == TrackedState::TRACKED_TRUE) { + m->track(false); + } else { + m->track(true); + } + }); +} + +void NexusTab::onCustomURLToggled() +{ + if (m_loading) { + return; + } + + mod().setHasCustomURL(ui->hasCustomURL->isChecked()); + ui->customURL->setEnabled(mod().hasCustomURL()); + ui->visitCustomURL->setEnabled(mod().hasCustomURL()); +} + +void NexusTab::onCustomURLChanged() +{ + if (m_loading) { + return; + } + + mod().setCustomURL(ui->customURL->text()); + ui->visitCustomURL->setToolTip(mod().parseCustomURL().toString()); +} + +void NexusTab::onVisitCustomURL() +{ + const QUrl url = mod().parseCustomURL(); + if (url.isValid()) { + shell::Open(url); + } +} diff --git a/src/src/modinforegular.cpp b/src/src/modinforegular.cpp index 2d361bb..81653b3 100644 --- a/src/src/modinforegular.cpp +++ b/src/src/modinforegular.cpp @@ -1,1044 +1,1042 @@ -#include "modinforegular.h" - -#include "categories.h" -#include "messagedialog.h" -#include "moddatacontent.h" -#include "organizercore.h" -#include "plugincontainer.h" -#include "report.h" -#include "settings.h" -#include -#include - -#include -#include -#include -#include - -#include - -using namespace MOBase; -using namespace MOShared; - -namespace -{ -// Arguably this should be a class static or we should be using FileString rather -// than QString for the names. Or both. -static bool ByName(const ModInfo::Ptr& LHS, const ModInfo::Ptr& RHS) -{ - return QString::compare(LHS->name(), RHS->name(), Qt::CaseInsensitive) < 0; -} - -QString loadMetaPath(const QString& value) -{ - if (value.isEmpty()) { - return value; - } - - if (MOBase::isWindowsDrivePath(value) || QDir::isAbsolutePath(value)) { - return MOBase::normalizePathForHost(value); - } - - return QDir::fromNativeSeparators(value); -} - -QString storeMetaPath(const QString& value) -{ - if (value.isEmpty()) { - return value; - } - - if (MOBase::isWindowsDrivePath(value) || QDir::isAbsolutePath(value)) { - return MOBase::normalizePathForWine(value); - } - - return QDir::fromNativeSeparators(value); -} -} // namespace - -ModInfoRegular::ModInfoRegular(const QDir& path, OrganizerCore& core) - : ModInfoWithConflictInfo(core), m_Name(path.dirName()), - m_Path(path.absolutePath()), - m_GameName(core.managedGame()->gameShortName()), - m_NexusBridge(&core.pluginContainer()) -{ - m_CreationTime = QFileInfo(path.absolutePath()).birthTime(); - // read out the meta-file for information - readMeta(); - if (m_GameName.compare(core.managedGame()->gameShortName(), Qt::CaseInsensitive) != 0) - if (!core.managedGame()->primarySources().contains(m_GameName, Qt::CaseInsensitive)) - m_IsAlternate = true; - - // populate m_Archives - m_Archives = QStringList(); - if (Settings::instance().archiveParsing()) { - archives(true); - } - - connect(&m_NexusBridge, - SIGNAL(descriptionAvailable(QString, int, QVariant, QVariant)), this, - SLOT(nxmDescriptionAvailable(QString, int, QVariant, QVariant))); - connect(&m_NexusBridge, SIGNAL(endorsementToggled(QString, int, QVariant, QVariant)), - this, SLOT(nxmEndorsementToggled(QString, int, QVariant, QVariant))); - connect(&m_NexusBridge, SIGNAL(trackingToggled(QString, int, QVariant, bool)), this, - SLOT(nxmTrackingToggled(QString, int, QVariant, bool))); - connect(&m_NexusBridge, - SIGNAL(requestFailed(QString, int, int, QVariant, int, QString)), this, - SLOT(nxmRequestFailed(QString, int, int, QVariant, int, QString))); -} - -ModInfoRegular::~ModInfoRegular() -{ - try { - saveMeta(); - } catch (const std::exception& e) { - log::error("failed to save meta information for \"{}\": {}", m_Name, e.what()); - } -} - -bool ModInfoRegular::isEmpty() const -{ - QDirIterator iter(m_Path, QDir::NoDotAndDotDot | QDir::Files | QDir::Dirs); - if (!iter.hasNext()) - return true; - iter.next(); - if ((iter.fileName() == "meta.ini") && !iter.hasNext()) - return true; - return false; -} - -void ModInfoRegular::readMeta() -{ - QSettings metaFile(m_Path + "/meta.ini", QSettings::IniFormat); - m_Comments = metaFile.value("comments", "").toString(); - m_Notes = metaFile.value("notes", "").toString(); - QString const tempGameName = metaFile.value("gameName", m_GameName).toString(); - if (tempGameName.size()) - m_GameName = tempGameName; - m_NexusID = metaFile.value("modid", -1).toInt(); - m_Version.parse(metaFile.value("version", "").toString()); - m_NewestVersion = metaFile.value("newestVersion", "").toString(); - m_IgnoredVersion = metaFile.value("ignoredVersion", "").toString(); - m_InstallationFile = - loadMetaPath(metaFile.value("installationFile", "").toString()); - m_NexusDescription = metaFile.value("nexusDescription", "").toString(); - m_NexusFileStatus = metaFile.value("nexusFileStatus", "1").toInt(); - m_NexusCategory = metaFile.value("nexusCategory", 0).toInt(); - m_Author = metaFile.value("author", "").toString(); - m_Uploader = metaFile.value("uploader", "").toString(); - m_UploaderUrl = metaFile.value("uploaderUrl", "").toString(); - m_Repository = metaFile.value("repository", "Nexus").toString(); - m_Converted = metaFile.value("converted", false).toBool(); - m_Validated = metaFile.value("validated", false).toBool(); - - // this handles changes to how the URL works after 2.2.0 - // - // in 2.2.0, "hasCustomUrl" does not exist and "url" is only used when the mod - // id is invalid, although it can be set at any time in the mod info dialog - // - // post 2.2.0, a custom url can be set on any mod, whether the mod id is - // valid or not, so an additional flag "hasCustomURL" is required, with a - // corresponding checkbox in the mod info dialog - // - // there are several cases to handle to make sure no data is lost and to - // determine whether the user has set a custom url before: - // - // 1) some mods have an incorrect url set along with a valid mod id; - // there is apparently a bug with the fomod installer that can set the - // url of a mod to a value used by a _previous_ installation - // - // 2) it is possible to set the url even if the mod id is valid, in which - // case it is saved, but never used in 2.2.0 - // - // 3) opening the mod info dialog on the nexus tab for a mod that has a - // valid id will force the url to be the same as what the plugin gives - // back - // - // the algorithm is as follows: - // always read the url from the meta file and store it so this piece of data - // is never lost; the problem then only becomes about whether to enable - // hasCustomURL - // - // if hasCustomURL is present in the meta file, just read that and be - // done with it - // - // if not, then the flag depends on the mod id and the url - // if the mod id is valid, the custom url is disabled; although the url - // could be _set_ by the user when a mod id was valid, it was never - // _used_, so the behaviour won't change - // - // if the mod id is invalid, the url should normally be empty, unless the - // user specified one, in which case hasCustomURL should be true - // (the only case where this fails is if a mod id was valid before and - // the user visited the nexus tab, in which case the url was set - // automatically, but then the id was manually changed to 0 - // - // in that case, the mod id is invalid and the url is not empty, but it - // was never set by the user; this case is impossible to distinguish - // from a user manually entering a url, and so is handled as such) - - // always read the url - m_CustomURL = metaFile.value("url").toString(); - - if (metaFile.contains("hasCustomURL")) { - m_HasCustomURL = metaFile.value("hasCustomURL").toBool(); - } else { - if (m_NexusID > 0) { - // the mod id is valid, disable the custom url - m_HasCustomURL = false; - } else { - if (!m_CustomURL.isEmpty()) { - // the mod id is invalid and the url is not empty, enable it - m_HasCustomURL = true; - } - } - } - - m_LastNexusQuery = QDateTime::fromString( - metaFile.value("lastNexusQuery", "").toString(), Qt::ISODate); - m_LastNexusUpdate = QDateTime::fromString( - metaFile.value("lastNexusUpdate", "").toString(), Qt::ISODate); - m_NexusLastModified = QDateTime::fromString( - metaFile.value("nexusLastModified", QDateTime::currentDateTimeUtc()).toString(), - Qt::ISODate); - m_NexusCategory = metaFile.value("nexusCategory", 0).toInt(); - m_Color = metaFile.value("color", QColor()).value(); - m_TrackedState = metaFile.value("tracked", false).toBool() - ? TrackedState::TRACKED_TRUE - : TrackedState::TRACKED_FALSE; - if (metaFile.contains("endorsed")) { - if (metaFile.value("endorsed").canConvert()) { - using ut = std::underlying_type_t; - switch (metaFile.value("endorsed").toInt()) { - case static_cast(EndorsedState::ENDORSED_FALSE): - m_EndorsedState = EndorsedState::ENDORSED_FALSE; - break; - case static_cast(EndorsedState::ENDORSED_TRUE): - m_EndorsedState = EndorsedState::ENDORSED_TRUE; - break; - case static_cast(EndorsedState::ENDORSED_NEVER): - m_EndorsedState = EndorsedState::ENDORSED_NEVER; - break; - default: - m_EndorsedState = EndorsedState::ENDORSED_UNKNOWN; - break; - } - } else { - m_EndorsedState = metaFile.value("endorsed", false).toBool() - ? EndorsedState::ENDORSED_TRUE - : EndorsedState::ENDORSED_FALSE; - } - } - - QString const categoriesString = metaFile.value("category", "").toString(); - - QStringList categories = categoriesString.split(',', Qt::SkipEmptyParts); - for (QStringList::iterator iter = categories.begin(); iter != categories.end(); - ++iter) { - bool ok = false; - int const categoryID = iter->toInt(&ok); - if (categoryID < 0) { - // ignore invalid id - continue; - } - if (ok && (categoryID != 0) && - (CategoryFactory::instance().categoryExists(categoryID))) { - m_Categories.insert(categoryID); - if (iter == categories.begin()) { - m_PrimaryCategory = categoryID; - } - } - } - - int const numFiles = metaFile.beginReadArray("installedFiles"); - for (int i = 0; i < numFiles; ++i) { - metaFile.setArrayIndex(i); - m_InstalledFileIDs.insert(std::make_pair(metaFile.value("modid").toInt(), - metaFile.value("fileid").toInt())); - } - metaFile.endArray(); - - // Plugin settings: - metaFile.beginGroup("Plugins"); - for (const auto& pluginName : metaFile.childGroups()) { - metaFile.beginGroup(pluginName); - for (const auto& settingKey : metaFile.childKeys()) { - m_PluginSettings[pluginName][settingKey] = metaFile.value(settingKey); - } - metaFile.endGroup(); - } - metaFile.endGroup(); - - m_MetaInfoChanged = false; -} - -void ModInfoRegular::saveMeta() -{ - // only write meta data if the mod directory exists - if (m_MetaInfoChanged && QFile::exists(absolutePath())) { - QSettings metaFile(absolutePath().append("/meta.ini"), QSettings::IniFormat); - if (metaFile.status() == QSettings::NoError) { - std::set temp = m_Categories; - temp.erase(m_PrimaryCategory); - metaFile.setValue("category", QString("%1").arg(m_PrimaryCategory) + "," + - SetJoin(temp, ",")); - metaFile.setValue("newestVersion", m_NewestVersion.canonicalString()); - metaFile.setValue("ignoredVersion", m_IgnoredVersion.canonicalString()); - metaFile.setValue("version", m_Version.canonicalString()); - metaFile.setValue("installationFile", storeMetaPath(m_InstallationFile)); - metaFile.setValue("repository", m_Repository); - metaFile.setValue("gameName", m_GameName); - metaFile.setValue("modid", m_NexusID); - metaFile.setValue("comments", m_Comments); - metaFile.setValue("notes", m_Notes); - metaFile.setValue("nexusDescription", m_NexusDescription); - metaFile.setValue("url", m_CustomURL); - metaFile.setValue("hasCustomURL", m_HasCustomURL); - metaFile.setValue("nexusFileStatus", m_NexusFileStatus); - metaFile.setValue("lastNexusQuery", m_LastNexusQuery.toString(Qt::ISODate)); - metaFile.setValue("lastNexusUpdate", m_LastNexusUpdate.toString(Qt::ISODate)); - metaFile.setValue("nexusLastModified", m_NexusLastModified.toString(Qt::ISODate)); - metaFile.setValue("nexusCategory", m_NexusCategory); - metaFile.setValue("author", m_Author); - metaFile.setValue("uploader", m_Uploader); - metaFile.setValue("uploaderUrl", m_UploaderUrl); - metaFile.setValue("converted", m_Converted); - metaFile.setValue("validated", m_Validated); - metaFile.setValue("color", m_Color); - if (m_EndorsedState != EndorsedState::ENDORSED_UNKNOWN) { - metaFile.setValue( - "endorsed", - static_cast>(m_EndorsedState)); - } - if (m_TrackedState != TrackedState::TRACKED_UNKNOWN) { - metaFile.setValue("tracked", static_cast>( - m_TrackedState)); - } - - metaFile.remove("installedFiles"); - metaFile.beginWriteArray("installedFiles"); - int idx = 0; - for (auto iter = m_InstalledFileIDs.begin(); iter != m_InstalledFileIDs.end(); - ++iter) { - metaFile.setArrayIndex(idx++); - metaFile.setValue("modid", iter->first); - metaFile.setValue("fileid", iter->second); - } - metaFile.endArray(); - - // Plugin settings: - metaFile.remove("Plugins"); - metaFile.beginGroup("Plugins"); - for (const auto& [pluginName, pluginSettings] : m_PluginSettings) { - metaFile.beginGroup(pluginName); - for (const auto& [settingName, settingValue] : pluginSettings) { - metaFile.setValue(settingName, settingValue); - } - metaFile.endGroup(); - } - metaFile.endGroup(); - - metaFile.sync(); // sync needs to be called to ensure the file is created - - if (metaFile.status() == QSettings::NoError) { - m_MetaInfoChanged = false; - } else { - log::error("failed to write {}/meta.ini: error {}", absolutePath(), - metaFile.status()); - } - } else { - log::error("failed to write {}/meta.ini: error {}", absolutePath(), - metaFile.status()); - } - } -} - -bool ModInfoRegular::updateAvailable() const -{ - if (m_IgnoredVersion.isValid() && (m_IgnoredVersion == m_NewestVersion)) { - return false; - } - if (m_NexusFileStatus == 4 || m_NexusFileStatus == 6) { - return true; - } - return m_NewestVersion.isValid() && (m_Version < m_NewestVersion); -} - -bool ModInfoRegular::downgradeAvailable() const -{ - if (m_IgnoredVersion.isValid() && (m_IgnoredVersion == m_NewestVersion)) { - return false; - } - return m_NewestVersion.isValid() && (m_NewestVersion < m_Version); -} - -void ModInfoRegular::nxmDescriptionAvailable(QString, int, QVariant, - QVariant resultData) -{ - QVariantMap result = resultData.toMap(); - setNexusDescription(result["description"].toString()); - - if ((m_EndorsedState != EndorsedState::ENDORSED_NEVER) && - (result.contains("endorsement"))) { - QVariantMap endorsement = result["endorsement"].toMap(); - QString const endorsementStatus = endorsement["endorse_status"].toString(); - if (endorsementStatus.compare("Endorsed", Qt::CaseInsensitive) == 00) - setEndorsedState(EndorsedState::ENDORSED_TRUE); - else if (endorsementStatus.compare("Abstained", Qt::CaseInsensitive) == 00) - setEndorsedState(EndorsedState::ENDORSED_NEVER); - else - setEndorsedState(EndorsedState::ENDORSED_FALSE); - } - m_LastNexusQuery = QDateTime::currentDateTimeUtc(); - m_NexusLastModified = - QDateTime::fromSecsSinceEpoch(result["updated_timestamp"].toInt(), QTimeZone::UTC); - m_MetaInfoChanged = true; - saveMeta(); - disconnect(sender(), SIGNAL(descriptionAvailable(QString, int, QVariant, QVariant))); - emit modDetailsUpdated(true); -} - -void ModInfoRegular::nxmEndorsementToggled(QString, int, QVariant, QVariant resultData) -{ - QMap results = resultData.toMap(); - QMutexLocker const locker(&s_Mutex); - for (auto& mod : s_Collection) { - if (mod->gameName().compare(m_GameName, Qt::CaseInsensitive) == 0 && - mod->nexusId() == m_NexusID) { - if (results["status"].toString().compare("Endorsed") == 0) { - mod->setIsEndorsed(true); - } else if (results["status"].toString().compare("Abstained") == 0) { - mod->setNeverEndorse(); - } else { - mod->setIsEndorsed(false); - } - mod->saveMeta(); - } - } - emit modDetailsUpdated(true); -} - -void ModInfoRegular::nxmTrackingToggled(QString, int, QVariant, bool tracked) -{ - QMutexLocker const locker(&s_Mutex); - for (auto& mod : s_Collection) { - if (mod->gameName().compare(m_GameName, Qt::CaseInsensitive) == 0 && - mod->nexusId() == m_NexusID) { - mod->setIsTracked(tracked); - mod->saveMeta(); - } - } - emit modDetailsUpdated(true); -} - -void ModInfoRegular::nxmRequestFailed(QString, int, int, QVariant userData, - int errorCode, const QString& errorMessage) -{ - QString fullMessage = errorMessage; - if (userData.canConvert() && (userData.toInt() == 1)) { - fullMessage += "\nNexus will reject endorsements within 15 Minutes of a failed " - "attempt, the error message may be misleading."; - } - if (QApplication::activeWindow() != nullptr) { - MessageDialog::showMessage(fullMessage, QApplication::activeWindow()); - } - emit modDetailsUpdated(false); -} - -bool ModInfoRegular::updateNXMInfo() -{ - if (needsDescriptionUpdate()) { - m_NexusBridge.requestDescription(m_GameName, m_NexusID, QVariant()); - return true; - } - - return false; -} - -bool ModInfoRegular::needsDescriptionUpdate() const -{ - if (m_NexusID > 0) { - QDateTime const time = QDateTime::currentDateTimeUtc(); - QDateTime const target = m_LastNexusQuery.addDays(1); - - if (time >= target) { - return true; - } - } - - return false; -} - -void ModInfoRegular::setCategory(int categoryID, bool active) -{ - m_MetaInfoChanged = true; - - if (active) { - m_Categories.insert(categoryID); - if (m_PrimaryCategory == -1) { - m_PrimaryCategory = categoryID; - } - } else { - std::set::iterator const iter = m_Categories.find(categoryID); - if (iter != m_Categories.end()) { - m_Categories.erase(iter); - } - if (categoryID == m_PrimaryCategory) { - if (m_Categories.empty()) { - m_PrimaryCategory = -1; - } else { - m_PrimaryCategory = *(m_Categories.begin()); - } - } - } -} - -bool ModInfoRegular::setName(const QString& name) -{ - if (name.contains('/') || name.contains('\\')) { - return false; - } - - QString const newPath = - m_Path.mid(0).replace(m_Path.length() - m_Name.length(), m_Name.length(), name); - QDir modDir(m_Path.mid(0, m_Path.length() - m_Name.length())); - - if (m_Name.compare(name, Qt::CaseInsensitive) == 0) { - QString tempName = name; - tempName.append("_temp"); - while (modDir.exists(tempName)) { - tempName.append("_"); - } - if (!modDir.rename(m_Name, tempName)) { - return false; - } - if (!modDir.rename(tempName, name)) { - log::error( - "rename to final name failed after successful rename to intermediate name"); - modDir.rename(tempName, m_Name); - return false; - } - } else { - if (!shellRename(modDir.absoluteFilePath(m_Name), modDir.absoluteFilePath(name))) { - log::error("failed to rename mod {} (errorcode {})", name, ::GetLastError()); - return false; - } - } - - std::map::iterator const nameIter = s_ModsByName.find(m_Name); - if (nameIter != s_ModsByName.end()) { - QMutexLocker const locker(&s_Mutex); - - unsigned int const index = nameIter->second; - s_ModsByName.erase(nameIter); - - m_Name = name; - m_Path = newPath; - - s_ModsByName[m_Name] = index; - - std::sort(s_Collection.begin(), s_Collection.end(), ModInfo::ByName); - updateIndices(); - } else { // otherwise mod isn't registered yet? - m_Name = name; - m_Path = newPath; - } - - return true; -} - -void ModInfoRegular::setComments(const QString& comments) -{ - m_Comments = comments; - m_MetaInfoChanged = true; -} - -void ModInfoRegular::setNotes(const QString& notes) -{ - m_Notes = notes; - m_MetaInfoChanged = true; -} - -void ModInfoRegular::setGameName(const QString& gameName) -{ - m_GameName = gameName; - m_MetaInfoChanged = true; -} - -void ModInfoRegular::setNexusID(int modID) -{ - m_NexusID = modID; - m_MetaInfoChanged = true; -} - -void ModInfoRegular::setVersion(const VersionInfo& version) -{ - m_Version = version; - m_MetaInfoChanged = true; -} - -void ModInfoRegular::setNewestVersion(const VersionInfo& version) -{ - if (version != m_NewestVersion) { - m_NewestVersion = version; - m_MetaInfoChanged = true; - } -} - -void ModInfoRegular::setNexusDescription(const QString& description) -{ - if (qHash(description) != qHash(m_NexusDescription)) { - m_NexusDescription = description; - m_MetaInfoChanged = true; - } -} - -void ModInfoRegular::setEndorsedState(EndorsedState endorsedState) -{ - if (endorsedState != m_EndorsedState) { - m_EndorsedState = endorsedState; - m_MetaInfoChanged = true; - } -} - -void ModInfoRegular::setTrackedState(TrackedState trackedState) -{ - if (trackedState != m_TrackedState) { - m_TrackedState = trackedState; - m_MetaInfoChanged = true; - } -} - -void ModInfoRegular::setInstallationFile(const QString& fileName) -{ - m_InstallationFile = fileName; - m_MetaInfoChanged = true; -} - -void ModInfoRegular::addNexusCategory(int categoryID) -{ - m_Categories.insert(CategoryFactory::instance().resolveNexusID(categoryID)); -} - -void ModInfoRegular::setIsEndorsed(bool endorsed) -{ - m_EndorsedState = - endorsed ? EndorsedState::ENDORSED_TRUE : EndorsedState::ENDORSED_FALSE; - m_MetaInfoChanged = true; -} - -void ModInfoRegular::setNeverEndorse() -{ - m_EndorsedState = EndorsedState::ENDORSED_NEVER; - m_MetaInfoChanged = true; -} - -void ModInfoRegular::setIsTracked(bool tracked) -{ - if (tracked != (m_TrackedState == TrackedState::TRACKED_TRUE)) { - m_TrackedState = tracked ? TrackedState::TRACKED_TRUE : TrackedState::TRACKED_FALSE; - m_MetaInfoChanged = true; - } -} - -void ModInfoRegular::setColor(QColor color) -{ - m_Color = color; - m_MetaInfoChanged = true; -} - -QColor ModInfoRegular::color() const -{ - return m_Color; -} - -void ModInfoRegular::endorse(bool doEndorse) -{ - if (doEndorse != (m_EndorsedState == EndorsedState::ENDORSED_TRUE)) { - m_NexusBridge.requestToggleEndorsement( - m_GameName, nexusId(), m_Version.canonicalString(), doEndorse, QVariant(1)); - } -} - -void ModInfoRegular::track(bool doTrack) -{ - if (doTrack != (m_TrackedState == TrackedState::TRACKED_TRUE)) { - m_NexusBridge.requestToggleTracking(m_GameName, nexusId(), doTrack, QVariant(1)); - } -} - -void ModInfoRegular::markConverted(bool converted) -{ - m_Converted = converted; - m_MetaInfoChanged = true; - saveMeta(); - emit modDetailsUpdated(true); -} - -void ModInfoRegular::markValidated(bool validated) -{ - m_Validated = validated; - m_MetaInfoChanged = true; - saveMeta(); - emit modDetailsUpdated(true); -} - -QString ModInfoRegular::absolutePath() const -{ - return m_Path; -} - -void ModInfoRegular::ignoreUpdate(bool ignore) -{ - if (ignore) { - m_IgnoredVersion = m_NewestVersion; - } else { - m_IgnoredVersion.clear(); - } - m_MetaInfoChanged = true; -} - -bool ModInfoRegular::canBeUpdated() const -{ - QDateTime const now = QDateTime::currentDateTimeUtc(); - QDateTime const target = getExpires(); - if (now >= target) - return m_NexusID > 0; - return false; -} - -QDateTime ModInfoRegular::getExpires() const -{ - return m_LastNexusUpdate.addSecs(300); -} - -std::vector ModInfoRegular::getFlags() const -{ - std::vector result = ModInfoWithConflictInfo::getFlags(); - if ((m_NexusID > 0) && (endorsedState() == EndorsedState::ENDORSED_FALSE) && - Settings::instance().nexus().endorsementIntegration()) { - result.push_back(ModInfo::FLAG_NOTENDORSED); - } - if ((m_NexusID > 0) && (trackedState() == TrackedState::TRACKED_TRUE) && - Settings::instance().nexus().trackedIntegration()) { - result.push_back(ModInfo::FLAG_TRACKED); - } - if (!isValid() && !m_Validated) { - result.push_back(ModInfo::FLAG_INVALID); - } - if (m_Notes.length() != 0) { - result.push_back(ModInfo::FLAG_NOTES); - } - if (m_PluginSelected) { - result.push_back(ModInfo::FLAG_PLUGIN_SELECTED); - } - if (m_IsAlternate && !m_Converted) { - result.push_back(ModInfo::FLAG_ALTERNATE_GAME); - } - return result; -} - -std::set ModInfoRegular::doGetContents() const -{ - auto contentFeature = - m_Core.pluginContainer().gameFeatures().gameFeature(); - - if (contentFeature) { - auto result = contentFeature->getContentsFor(fileTree()); - return {std::begin(result), std::end(result)}; - } - - return {}; -} - -int ModInfoRegular::getHighlight() const -{ - if (!isValid() && !m_Validated) - return HIGHLIGHT_INVALID; - auto flags = getFlags(); - if (std::find(flags.begin(), flags.end(), ModInfo::FLAG_PLUGIN_SELECTED) != - flags.end()) - return HIGHLIGHT_PLUGIN; - return HIGHLIGHT_NONE; -} - -QString ModInfoRegular::getDescription() const -{ - if (!isValid() && !m_Validated) { - return tr("%1 contains no esp/esm/esl and no asset (textures, meshes, interface, " - "...) directory") - .arg(name()); - } else { - const std::set& categories = getCategories(); - if (categories.empty()) { - return {}; - } - std::wostringstream categoryString; - categoryString << ToWString(tr("Categories:
    ")); - CategoryFactory const& categoryFactory = CategoryFactory::instance(); - for (std::set::const_iterator catIter = categories.begin(); - catIter != categories.end(); ++catIter) { - if (catIter != categories.begin()) { - categoryString << " , "; - } - categoryString << "" - << ToWString(categoryFactory.getCategoryName( - categoryFactory.getCategoryIndex(*catIter))) - << ""; - } - - return ToQString(categoryString.str()); - } -} - -int ModInfoRegular::getNexusFileStatus() const -{ - return m_NexusFileStatus; -} - -void ModInfoRegular::setNexusFileStatus(int status) -{ - m_NexusFileStatus = status; - m_MetaInfoChanged = true; - saveMeta(); - emit modDetailsUpdated(true); -} - -QString ModInfoRegular::comments() const -{ - return m_Comments; -} - -QString ModInfoRegular::notes() const -{ - return m_Notes; -} - -QDateTime ModInfoRegular::creationTime() const -{ - return m_CreationTime; -} - -QString ModInfoRegular::getNexusDescription() const -{ - return m_NexusDescription; -} - -QString ModInfoRegular::repository() const -{ - return m_Repository; -} - -EndorsedState ModInfoRegular::endorsedState() const -{ - return m_EndorsedState; -} - -TrackedState ModInfoRegular::trackedState() const -{ - return m_TrackedState; -} - -QDateTime ModInfoRegular::getLastNexusUpdate() const -{ - return m_LastNexusUpdate; -} - -void ModInfoRegular::setLastNexusUpdate(QDateTime time) -{ - m_LastNexusUpdate = time; - m_MetaInfoChanged = true; - saveMeta(); - emit modDetailsUpdated(true); -} - -QDateTime ModInfoRegular::getLastNexusQuery() const -{ - return m_LastNexusQuery; -} - -void ModInfoRegular::setLastNexusQuery(QDateTime time) -{ - m_LastNexusQuery = time; - m_MetaInfoChanged = true; - saveMeta(); - emit modDetailsUpdated(true); -} - -QDateTime ModInfoRegular::getNexusLastModified() const -{ - return m_NexusLastModified; -} - -void ModInfoRegular::setNexusLastModified(QDateTime time) -{ - m_NexusLastModified = time; - m_MetaInfoChanged = true; - saveMeta(); - emit modDetailsUpdated(true); -} - -int ModInfoRegular::getNexusCategory() const -{ - return m_NexusCategory; -} - -void ModInfoRegular::setNexusCategory(int category) -{ - m_NexusCategory = category; - m_MetaInfoChanged = true; - saveMeta(); -} - -QString ModInfoRegular::author() const -{ - return m_Author; -} - -void ModInfoRegular::setAuthor(const QString& author) -{ - m_Author = author; - m_MetaInfoChanged = true; - saveMeta(); - emit modDetailsUpdated(true); -} - -QString ModInfoRegular::uploader() const -{ - return m_Uploader; -} - -void ModInfoRegular::setUploader(const QString& uploader) -{ - m_Uploader = uploader; - m_MetaInfoChanged = true; - saveMeta(); - emit modDetailsUpdated(true); -} - -QString ModInfoRegular::uploaderUrl() const -{ - return m_UploaderUrl; -} - -void ModInfoRegular::setUploaderUrl(const QString& uploaderUrl) -{ - m_UploaderUrl = uploaderUrl; - m_MetaInfoChanged = true; - saveMeta(); - emit modDetailsUpdated(true); -} - -void ModInfoRegular::setCustomURL(QString const& url) -{ - m_CustomURL = url; - m_MetaInfoChanged = true; -} - -QString ModInfoRegular::url() const -{ - return m_CustomURL; -} - -void ModInfoRegular::setHasCustomURL(bool b) -{ - m_HasCustomURL = b; - m_MetaInfoChanged = true; -} - -bool ModInfoRegular::hasCustomURL() const -{ - return m_HasCustomURL; -} - -QStringList ModInfoRegular::archives(bool checkOnDisk) -{ - if (checkOnDisk) { - QStringList result; - QDir const dir(this->absolutePath()); - QStringList const bsaList = dir.entryList(QStringList({"*.bsa", "*.ba2"})); - for (const QString& archive : bsaList) { - result.append(this->absolutePath() + "/" + archive); - } - m_Archives = result; - } - return m_Archives; -} - -void ModInfoRegular::addInstalledFile(int modId, int fileId) -{ - m_InstalledFileIDs.insert(std::make_pair(modId, fileId)); - m_MetaInfoChanged = true; -} - -std::vector ModInfoRegular::getIniTweaks() const -{ - QString const metaFileName = absolutePath().append("/meta.ini"); - QSettings metaFile(metaFileName, QSettings::IniFormat); - - std::vector result; - - int numTweaks = metaFile.beginReadArray("INI Tweaks"); - - if (numTweaks != 0) { - log::debug("{} active ini tweaks in {}", numTweaks, - QDir::toNativeSeparators(metaFileName)); - } - - for (int i = 0; i < numTweaks; ++i) { - metaFile.setArrayIndex(i); - QString const filename = - absolutePath().append("/INI Tweaks/").append(metaFile.value("name").toString()); - result.push_back(filename); - } - metaFile.endArray(); - return result; -} - -std::map -ModInfoRegular::pluginSettings(const QString& pluginName) const -{ - auto itp = m_PluginSettings.find(pluginName); - if (itp == std::end(m_PluginSettings)) { - return {}; - } - return itp->second; -} - -QVariant ModInfoRegular::pluginSetting(const QString& pluginName, const QString& key, - const QVariant& defaultValue) const -{ - auto itp = m_PluginSettings.find(pluginName); - if (itp == std::end(m_PluginSettings)) { - return defaultValue; - } - - auto its = itp->second.find(key); - if (its == std::end(itp->second)) { - return defaultValue; - } - - return its->second; -} - -bool ModInfoRegular::setPluginSetting(const QString& pluginName, const QString& key, - const QVariant& value) -{ - m_PluginSettings[pluginName][key] = value; - m_MetaInfoChanged = true; - saveMeta(); - return true; -} - -std::map -ModInfoRegular::clearPluginSettings(const QString& pluginName) -{ - auto itp = m_PluginSettings.find(pluginName); - if (itp == std::end(m_PluginSettings)) { - return {}; - } - auto settings = itp->second; - m_PluginSettings.erase(itp); - saveMeta(); - return settings; -} +#include "modinforegular.h" + +#include "categories.h" +#include "messagedialog.h" +#include "moddatacontent.h" +#include "organizercore.h" +#include "plugincontainer.h" +#include "report.h" +#include "settings.h" +#include +#include + +#include +#include +#include +#include + +#include + +using namespace MOBase; +using namespace MOShared; + +namespace +{ +// Arguably this should be a class static or we should be using FileString rather +// than QString for the names. Or both. +static bool ByName(const ModInfo::Ptr& LHS, const ModInfo::Ptr& RHS) +{ + return QString::compare(LHS->name(), RHS->name(), Qt::CaseInsensitive) < 0; +} + +QString loadMetaPath(const QString& value) +{ + if (value.isEmpty()) { + return value; + } + + if (MOBase::isWindowsDrivePath(value) || QDir::isAbsolutePath(value)) { + return MOBase::normalizePathForHost(value); + } + + return QDir::fromNativeSeparators(value); +} + +QString storeMetaPath(const QString& value) +{ + if (value.isEmpty()) { + return value; + } + + if (MOBase::isWindowsDrivePath(value) || QDir::isAbsolutePath(value)) { + return MOBase::normalizePathForWine(value); + } + + return QDir::fromNativeSeparators(value); +} +} // namespace + +ModInfoRegular::ModInfoRegular(const QDir& path, OrganizerCore& core) + : ModInfoWithConflictInfo(core), m_Name(path.dirName()), + m_Path(path.absolutePath()), + m_GameName(core.managedGame()->gameShortName()), + m_NexusBridge(&core.pluginContainer()) +{ + m_CreationTime = QFileInfo(path.absolutePath()).birthTime(); + // read out the meta-file for information + readMeta(); + if (m_GameName.compare(core.managedGame()->gameShortName(), Qt::CaseInsensitive) != 0) + if (!core.managedGame()->primarySources().contains(m_GameName, Qt::CaseInsensitive)) + m_IsAlternate = true; + + // populate m_Archives + m_Archives = QStringList(); + if (Settings::instance().archiveParsing()) { + archives(true); + } + + connect(&m_NexusBridge, + SIGNAL(descriptionAvailable(QString, int, QVariant, QVariant)), this, + SLOT(nxmDescriptionAvailable(QString, int, QVariant, QVariant))); + connect(&m_NexusBridge, SIGNAL(endorsementToggled(QString, int, QVariant, QVariant)), + this, SLOT(nxmEndorsementToggled(QString, int, QVariant, QVariant))); + connect(&m_NexusBridge, SIGNAL(trackingToggled(QString, int, QVariant, bool)), this, + SLOT(nxmTrackingToggled(QString, int, QVariant, bool))); + connect(&m_NexusBridge, + SIGNAL(requestFailed(QString, int, int, QVariant, int, QString)), this, + SLOT(nxmRequestFailed(QString, int, int, QVariant, int, QString))); +} + +ModInfoRegular::~ModInfoRegular() +{ + try { + saveMeta(); + } catch (const std::exception& e) { + log::error("failed to save meta information for \"{}\": {}", m_Name, e.what()); + } +} + +bool ModInfoRegular::isEmpty() const +{ + QDirIterator iter(m_Path, QDir::NoDotAndDotDot | QDir::Files | QDir::Dirs); + if (!iter.hasNext()) + return true; + iter.next(); + return (iter.fileName() == "meta.ini") && !iter.hasNext(); +} + +void ModInfoRegular::readMeta() +{ + QSettings metaFile(m_Path + "/meta.ini", QSettings::IniFormat); + m_Comments = metaFile.value("comments", "").toString(); + m_Notes = metaFile.value("notes", "").toString(); + QString const tempGameName = metaFile.value("gameName", m_GameName).toString(); + if (tempGameName.size()) + m_GameName = tempGameName; + m_NexusID = metaFile.value("modid", -1).toInt(); + m_Version.parse(metaFile.value("version", "").toString()); + m_NewestVersion = metaFile.value("newestVersion", "").toString(); + m_IgnoredVersion = metaFile.value("ignoredVersion", "").toString(); + m_InstallationFile = + loadMetaPath(metaFile.value("installationFile", "").toString()); + m_NexusDescription = metaFile.value("nexusDescription", "").toString(); + m_NexusFileStatus = metaFile.value("nexusFileStatus", "1").toInt(); + m_NexusCategory = metaFile.value("nexusCategory", 0).toInt(); + m_Author = metaFile.value("author", "").toString(); + m_Uploader = metaFile.value("uploader", "").toString(); + m_UploaderUrl = metaFile.value("uploaderUrl", "").toString(); + m_Repository = metaFile.value("repository", "Nexus").toString(); + m_Converted = metaFile.value("converted", false).toBool(); + m_Validated = metaFile.value("validated", false).toBool(); + + // this handles changes to how the URL works after 2.2.0 + // + // in 2.2.0, "hasCustomUrl" does not exist and "url" is only used when the mod + // id is invalid, although it can be set at any time in the mod info dialog + // + // post 2.2.0, a custom url can be set on any mod, whether the mod id is + // valid or not, so an additional flag "hasCustomURL" is required, with a + // corresponding checkbox in the mod info dialog + // + // there are several cases to handle to make sure no data is lost and to + // determine whether the user has set a custom url before: + // + // 1) some mods have an incorrect url set along with a valid mod id; + // there is apparently a bug with the fomod installer that can set the + // url of a mod to a value used by a _previous_ installation + // + // 2) it is possible to set the url even if the mod id is valid, in which + // case it is saved, but never used in 2.2.0 + // + // 3) opening the mod info dialog on the nexus tab for a mod that has a + // valid id will force the url to be the same as what the plugin gives + // back + // + // the algorithm is as follows: + // always read the url from the meta file and store it so this piece of data + // is never lost; the problem then only becomes about whether to enable + // hasCustomURL + // + // if hasCustomURL is present in the meta file, just read that and be + // done with it + // + // if not, then the flag depends on the mod id and the url + // if the mod id is valid, the custom url is disabled; although the url + // could be _set_ by the user when a mod id was valid, it was never + // _used_, so the behaviour won't change + // + // if the mod id is invalid, the url should normally be empty, unless the + // user specified one, in which case hasCustomURL should be true + // (the only case where this fails is if a mod id was valid before and + // the user visited the nexus tab, in which case the url was set + // automatically, but then the id was manually changed to 0 + // + // in that case, the mod id is invalid and the url is not empty, but it + // was never set by the user; this case is impossible to distinguish + // from a user manually entering a url, and so is handled as such) + + // always read the url + m_CustomURL = metaFile.value("url").toString(); + + if (metaFile.contains("hasCustomURL")) { + m_HasCustomURL = metaFile.value("hasCustomURL").toBool(); + } else { + if (m_NexusID > 0) { + // the mod id is valid, disable the custom url + m_HasCustomURL = false; + } else { + if (!m_CustomURL.isEmpty()) { + // the mod id is invalid and the url is not empty, enable it + m_HasCustomURL = true; + } + } + } + + m_LastNexusQuery = QDateTime::fromString( + metaFile.value("lastNexusQuery", "").toString(), Qt::ISODate); + m_LastNexusUpdate = QDateTime::fromString( + metaFile.value("lastNexusUpdate", "").toString(), Qt::ISODate); + m_NexusLastModified = QDateTime::fromString( + metaFile.value("nexusLastModified", QDateTime::currentDateTimeUtc()).toString(), + Qt::ISODate); + m_NexusCategory = metaFile.value("nexusCategory", 0).toInt(); + m_Color = metaFile.value("color", QColor()).value(); + m_TrackedState = metaFile.value("tracked", false).toBool() + ? TrackedState::TRACKED_TRUE + : TrackedState::TRACKED_FALSE; + if (metaFile.contains("endorsed")) { + if (metaFile.value("endorsed").canConvert()) { + using ut = std::underlying_type_t; + switch (metaFile.value("endorsed").toInt()) { + case static_cast(EndorsedState::ENDORSED_FALSE): + m_EndorsedState = EndorsedState::ENDORSED_FALSE; + break; + case static_cast(EndorsedState::ENDORSED_TRUE): + m_EndorsedState = EndorsedState::ENDORSED_TRUE; + break; + case static_cast(EndorsedState::ENDORSED_NEVER): + m_EndorsedState = EndorsedState::ENDORSED_NEVER; + break; + default: + m_EndorsedState = EndorsedState::ENDORSED_UNKNOWN; + break; + } + } else { + m_EndorsedState = metaFile.value("endorsed", false).toBool() + ? EndorsedState::ENDORSED_TRUE + : EndorsedState::ENDORSED_FALSE; + } + } + + QString const categoriesString = metaFile.value("category", "").toString(); + + QStringList categories = categoriesString.split(',', Qt::SkipEmptyParts); + for (QStringList::iterator iter = categories.begin(); iter != categories.end(); + ++iter) { + bool ok = false; + int const categoryID = iter->toInt(&ok); + if (categoryID < 0) { + // ignore invalid id + continue; + } + if (ok && (categoryID != 0) && + (CategoryFactory::instance().categoryExists(categoryID))) { + m_Categories.insert(categoryID); + if (iter == categories.begin()) { + m_PrimaryCategory = categoryID; + } + } + } + + int const numFiles = metaFile.beginReadArray("installedFiles"); + for (int i = 0; i < numFiles; ++i) { + metaFile.setArrayIndex(i); + m_InstalledFileIDs.insert(std::make_pair(metaFile.value("modid").toInt(), + metaFile.value("fileid").toInt())); + } + metaFile.endArray(); + + // Plugin settings: + metaFile.beginGroup("Plugins"); + for (const auto& pluginName : metaFile.childGroups()) { + metaFile.beginGroup(pluginName); + for (const auto& settingKey : metaFile.childKeys()) { + m_PluginSettings[pluginName][settingKey] = metaFile.value(settingKey); + } + metaFile.endGroup(); + } + metaFile.endGroup(); + + m_MetaInfoChanged = false; +} + +void ModInfoRegular::saveMeta() +{ + // only write meta data if the mod directory exists + if (m_MetaInfoChanged && QFile::exists(absolutePath())) { + QSettings metaFile(absolutePath().append("/meta.ini"), QSettings::IniFormat); + if (metaFile.status() == QSettings::NoError) { + std::set temp = m_Categories; + temp.erase(m_PrimaryCategory); + metaFile.setValue("category", QString("%1").arg(m_PrimaryCategory) + "," + + SetJoin(temp, ",")); + metaFile.setValue("newestVersion", m_NewestVersion.canonicalString()); + metaFile.setValue("ignoredVersion", m_IgnoredVersion.canonicalString()); + metaFile.setValue("version", m_Version.canonicalString()); + metaFile.setValue("installationFile", storeMetaPath(m_InstallationFile)); + metaFile.setValue("repository", m_Repository); + metaFile.setValue("gameName", m_GameName); + metaFile.setValue("modid", m_NexusID); + metaFile.setValue("comments", m_Comments); + metaFile.setValue("notes", m_Notes); + metaFile.setValue("nexusDescription", m_NexusDescription); + metaFile.setValue("url", m_CustomURL); + metaFile.setValue("hasCustomURL", m_HasCustomURL); + metaFile.setValue("nexusFileStatus", m_NexusFileStatus); + metaFile.setValue("lastNexusQuery", m_LastNexusQuery.toString(Qt::ISODate)); + metaFile.setValue("lastNexusUpdate", m_LastNexusUpdate.toString(Qt::ISODate)); + metaFile.setValue("nexusLastModified", m_NexusLastModified.toString(Qt::ISODate)); + metaFile.setValue("nexusCategory", m_NexusCategory); + metaFile.setValue("author", m_Author); + metaFile.setValue("uploader", m_Uploader); + metaFile.setValue("uploaderUrl", m_UploaderUrl); + metaFile.setValue("converted", m_Converted); + metaFile.setValue("validated", m_Validated); + metaFile.setValue("color", m_Color); + if (m_EndorsedState != EndorsedState::ENDORSED_UNKNOWN) { + metaFile.setValue( + "endorsed", + static_cast>(m_EndorsedState)); + } + if (m_TrackedState != TrackedState::TRACKED_UNKNOWN) { + metaFile.setValue("tracked", static_cast>( + m_TrackedState)); + } + + metaFile.remove("installedFiles"); + metaFile.beginWriteArray("installedFiles"); + int idx = 0; + for (auto iter = m_InstalledFileIDs.begin(); iter != m_InstalledFileIDs.end(); + ++iter) { + metaFile.setArrayIndex(idx++); + metaFile.setValue("modid", iter->first); + metaFile.setValue("fileid", iter->second); + } + metaFile.endArray(); + + // Plugin settings: + metaFile.remove("Plugins"); + metaFile.beginGroup("Plugins"); + for (const auto& [pluginName, pluginSettings] : m_PluginSettings) { + metaFile.beginGroup(pluginName); + for (const auto& [settingName, settingValue] : pluginSettings) { + metaFile.setValue(settingName, settingValue); + } + metaFile.endGroup(); + } + metaFile.endGroup(); + + metaFile.sync(); // sync needs to be called to ensure the file is created + + if (metaFile.status() == QSettings::NoError) { + m_MetaInfoChanged = false; + } else { + log::error("failed to write {}/meta.ini: error {}", absolutePath(), + metaFile.status()); + } + } else { + log::error("failed to write {}/meta.ini: error {}", absolutePath(), + metaFile.status()); + } + } +} + +bool ModInfoRegular::updateAvailable() const +{ + if (m_IgnoredVersion.isValid() && (m_IgnoredVersion == m_NewestVersion)) { + return false; + } + if (m_NexusFileStatus == 4 || m_NexusFileStatus == 6) { + return true; + } + return m_NewestVersion.isValid() && (m_Version < m_NewestVersion); +} + +bool ModInfoRegular::downgradeAvailable() const +{ + if (m_IgnoredVersion.isValid() && (m_IgnoredVersion == m_NewestVersion)) { + return false; + } + return m_NewestVersion.isValid() && (m_NewestVersion < m_Version); +} + +void ModInfoRegular::nxmDescriptionAvailable(QString, int, QVariant, + QVariant resultData) +{ + QVariantMap result = resultData.toMap(); + setNexusDescription(result["description"].toString()); + + if ((m_EndorsedState != EndorsedState::ENDORSED_NEVER) && + (result.contains("endorsement"))) { + QVariantMap endorsement = result["endorsement"].toMap(); + QString const endorsementStatus = endorsement["endorse_status"].toString(); + if (endorsementStatus.compare("Endorsed", Qt::CaseInsensitive) == 00) + setEndorsedState(EndorsedState::ENDORSED_TRUE); + else if (endorsementStatus.compare("Abstained", Qt::CaseInsensitive) == 00) + setEndorsedState(EndorsedState::ENDORSED_NEVER); + else + setEndorsedState(EndorsedState::ENDORSED_FALSE); + } + m_LastNexusQuery = QDateTime::currentDateTimeUtc(); + m_NexusLastModified = + QDateTime::fromSecsSinceEpoch(result["updated_timestamp"].toInt(), QTimeZone::UTC); + m_MetaInfoChanged = true; + saveMeta(); + disconnect(sender(), SIGNAL(descriptionAvailable(QString, int, QVariant, QVariant))); + emit modDetailsUpdated(true); +} + +void ModInfoRegular::nxmEndorsementToggled(QString, int, QVariant, QVariant resultData) +{ + QMap results = resultData.toMap(); + QMutexLocker const locker(&s_Mutex); + for (auto& mod : s_Collection) { + if (mod->gameName().compare(m_GameName, Qt::CaseInsensitive) == 0 && + mod->nexusId() == m_NexusID) { + if (results["status"].toString().compare("Endorsed") == 0) { + mod->setIsEndorsed(true); + } else if (results["status"].toString().compare("Abstained") == 0) { + mod->setNeverEndorse(); + } else { + mod->setIsEndorsed(false); + } + mod->saveMeta(); + } + } + emit modDetailsUpdated(true); +} + +void ModInfoRegular::nxmTrackingToggled(QString, int, QVariant, bool tracked) +{ + QMutexLocker const locker(&s_Mutex); + for (auto& mod : s_Collection) { + if (mod->gameName().compare(m_GameName, Qt::CaseInsensitive) == 0 && + mod->nexusId() == m_NexusID) { + mod->setIsTracked(tracked); + mod->saveMeta(); + } + } + emit modDetailsUpdated(true); +} + +void ModInfoRegular::nxmRequestFailed(QString, int, int, QVariant userData, + int errorCode, const QString& errorMessage) +{ + QString fullMessage = errorMessage; + if (userData.canConvert() && (userData.toInt() == 1)) { + fullMessage += "\nNexus will reject endorsements within 15 Minutes of a failed " + "attempt, the error message may be misleading."; + } + if (QApplication::activeWindow() != nullptr) { + MessageDialog::showMessage(fullMessage, QApplication::activeWindow()); + } + emit modDetailsUpdated(false); +} + +bool ModInfoRegular::updateNXMInfo() +{ + if (needsDescriptionUpdate()) { + m_NexusBridge.requestDescription(m_GameName, m_NexusID, QVariant()); + return true; + } + + return false; +} + +bool ModInfoRegular::needsDescriptionUpdate() const +{ + if (m_NexusID > 0) { + QDateTime const time = QDateTime::currentDateTimeUtc(); + QDateTime const target = m_LastNexusQuery.addDays(1); + + if (time >= target) { + return true; + } + } + + return false; +} + +void ModInfoRegular::setCategory(int categoryID, bool active) +{ + m_MetaInfoChanged = true; + + if (active) { + m_Categories.insert(categoryID); + if (m_PrimaryCategory == -1) { + m_PrimaryCategory = categoryID; + } + } else { + std::set::iterator const iter = m_Categories.find(categoryID); + if (iter != m_Categories.end()) { + m_Categories.erase(iter); + } + if (categoryID == m_PrimaryCategory) { + if (m_Categories.empty()) { + m_PrimaryCategory = -1; + } else { + m_PrimaryCategory = *(m_Categories.begin()); + } + } + } +} + +bool ModInfoRegular::setName(const QString& name) +{ + if (name.contains('/') || name.contains('\\')) { + return false; + } + + QString const newPath = + m_Path.mid(0).replace(m_Path.length() - m_Name.length(), m_Name.length(), name); + QDir modDir(m_Path.mid(0, m_Path.length() - m_Name.length())); + + if (m_Name.compare(name, Qt::CaseInsensitive) == 0) { + QString tempName = name; + tempName.append("_temp"); + while (modDir.exists(tempName)) { + tempName.append("_"); + } + if (!modDir.rename(m_Name, tempName)) { + return false; + } + if (!modDir.rename(tempName, name)) { + log::error( + "rename to final name failed after successful rename to intermediate name"); + modDir.rename(tempName, m_Name); + return false; + } + } else { + if (!shellRename(modDir.absoluteFilePath(m_Name), modDir.absoluteFilePath(name))) { + log::error("failed to rename mod {} (errorcode {})", name, ::GetLastError()); + return false; + } + } + + std::map::iterator const nameIter = s_ModsByName.find(m_Name); + if (nameIter != s_ModsByName.end()) { + QMutexLocker const locker(&s_Mutex); + + unsigned int const index = nameIter->second; + s_ModsByName.erase(nameIter); + + m_Name = name; + m_Path = newPath; + + s_ModsByName[m_Name] = index; + + std::sort(s_Collection.begin(), s_Collection.end(), ModInfo::ByName); + updateIndices(); + } else { // otherwise mod isn't registered yet? + m_Name = name; + m_Path = newPath; + } + + return true; +} + +void ModInfoRegular::setComments(const QString& comments) +{ + m_Comments = comments; + m_MetaInfoChanged = true; +} + +void ModInfoRegular::setNotes(const QString& notes) +{ + m_Notes = notes; + m_MetaInfoChanged = true; +} + +void ModInfoRegular::setGameName(const QString& gameName) +{ + m_GameName = gameName; + m_MetaInfoChanged = true; +} + +void ModInfoRegular::setNexusID(int modID) +{ + m_NexusID = modID; + m_MetaInfoChanged = true; +} + +void ModInfoRegular::setVersion(const VersionInfo& version) +{ + m_Version = version; + m_MetaInfoChanged = true; +} + +void ModInfoRegular::setNewestVersion(const VersionInfo& version) +{ + if (version != m_NewestVersion) { + m_NewestVersion = version; + m_MetaInfoChanged = true; + } +} + +void ModInfoRegular::setNexusDescription(const QString& description) +{ + if (qHash(description) != qHash(m_NexusDescription)) { + m_NexusDescription = description; + m_MetaInfoChanged = true; + } +} + +void ModInfoRegular::setEndorsedState(EndorsedState endorsedState) +{ + if (endorsedState != m_EndorsedState) { + m_EndorsedState = endorsedState; + m_MetaInfoChanged = true; + } +} + +void ModInfoRegular::setTrackedState(TrackedState trackedState) +{ + if (trackedState != m_TrackedState) { + m_TrackedState = trackedState; + m_MetaInfoChanged = true; + } +} + +void ModInfoRegular::setInstallationFile(const QString& fileName) +{ + m_InstallationFile = fileName; + m_MetaInfoChanged = true; +} + +void ModInfoRegular::addNexusCategory(int categoryID) +{ + m_Categories.insert(CategoryFactory::instance().resolveNexusID(categoryID)); +} + +void ModInfoRegular::setIsEndorsed(bool endorsed) +{ + m_EndorsedState = + endorsed ? EndorsedState::ENDORSED_TRUE : EndorsedState::ENDORSED_FALSE; + m_MetaInfoChanged = true; +} + +void ModInfoRegular::setNeverEndorse() +{ + m_EndorsedState = EndorsedState::ENDORSED_NEVER; + m_MetaInfoChanged = true; +} + +void ModInfoRegular::setIsTracked(bool tracked) +{ + if (tracked != (m_TrackedState == TrackedState::TRACKED_TRUE)) { + m_TrackedState = tracked ? TrackedState::TRACKED_TRUE : TrackedState::TRACKED_FALSE; + m_MetaInfoChanged = true; + } +} + +void ModInfoRegular::setColor(QColor color) +{ + m_Color = color; + m_MetaInfoChanged = true; +} + +QColor ModInfoRegular::color() const +{ + return m_Color; +} + +void ModInfoRegular::endorse(bool doEndorse) +{ + if (doEndorse != (m_EndorsedState == EndorsedState::ENDORSED_TRUE)) { + m_NexusBridge.requestToggleEndorsement( + m_GameName, nexusId(), m_Version.canonicalString(), doEndorse, QVariant(1)); + } +} + +void ModInfoRegular::track(bool doTrack) +{ + if (doTrack != (m_TrackedState == TrackedState::TRACKED_TRUE)) { + m_NexusBridge.requestToggleTracking(m_GameName, nexusId(), doTrack, QVariant(1)); + } +} + +void ModInfoRegular::markConverted(bool converted) +{ + m_Converted = converted; + m_MetaInfoChanged = true; + saveMeta(); + emit modDetailsUpdated(true); +} + +void ModInfoRegular::markValidated(bool validated) +{ + m_Validated = validated; + m_MetaInfoChanged = true; + saveMeta(); + emit modDetailsUpdated(true); +} + +QString ModInfoRegular::absolutePath() const +{ + return m_Path; +} + +void ModInfoRegular::ignoreUpdate(bool ignore) +{ + if (ignore) { + m_IgnoredVersion = m_NewestVersion; + } else { + m_IgnoredVersion.clear(); + } + m_MetaInfoChanged = true; +} + +bool ModInfoRegular::canBeUpdated() const +{ + QDateTime const now = QDateTime::currentDateTimeUtc(); + QDateTime const target = getExpires(); + if (now >= target) + return m_NexusID > 0; + return false; +} + +QDateTime ModInfoRegular::getExpires() const +{ + return m_LastNexusUpdate.addSecs(300); +} + +std::vector ModInfoRegular::getFlags() const +{ + std::vector result = ModInfoWithConflictInfo::getFlags(); + if ((m_NexusID > 0) && (endorsedState() == EndorsedState::ENDORSED_FALSE) && + Settings::instance().nexus().endorsementIntegration()) { + result.push_back(ModInfo::FLAG_NOTENDORSED); + } + if ((m_NexusID > 0) && (trackedState() == TrackedState::TRACKED_TRUE) && + Settings::instance().nexus().trackedIntegration()) { + result.push_back(ModInfo::FLAG_TRACKED); + } + if (!isValid() && !m_Validated) { + result.push_back(ModInfo::FLAG_INVALID); + } + if (m_Notes.length() != 0) { + result.push_back(ModInfo::FLAG_NOTES); + } + if (m_PluginSelected) { + result.push_back(ModInfo::FLAG_PLUGIN_SELECTED); + } + if (m_IsAlternate && !m_Converted) { + result.push_back(ModInfo::FLAG_ALTERNATE_GAME); + } + return result; +} + +std::set ModInfoRegular::doGetContents() const +{ + auto contentFeature = + m_Core.pluginContainer().gameFeatures().gameFeature(); + + if (contentFeature) { + auto result = contentFeature->getContentsFor(fileTree()); + return {std::begin(result), std::end(result)}; + } + + return {}; +} + +int ModInfoRegular::getHighlight() const +{ + if (!isValid() && !m_Validated) + return HIGHLIGHT_INVALID; + auto flags = getFlags(); + if (std::find(flags.begin(), flags.end(), ModInfo::FLAG_PLUGIN_SELECTED) != + flags.end()) + return HIGHLIGHT_PLUGIN; + return HIGHLIGHT_NONE; +} + +QString ModInfoRegular::getDescription() const +{ + if (!isValid() && !m_Validated) { + return tr("%1 contains no esp/esm/esl and no asset (textures, meshes, interface, " + "...) directory") + .arg(name()); + } else { + const std::set& categories = getCategories(); + if (categories.empty()) { + return {}; + } + std::wostringstream categoryString; + categoryString << ToWString(tr("Categories:
    ")); + CategoryFactory const& categoryFactory = CategoryFactory::instance(); + for (std::set::const_iterator catIter = categories.begin(); + catIter != categories.end(); ++catIter) { + if (catIter != categories.begin()) { + categoryString << " , "; + } + categoryString << "" + << ToWString(categoryFactory.getCategoryName( + categoryFactory.getCategoryIndex(*catIter))) + << ""; + } + + return ToQString(categoryString.str()); + } +} + +int ModInfoRegular::getNexusFileStatus() const +{ + return m_NexusFileStatus; +} + +void ModInfoRegular::setNexusFileStatus(int status) +{ + m_NexusFileStatus = status; + m_MetaInfoChanged = true; + saveMeta(); + emit modDetailsUpdated(true); +} + +QString ModInfoRegular::comments() const +{ + return m_Comments; +} + +QString ModInfoRegular::notes() const +{ + return m_Notes; +} + +QDateTime ModInfoRegular::creationTime() const +{ + return m_CreationTime; +} + +QString ModInfoRegular::getNexusDescription() const +{ + return m_NexusDescription; +} + +QString ModInfoRegular::repository() const +{ + return m_Repository; +} + +EndorsedState ModInfoRegular::endorsedState() const +{ + return m_EndorsedState; +} + +TrackedState ModInfoRegular::trackedState() const +{ + return m_TrackedState; +} + +QDateTime ModInfoRegular::getLastNexusUpdate() const +{ + return m_LastNexusUpdate; +} + +void ModInfoRegular::setLastNexusUpdate(QDateTime time) +{ + m_LastNexusUpdate = time; + m_MetaInfoChanged = true; + saveMeta(); + emit modDetailsUpdated(true); +} + +QDateTime ModInfoRegular::getLastNexusQuery() const +{ + return m_LastNexusQuery; +} + +void ModInfoRegular::setLastNexusQuery(QDateTime time) +{ + m_LastNexusQuery = time; + m_MetaInfoChanged = true; + saveMeta(); + emit modDetailsUpdated(true); +} + +QDateTime ModInfoRegular::getNexusLastModified() const +{ + return m_NexusLastModified; +} + +void ModInfoRegular::setNexusLastModified(QDateTime time) +{ + m_NexusLastModified = time; + m_MetaInfoChanged = true; + saveMeta(); + emit modDetailsUpdated(true); +} + +int ModInfoRegular::getNexusCategory() const +{ + return m_NexusCategory; +} + +void ModInfoRegular::setNexusCategory(int category) +{ + m_NexusCategory = category; + m_MetaInfoChanged = true; + saveMeta(); +} + +QString ModInfoRegular::author() const +{ + return m_Author; +} + +void ModInfoRegular::setAuthor(const QString& author) +{ + m_Author = author; + m_MetaInfoChanged = true; + saveMeta(); + emit modDetailsUpdated(true); +} + +QString ModInfoRegular::uploader() const +{ + return m_Uploader; +} + +void ModInfoRegular::setUploader(const QString& uploader) +{ + m_Uploader = uploader; + m_MetaInfoChanged = true; + saveMeta(); + emit modDetailsUpdated(true); +} + +QString ModInfoRegular::uploaderUrl() const +{ + return m_UploaderUrl; +} + +void ModInfoRegular::setUploaderUrl(const QString& uploaderUrl) +{ + m_UploaderUrl = uploaderUrl; + m_MetaInfoChanged = true; + saveMeta(); + emit modDetailsUpdated(true); +} + +void ModInfoRegular::setCustomURL(QString const& url) +{ + m_CustomURL = url; + m_MetaInfoChanged = true; +} + +QString ModInfoRegular::url() const +{ + return m_CustomURL; +} + +void ModInfoRegular::setHasCustomURL(bool b) +{ + m_HasCustomURL = b; + m_MetaInfoChanged = true; +} + +bool ModInfoRegular::hasCustomURL() const +{ + return m_HasCustomURL; +} + +QStringList ModInfoRegular::archives(bool checkOnDisk) +{ + if (checkOnDisk) { + QStringList result; + QDir const dir(this->absolutePath()); + QStringList const bsaList = dir.entryList(QStringList({"*.bsa", "*.ba2"})); + for (const QString& archive : bsaList) { + result.append(this->absolutePath() + "/" + archive); + } + m_Archives = result; + } + return m_Archives; +} + +void ModInfoRegular::addInstalledFile(int modId, int fileId) +{ + m_InstalledFileIDs.insert(std::make_pair(modId, fileId)); + m_MetaInfoChanged = true; +} + +std::vector ModInfoRegular::getIniTweaks() const +{ + QString const metaFileName = absolutePath().append("/meta.ini"); + QSettings metaFile(metaFileName, QSettings::IniFormat); + + std::vector result; + + int numTweaks = metaFile.beginReadArray("INI Tweaks"); + + if (numTweaks != 0) { + log::debug("{} active ini tweaks in {}", numTweaks, + QDir::toNativeSeparators(metaFileName)); + } + + for (int i = 0; i < numTweaks; ++i) { + metaFile.setArrayIndex(i); + QString const filename = + absolutePath().append("/INI Tweaks/").append(metaFile.value("name").toString()); + result.push_back(filename); + } + metaFile.endArray(); + return result; +} + +std::map +ModInfoRegular::pluginSettings(const QString& pluginName) const +{ + auto itp = m_PluginSettings.find(pluginName); + if (itp == std::end(m_PluginSettings)) { + return {}; + } + return itp->second; +} + +QVariant ModInfoRegular::pluginSetting(const QString& pluginName, const QString& key, + const QVariant& defaultValue) const +{ + auto itp = m_PluginSettings.find(pluginName); + if (itp == std::end(m_PluginSettings)) { + return defaultValue; + } + + auto its = itp->second.find(key); + if (its == std::end(itp->second)) { + return defaultValue; + } + + return its->second; +} + +bool ModInfoRegular::setPluginSetting(const QString& pluginName, const QString& key, + const QVariant& value) +{ + m_PluginSettings[pluginName][key] = value; + m_MetaInfoChanged = true; + saveMeta(); + return true; +} + +std::map +ModInfoRegular::clearPluginSettings(const QString& pluginName) +{ + auto itp = m_PluginSettings.find(pluginName); + if (itp == std::end(m_PluginSettings)) { + return {}; + } + auto settings = itp->second; + m_PluginSettings.erase(itp); + saveMeta(); + return settings; +} diff --git a/src/src/modinfowithconflictinfo.cpp b/src/src/modinfowithconflictinfo.cpp index e6f4a5f..1581e3e 100644 --- a/src/src/modinfowithconflictinfo.cpp +++ b/src/src/modinfowithconflictinfo.cpp @@ -1,372 +1,372 @@ -#include "modinfowithconflictinfo.h" -#include "shared/directoryentry.h" -#include "shared/fileentry.h" -#include "shared/filesorigin.h" -#include "utility.h" -#include - -#include "iplugingame.h" -#include "moddatachecker.h" -#include "organizercore.h" -#include "qdirfiletree.h" -#include "settings.h" - -using namespace MOBase; -using namespace MOShared; -namespace fs = std::filesystem; - -ModInfoWithConflictInfo::ModInfoWithConflictInfo(OrganizerCore& core) - : ModInfo(core), m_FileTree([this]() { - return QDirFileTree::makeTree(absolutePath()); - }), - m_Valid([this]() { - return doIsValid(); - }), - m_Contents([this]() { - return doGetContents(); - }), - m_Conflicts([this]() { - return doConflictCheck(); - }) -{} - -void ModInfoWithConflictInfo::clearCaches() -{ - m_Conflicts.invalidate(); -} - -std::vector ModInfoWithConflictInfo::getFlags() const -{ - std::vector result = std::vector(); - if (hasHiddenFiles()) { - result.push_back(ModInfo::FLAG_HIDDEN_FILES); - } - return result; -} - -std::vector ModInfoWithConflictInfo::getConflictFlags() const -{ - std::vector result; - switch (isConflicted()) { - case CONFLICT_MIXED: { - result.push_back(ModInfo::FLAG_CONFLICT_MIXED); - } break; - case CONFLICT_OVERWRITE: { - result.push_back(ModInfo::FLAG_CONFLICT_OVERWRITE); - } break; - case CONFLICT_OVERWRITTEN: { - result.push_back(ModInfo::FLAG_CONFLICT_OVERWRITTEN); - } break; - case CONFLICT_REDUNDANT: { - result.push_back(ModInfo::FLAG_CONFLICT_REDUNDANT); - } break; - default: { /* NOP */ - } - } - switch (isLooseArchiveConflicted()) { - case CONFLICT_MIXED: { - result.push_back(ModInfo::FLAG_ARCHIVE_LOOSE_CONFLICT_OVERWRITE); - result.push_back(ModInfo::FLAG_ARCHIVE_LOOSE_CONFLICT_OVERWRITTEN); - } break; - case CONFLICT_OVERWRITE: { - result.push_back(ModInfo::FLAG_ARCHIVE_LOOSE_CONFLICT_OVERWRITE); - } break; - case CONFLICT_OVERWRITTEN: { - result.push_back(ModInfo::FLAG_ARCHIVE_LOOSE_CONFLICT_OVERWRITTEN); - } break; - default: { /* NOP */ - } - } - switch (isArchiveConflicted()) { - case CONFLICT_MIXED: { - result.push_back(ModInfo::FLAG_ARCHIVE_CONFLICT_MIXED); - } break; - case CONFLICT_OVERWRITE: { - result.push_back(ModInfo::FLAG_ARCHIVE_CONFLICT_OVERWRITE); - } break; - case CONFLICT_OVERWRITTEN: { - result.push_back(ModInfo::FLAG_ARCHIVE_CONFLICT_OVERWRITTEN); - } break; - default: { /* NOP */ - } - } - return result; -} - -ModInfoWithConflictInfo::Conflicts ModInfoWithConflictInfo::doConflictCheck() const -{ - Conflicts conflicts; - - bool providesAnything = false; - bool hasHiddenFiles = false; - bool hasVisibleFiles = false; - - std::vector dataIDs; - if (m_Core.directoryStructure()->originExists(L"data")) { - dataIDs.push_back(m_Core.directoryStructure()->getOriginByName(L"data").getID()); - } - for (const auto& origin : m_Core.managedGame()->secondaryDataDirectories().keys()) { - if (m_Core.directoryStructure()->originExists(origin.toStdWString())) { - dataIDs.push_back( - m_Core.directoryStructure()->getOriginByName(origin.toStdWString()).getID()); - } - } - - std::wstring const name = ToWString(this->name()); - - if (m_Core.directoryStructure()->originExists(name)) { - FilesOrigin const& origin = m_Core.directoryStructure()->getOriginByName(name); - std::vector const files = origin.getFiles(); - std::set checkedDirs; - - // for all files in this origin - for (const FileEntryPtr& file : files) { - if (QString::fromStdWString(file->getName()) - .endsWith(ModInfo::s_HiddenExt, Qt::CaseInsensitive)) { - hasHiddenFiles = true; - // skip hidden file conflicts - continue; - } else { - const DirectoryEntry* parent = file->getParent(); - auto hidden = false; - - // iterate on all parent directory entries to check for .mohiddden - while (parent != nullptr) { - auto insertResult = checkedDirs.insert(parent); - - if (insertResult.second == false) { - // if already present break as we can assume to have checked the parents as - // well - break; - } else { - if (QString::fromStdWString(parent->getName()) - .endsWith(ModInfo::s_HiddenExt, Qt::CaseInsensitive)) { - hasHiddenFiles = hidden = true; - break; - } - parent = parent->getParent(); - } - } - if (hidden) { - // skip hidden file conflicts - continue; - } - } - - hasVisibleFiles = true; - auto alternatives = file->getAlternatives(); - if ((alternatives.empty()) || - std::find(dataIDs.begin(), dataIDs.end(), alternatives.back().originID()) != - dataIDs.end()) { - // no alternatives -> no conflict - providesAnything = true; - } else { - // Get the archive data for the current mod - DataArchiveOrigin archiveData; - if (file->getOrigin() == origin.getID()) - archiveData = file->getArchive(); - else { - for (const auto& alt : alternatives) { - if (alt.originID() == origin.getID()) { - archiveData = alt.archive(); - break; - } - } - } - - // If this is not the origin then determine the correct overwrite - if (file->getOrigin() != origin.getID()) { - FilesOrigin const& altOrigin = - m_Core.directoryStructure()->getOriginByID(file->getOrigin()); - unsigned int const altIndex = ModInfo::getIndex(ToQString(altOrigin.getName())); - if (!file->isFromArchive()) { - if (!archiveData.isValid()) - conflicts.m_OverwrittenList.insert(altIndex); - else - conflicts.m_ArchiveLooseOverwrittenList.insert(altIndex); - } else { - conflicts.m_ArchiveOverwrittenList.insert(altIndex); - } - } else { - providesAnything = true; - } - - // Sort out the alternatives - for (const auto& altInfo : alternatives) { - if (!(std::find(dataIDs.begin(), dataIDs.end(), - alternatives.back().originID()) != dataIDs.end()) && - (altInfo.originID() != origin.getID())) { - FilesOrigin const& altOrigin = - m_Core.directoryStructure()->getOriginByID(altInfo.originID()); - QString const altOriginName = ToQString(altOrigin.getName()); - unsigned int const altIndex = ModInfo::getIndex(altOriginName); - if (!altInfo.isFromArchive()) { - if (!archiveData.isValid()) { - if (origin.getPriority() > altOrigin.getPriority()) { - conflicts.m_OverwriteList.insert(altIndex); - } else { - conflicts.m_OverwrittenList.insert(altIndex); - } - } else { - conflicts.m_ArchiveLooseOverwrittenList.insert(altIndex); - } - } else { - if (!archiveData.isValid()) { - conflicts.m_ArchiveLooseOverwriteList.insert(altIndex); - } else { - if (archiveData.order() > altInfo.archive().order()) { - conflicts.m_ArchiveOverwriteList.insert(altIndex); - } else if (archiveData.order() < altInfo.archive().order()) { - conflicts.m_ArchiveOverwrittenList.insert(altIndex); - } - } - } - } - } - } - } - - if (!files.empty()) { - if (hasVisibleFiles && !providesAnything) - conflicts.m_CurrentConflictState = CONFLICT_REDUNDANT; - else if (!conflicts.m_OverwriteList.empty() && - !conflicts.m_OverwrittenList.empty()) - conflicts.m_CurrentConflictState = CONFLICT_MIXED; - else if (!conflicts.m_OverwriteList.empty()) - conflicts.m_CurrentConflictState = CONFLICT_OVERWRITE; - else if (!conflicts.m_OverwrittenList.empty()) - conflicts.m_CurrentConflictState = CONFLICT_OVERWRITTEN; - - if (!conflicts.m_ArchiveOverwriteList.empty() && - !conflicts.m_ArchiveOverwrittenList.empty()) - conflicts.m_ArchiveConflictState = CONFLICT_MIXED; - else if (!conflicts.m_ArchiveOverwriteList.empty()) - conflicts.m_ArchiveConflictState = CONFLICT_OVERWRITE; - else if (!conflicts.m_ArchiveOverwrittenList.empty()) - conflicts.m_ArchiveConflictState = CONFLICT_OVERWRITTEN; - - if (!conflicts.m_ArchiveLooseOverwrittenList.empty() && - !conflicts.m_ArchiveLooseOverwriteList.empty()) - conflicts.m_ArchiveConflictLooseState = CONFLICT_MIXED; - else if (!conflicts.m_ArchiveLooseOverwrittenList.empty()) - conflicts.m_ArchiveConflictLooseState = CONFLICT_OVERWRITTEN; - else if (!conflicts.m_ArchiveLooseOverwriteList.empty()) - conflicts.m_ArchiveConflictLooseState = CONFLICT_OVERWRITE; - - conflicts.m_HasHiddenFiles = hasHiddenFiles; - } - } - - return conflicts; -} - -ModInfoWithConflictInfo::EConflictType ModInfoWithConflictInfo::isConflicted() const -{ - return m_Conflicts.value().m_CurrentConflictState; -} - -ModInfoWithConflictInfo::EConflictType -ModInfoWithConflictInfo::isArchiveConflicted() const -{ - return m_Conflicts.value().m_ArchiveConflictState; -} - -ModInfoWithConflictInfo::EConflictType -ModInfoWithConflictInfo::isLooseArchiveConflicted() const -{ - return m_Conflicts.value().m_ArchiveConflictLooseState; -} - -bool ModInfoWithConflictInfo::isRedundant() const -{ - std::wstring const name = ToWString(this->name()); - if (m_Core.directoryStructure()->originExists(name)) { - FilesOrigin const& origin = m_Core.directoryStructure()->getOriginByName(name); - std::vector files = origin.getFiles(); - bool ignore = false; - for (auto iter = files.begin(); iter != files.end(); ++iter) { - if ((*iter)->getOrigin(ignore) == origin.getID()) { - return false; - } - } - return true; - } else { - return false; - } -} - -bool ModInfoWithConflictInfo::hasHiddenFiles() const -{ - return m_Conflicts.value().m_HasHiddenFiles; -} - -void ModInfoWithConflictInfo::diskContentModified() -{ - m_FileTree.invalidate(); - m_Valid.invalidate(); - m_Contents.invalidate(); -} - -void ModInfoWithConflictInfo::prefetch() -{ - // Populating the tree to 1-depth (IFileTree is lazy, so size() forces the - // tree to populate the first level): - fileTree()->size(); -} - -bool ModInfoWithConflictInfo::doIsValid() const -{ - auto mdc = m_Core.gameFeatures().gameFeature(); - - if (mdc) { - auto qdirfiletree = fileTree(); - if (mdc->dataLooksValid(qdirfiletree) == ModDataChecker::CheckReturn::VALID) { - return true; - } - } - - // If VFS Root Builder is enabled, a mod with only a Root/ folder is still - // valid (e.g. SKSE, ENB — files get deployed to the game directory at - // launch). - if (const auto* s = Settings::maybeInstance()) { - const QSettings instanceIni(s->filename(), QSettings::IniFormat); - if (instanceIni.value("fluorine/vfs_root_builder", true).toBool()) { - const auto modPath = absolutePath(); - std::error_code ec; - for (const auto& entry : - fs::directory_iterator(modPath.toStdString(), ec)) { - if (entry.is_directory()) { - auto name = entry.path().filename().string(); - std::transform(name.begin(), name.end(), name.begin(), ::tolower); - if (name == "root") { - return true; - } - } - } - } - } - - return mdc == nullptr; -} - -std::shared_ptr ModInfoWithConflictInfo::fileTree() const -{ - return m_FileTree.value(); -} - -bool ModInfoWithConflictInfo::isValid() const -{ - return m_Valid.value(); -} - -const std::set& ModInfoWithConflictInfo::getContents() const -{ - return m_Contents.value(); -} - -bool ModInfoWithConflictInfo::hasContent(int content) const -{ - auto& contents = m_Contents.value(); - return std::find(std::begin(contents), std::end(contents), content) != - std::end(contents); -} +#include "modinfowithconflictinfo.h" +#include "shared/directoryentry.h" +#include "shared/fileentry.h" +#include "shared/filesorigin.h" +#include "utility.h" +#include + +#include "iplugingame.h" +#include "moddatachecker.h" +#include "organizercore.h" +#include "qdirfiletree.h" +#include "settings.h" + +using namespace MOBase; +using namespace MOShared; +namespace fs = std::filesystem; + +ModInfoWithConflictInfo::ModInfoWithConflictInfo(OrganizerCore& core) + : ModInfo(core), m_FileTree([this]() { + return QDirFileTree::makeTree(absolutePath()); + }), + m_Valid([this]() { + return doIsValid(); + }), + m_Contents([this]() { + return doGetContents(); + }), + m_Conflicts([this]() { + return doConflictCheck(); + }) +{} + +void ModInfoWithConflictInfo::clearCaches() +{ + m_Conflicts.invalidate(); +} + +std::vector ModInfoWithConflictInfo::getFlags() const +{ + std::vector result = std::vector(); + if (hasHiddenFiles()) { + result.push_back(ModInfo::FLAG_HIDDEN_FILES); + } + return result; +} + +std::vector ModInfoWithConflictInfo::getConflictFlags() const +{ + std::vector result; + switch (isConflicted()) { + case CONFLICT_MIXED: { + result.push_back(ModInfo::FLAG_CONFLICT_MIXED); + } break; + case CONFLICT_OVERWRITE: { + result.push_back(ModInfo::FLAG_CONFLICT_OVERWRITE); + } break; + case CONFLICT_OVERWRITTEN: { + result.push_back(ModInfo::FLAG_CONFLICT_OVERWRITTEN); + } break; + case CONFLICT_REDUNDANT: { + result.push_back(ModInfo::FLAG_CONFLICT_REDUNDANT); + } break; + default: { /* NOP */ + } + } + switch (isLooseArchiveConflicted()) { + case CONFLICT_MIXED: { + result.push_back(ModInfo::FLAG_ARCHIVE_LOOSE_CONFLICT_OVERWRITE); + result.push_back(ModInfo::FLAG_ARCHIVE_LOOSE_CONFLICT_OVERWRITTEN); + } break; + case CONFLICT_OVERWRITE: { + result.push_back(ModInfo::FLAG_ARCHIVE_LOOSE_CONFLICT_OVERWRITE); + } break; + case CONFLICT_OVERWRITTEN: { + result.push_back(ModInfo::FLAG_ARCHIVE_LOOSE_CONFLICT_OVERWRITTEN); + } break; + default: { /* NOP */ + } + } + switch (isArchiveConflicted()) { + case CONFLICT_MIXED: { + result.push_back(ModInfo::FLAG_ARCHIVE_CONFLICT_MIXED); + } break; + case CONFLICT_OVERWRITE: { + result.push_back(ModInfo::FLAG_ARCHIVE_CONFLICT_OVERWRITE); + } break; + case CONFLICT_OVERWRITTEN: { + result.push_back(ModInfo::FLAG_ARCHIVE_CONFLICT_OVERWRITTEN); + } break; + default: { /* NOP */ + } + } + return result; +} + +ModInfoWithConflictInfo::Conflicts ModInfoWithConflictInfo::doConflictCheck() const +{ + Conflicts conflicts; + + bool providesAnything = false; + bool hasHiddenFiles = false; + bool hasVisibleFiles = false; + + std::vector dataIDs; + if (m_Core.directoryStructure()->originExists(L"data")) { + dataIDs.push_back(m_Core.directoryStructure()->getOriginByName(L"data").getID()); + } + for (const auto& origin : m_Core.managedGame()->secondaryDataDirectories().keys()) { + if (m_Core.directoryStructure()->originExists(origin.toStdWString())) { + dataIDs.push_back( + m_Core.directoryStructure()->getOriginByName(origin.toStdWString()).getID()); + } + } + + std::wstring const name = ToWString(this->name()); + + if (m_Core.directoryStructure()->originExists(name)) { + FilesOrigin const& origin = m_Core.directoryStructure()->getOriginByName(name); + std::vector const files = origin.getFiles(); + std::set checkedDirs; + + // for all files in this origin + for (const FileEntryPtr& file : files) { + if (QString::fromStdWString(file->getName()) + .endsWith(ModInfo::s_HiddenExt, Qt::CaseInsensitive)) { + hasHiddenFiles = true; + // skip hidden file conflicts + continue; + } else { + const DirectoryEntry* parent = file->getParent(); + auto hidden = false; + + // iterate on all parent directory entries to check for .mohiddden + while (parent != nullptr) { + auto insertResult = checkedDirs.insert(parent); + + if (!insertResult.second) { + // if already present break as we can assume to have checked the parents as + // well + break; + } else { + if (QString::fromStdWString(parent->getName()) + .endsWith(ModInfo::s_HiddenExt, Qt::CaseInsensitive)) { + hasHiddenFiles = hidden = true; + break; + } + parent = parent->getParent(); + } + } + if (hidden) { + // skip hidden file conflicts + continue; + } + } + + hasVisibleFiles = true; + auto alternatives = file->getAlternatives(); + if ((alternatives.empty()) || + std::find(dataIDs.begin(), dataIDs.end(), alternatives.back().originID()) != + dataIDs.end()) { + // no alternatives -> no conflict + providesAnything = true; + } else { + // Get the archive data for the current mod + DataArchiveOrigin archiveData; + if (file->getOrigin() == origin.getID()) + archiveData = file->getArchive(); + else { + for (const auto& alt : alternatives) { + if (alt.originID() == origin.getID()) { + archiveData = alt.archive(); + break; + } + } + } + + // If this is not the origin then determine the correct overwrite + if (file->getOrigin() != origin.getID()) { + FilesOrigin const& altOrigin = + m_Core.directoryStructure()->getOriginByID(file->getOrigin()); + unsigned int const altIndex = ModInfo::getIndex(ToQString(altOrigin.getName())); + if (!file->isFromArchive()) { + if (!archiveData.isValid()) + conflicts.m_OverwrittenList.insert(altIndex); + else + conflicts.m_ArchiveLooseOverwrittenList.insert(altIndex); + } else { + conflicts.m_ArchiveOverwrittenList.insert(altIndex); + } + } else { + providesAnything = true; + } + + // Sort out the alternatives + for (const auto& altInfo : alternatives) { + if (!(std::find(dataIDs.begin(), dataIDs.end(), + alternatives.back().originID()) != dataIDs.end()) && + (altInfo.originID() != origin.getID())) { + FilesOrigin const& altOrigin = + m_Core.directoryStructure()->getOriginByID(altInfo.originID()); + QString const altOriginName = ToQString(altOrigin.getName()); + unsigned int const altIndex = ModInfo::getIndex(altOriginName); + if (!altInfo.isFromArchive()) { + if (!archiveData.isValid()) { + if (origin.getPriority() > altOrigin.getPriority()) { + conflicts.m_OverwriteList.insert(altIndex); + } else { + conflicts.m_OverwrittenList.insert(altIndex); + } + } else { + conflicts.m_ArchiveLooseOverwrittenList.insert(altIndex); + } + } else { + if (!archiveData.isValid()) { + conflicts.m_ArchiveLooseOverwriteList.insert(altIndex); + } else { + if (archiveData.order() > altInfo.archive().order()) { + conflicts.m_ArchiveOverwriteList.insert(altIndex); + } else if (archiveData.order() < altInfo.archive().order()) { + conflicts.m_ArchiveOverwrittenList.insert(altIndex); + } + } + } + } + } + } + } + + if (!files.empty()) { + if (hasVisibleFiles && !providesAnything) + conflicts.m_CurrentConflictState = CONFLICT_REDUNDANT; + else if (!conflicts.m_OverwriteList.empty() && + !conflicts.m_OverwrittenList.empty()) + conflicts.m_CurrentConflictState = CONFLICT_MIXED; + else if (!conflicts.m_OverwriteList.empty()) + conflicts.m_CurrentConflictState = CONFLICT_OVERWRITE; + else if (!conflicts.m_OverwrittenList.empty()) + conflicts.m_CurrentConflictState = CONFLICT_OVERWRITTEN; + + if (!conflicts.m_ArchiveOverwriteList.empty() && + !conflicts.m_ArchiveOverwrittenList.empty()) + conflicts.m_ArchiveConflictState = CONFLICT_MIXED; + else if (!conflicts.m_ArchiveOverwriteList.empty()) + conflicts.m_ArchiveConflictState = CONFLICT_OVERWRITE; + else if (!conflicts.m_ArchiveOverwrittenList.empty()) + conflicts.m_ArchiveConflictState = CONFLICT_OVERWRITTEN; + + if (!conflicts.m_ArchiveLooseOverwrittenList.empty() && + !conflicts.m_ArchiveLooseOverwriteList.empty()) + conflicts.m_ArchiveConflictLooseState = CONFLICT_MIXED; + else if (!conflicts.m_ArchiveLooseOverwrittenList.empty()) + conflicts.m_ArchiveConflictLooseState = CONFLICT_OVERWRITTEN; + else if (!conflicts.m_ArchiveLooseOverwriteList.empty()) + conflicts.m_ArchiveConflictLooseState = CONFLICT_OVERWRITE; + + conflicts.m_HasHiddenFiles = hasHiddenFiles; + } + } + + return conflicts; +} + +ModInfoWithConflictInfo::EConflictType ModInfoWithConflictInfo::isConflicted() const +{ + return m_Conflicts.value().m_CurrentConflictState; +} + +ModInfoWithConflictInfo::EConflictType +ModInfoWithConflictInfo::isArchiveConflicted() const +{ + return m_Conflicts.value().m_ArchiveConflictState; +} + +ModInfoWithConflictInfo::EConflictType +ModInfoWithConflictInfo::isLooseArchiveConflicted() const +{ + return m_Conflicts.value().m_ArchiveConflictLooseState; +} + +bool ModInfoWithConflictInfo::isRedundant() const +{ + std::wstring const name = ToWString(this->name()); + if (m_Core.directoryStructure()->originExists(name)) { + FilesOrigin const& origin = m_Core.directoryStructure()->getOriginByName(name); + std::vector files = origin.getFiles(); + bool ignore = false; + for (auto iter = files.begin(); iter != files.end(); ++iter) { + if ((*iter)->getOrigin(ignore) == origin.getID()) { + return false; + } + } + return true; + } else { + return false; + } +} + +bool ModInfoWithConflictInfo::hasHiddenFiles() const +{ + return m_Conflicts.value().m_HasHiddenFiles; +} + +void ModInfoWithConflictInfo::diskContentModified() +{ + m_FileTree.invalidate(); + m_Valid.invalidate(); + m_Contents.invalidate(); +} + +void ModInfoWithConflictInfo::prefetch() +{ + // Populating the tree to 1-depth (IFileTree is lazy, so size() forces the + // tree to populate the first level): + fileTree()->size(); +} + +bool ModInfoWithConflictInfo::doIsValid() const +{ + auto mdc = m_Core.gameFeatures().gameFeature(); + + if (mdc) { + auto qdirfiletree = fileTree(); + if (mdc->dataLooksValid(qdirfiletree) == ModDataChecker::CheckReturn::VALID) { + return true; + } + } + + // If VFS Root Builder is enabled, a mod with only a Root/ folder is still + // valid (e.g. SKSE, ENB — files get deployed to the game directory at + // launch). + if (const auto* s = Settings::maybeInstance()) { + const QSettings instanceIni(s->filename(), QSettings::IniFormat); + if (instanceIni.value("fluorine/vfs_root_builder", true).toBool()) { + const auto modPath = absolutePath(); + std::error_code ec; + for (const auto& entry : + fs::directory_iterator(modPath.toStdString(), ec)) { + if (entry.is_directory()) { + auto name = entry.path().filename().string(); + std::transform(name.begin(), name.end(), name.begin(), ::tolower); + if (name == "root") { + return true; + } + } + } + } + } + + return mdc == nullptr; +} + +std::shared_ptr ModInfoWithConflictInfo::fileTree() const +{ + return m_FileTree.value(); +} + +bool ModInfoWithConflictInfo::isValid() const +{ + return m_Valid.value(); +} + +const std::set& ModInfoWithConflictInfo::getContents() const +{ + return m_Contents.value(); +} + +bool ModInfoWithConflictInfo::hasContent(int content) const +{ + auto& contents = m_Contents.value(); + return std::find(std::begin(contents), std::end(contents), content) != + std::end(contents); +} diff --git a/src/src/modlist.cpp b/src/src/modlist.cpp index 0fdf6c8..de2f6b3 100644 --- a/src/src/modlist.cpp +++ b/src/src/modlist.cpp @@ -1,1517 +1,1518 @@ -/* -Copyright (C) 2012 Sebastian Herbord. All rights reserved. - -This file is part of Mod Organizer. - -Mod Organizer is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -Mod Organizer is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with Mod Organizer. If not, see . -*/ - -#include "modlist.h" - -#include "messagedialog.h" -#include "modinforegular.h" -#include "modlistdropinfo.h" -#include "modlistsortproxy.h" -#include "organizercore.h" -#include "pluginlist.h" -#include "qtgroupingproxy.h" -#include "settings.h" -#include "shared/directoryentry.h" -#include "shared/fileentry.h" -#include "shared/filesorigin.h" -#include "viewmarkingscrollbar.h" -#include "widgetutility.h" - -#include "filesystemutilities.h" -#include "shared/appconfig.h" -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include - -using namespace MOBase; - -ModList::ModList(PluginContainer* pluginContainer, OrganizerCore* organizer) - : QAbstractItemModel(organizer), m_Organizer(organizer), m_Profile(nullptr), - m_NexusInterface(nullptr), m_Modified(false), m_InNotifyChange(false), - m_FontMetrics(QFont()), m_PluginContainer(pluginContainer) -{ - m_LastCheck.start(); -} - -ModList::~ModList() -{ - m_ModInstalled.disconnect_all_slots(); - m_ModRemoved.disconnect_all_slots(); - m_ModStateChanged.disconnect_all_slots(); - m_ModMoved.disconnect_all_slots(); -} - -void ModList::setProfile(Profile* profile) -{ - m_Profile = profile; -} - -int ModList::rowCount(const QModelIndex& parent) const -{ - if (!parent.isValid()) { - return ModInfo::getNumMods(); - } else { - return 0; - } -} - -bool ModList::hasChildren(const QModelIndex& parent) const -{ - if (!parent.isValid()) { - return ModInfo::getNumMods() > 0; - } else { - return false; - } -} - -int ModList::columnCount(const QModelIndex&) const -{ - return COL_LASTCOLUMN + 1; -} - -QString ModList::getDisplayName(ModInfo::Ptr info) -{ - QString name = info->name(); - if (info->isSeparator()) { - name = name.replace("_separator", ""); - } - return name; -} - -QString ModList::makeInternalName(ModInfo::Ptr info, QString name) -{ - if (info->isSeparator()) { - name += "_separator"; - } - return name; -} - -QString ModList::getFlagText(ModInfo::EFlag flag, ModInfo::Ptr modInfo) -{ - switch (flag) { - case ModInfo::FLAG_BACKUP: - return tr("Backup"); - case ModInfo::FLAG_SEPARATOR: - return tr("Separator"); - case ModInfo::FLAG_INVALID: - return tr("No valid game data"); - case ModInfo::FLAG_NOTENDORSED: - return tr("Not endorsed yet"); - case ModInfo::FLAG_NOTES: { - QStringList output; - if (!modInfo->comments().isEmpty()) - output << QString("%1").arg(modInfo->comments()); - if (!modInfo->notes().isEmpty()) - output << QString("%1").arg(modInfo->notes()); - return output.join(""); - } - case ModInfo::FLAG_ALTERNATE_GAME: - return tr("This mod is for a different
    game, " - "make sure it's compatible or it could cause crashes."); - case ModInfo::FLAG_TRACKED: - return tr("Mod is being tracked on the website"); - case ModInfo::FLAG_HIDDEN_FILES: - return tr("Contains hidden files"); - default: - return ""; - } -} - -QString ModList::getConflictFlagText(ModInfo::EConflictFlag flag, - ModInfo::Ptr modInfo) -{ - switch (flag) { - case ModInfo::FLAG_CONFLICT_OVERWRITE: - return tr("Overwrites loose files"); - case ModInfo::FLAG_CONFLICT_OVERWRITTEN: - return tr("Overwritten loose files"); - case ModInfo::FLAG_CONFLICT_MIXED: - return tr("Loose files Overwrites & Overwritten"); - case ModInfo::FLAG_CONFLICT_REDUNDANT: - return tr("Redundant"); - case ModInfo::FLAG_ARCHIVE_LOOSE_CONFLICT_OVERWRITE: - return tr("Overwrites an archive with loose files"); - case ModInfo::FLAG_ARCHIVE_LOOSE_CONFLICT_OVERWRITTEN: - return tr("Archive is overwritten by loose files"); - case ModInfo::FLAG_ARCHIVE_CONFLICT_OVERWRITE: - return tr("Overwrites another archive file"); - case ModInfo::FLAG_ARCHIVE_CONFLICT_OVERWRITTEN: - return tr("Overwritten by another archive file"); - case ModInfo::FLAG_ARCHIVE_CONFLICT_MIXED: - return tr("Archive files overwrites & overwritten"); - default: - return ""; - } -} - -QVariant ModList::data(const QModelIndex& modelIndex, int role) const -{ - if (m_Profile == nullptr) - return {}; - if (!modelIndex.isValid()) - return {}; - unsigned int const modIndex = modelIndex.row(); - int const column = modelIndex.column(); - - ModInfo::Ptr const modInfo = ModInfo::getByIndex(modIndex); - if ((role == Qt::DisplayRole) || (role == Qt::EditRole)) { - if ((column == COL_FLAGS) || (column == COL_CONTENT) || - (column == COL_CONFLICTFLAGS)) { - return {}; - } else if (column == COL_NAME) { - return getDisplayName(modInfo); - } else if (column == COL_VERSION) { - VersionInfo const verInfo = modInfo->version(); - QString version = verInfo.displayString(); - if (role != Qt::EditRole) { - if (version.isEmpty() && modInfo->canBeUpdated()) { - version = "?"; - } - } - return version; - } else if (column == COL_PRIORITY) { - if (modInfo->hasAutomaticPriority()) { - return {}; // hide priority for mods where it's fixed - } else { - return QString::number(m_Profile->getModPriority(modIndex)); - } - } else if (column == COL_MODID) { - int const modID = modInfo->nexusId(); - if (modID > 0) { - return modID; - } else { - return {}; - } - } else if (column == COL_GAME) { - if (m_PluginContainer != nullptr) { - for (auto game : m_PluginContainer->plugins()) { - if (game->gameShortName().compare(modInfo->gameName(), Qt::CaseInsensitive) == - 0) - return game->gameName(); - } - } - return modInfo->gameName(); - } else if (column == COL_CATEGORY) { - if (modInfo->hasFlag(ModInfo::FLAG_FOREIGN)) { - return tr("Non-MO"); - } else { - int category = modInfo->primaryCategory(); - if (category != -1) { - CategoryFactory const& categoryFactory = CategoryFactory::instance(); - if (categoryFactory.categoryExists(category)) { - try { - int const categoryIdx = categoryFactory.getCategoryIndex(category); - return categoryFactory.getCategoryName(categoryIdx); - } catch (const std::exception& e) { - log::error("failed to retrieve category name: {}", e.what()); - return QString(); - } - } else { - log::warn("category {} doesn't exist (may have been removed)", category); - modInfo->setCategory(category, false); - return QString(); - } - } else { - return {}; - } - } - } else if (column == COL_AUTHOR) { - return modInfo->author(); - } else if (column == COL_UPLOADER) { - return modInfo->uploader(); - } else if (column == COL_INSTALLTIME) { - // display installation time for mods that can be updated - if (modInfo->creationTime().isValid()) { - return modInfo->creationTime(); - } else { - return {}; - } - } else if (column == COL_NOTES) { - return modInfo->comments(); - } else { - return tr("invalid"); - } - } else if ((role == Qt::CheckStateRole) && (column == 0)) { - if (modInfo->canBeEnabled()) { - return m_Profile->modEnabled(modIndex) ? Qt::Checked : Qt::Unchecked; - } else { - return {}; - } - } else if (role == Qt::TextAlignmentRole) { - if (column == COL_NAME) { - if (modInfo->getHighlight() & ModInfo::HIGHLIGHT_CENTER) { - return {Qt::AlignCenter | Qt::AlignVCenter}; - } else { - return {Qt::AlignLeft | Qt::AlignVCenter}; - } - } else if (column == COL_VERSION) { - return {Qt::AlignRight | Qt::AlignVCenter}; - } else if (column == COL_NOTES) { - return {Qt::AlignLeft | Qt::AlignVCenter}; - } else { - return {Qt::AlignCenter | Qt::AlignVCenter}; - } - } else if (role == GroupingRole) { - if (column == COL_CATEGORY) { - QVariantList categoryNames; - std::set const categories = modInfo->getCategories(); - CategoryFactory const& categoryFactory = CategoryFactory::instance(); - for (auto iter = categories.begin(); iter != categories.end(); ++iter) { - categoryNames.append( - categoryFactory.getCategoryName(categoryFactory.getCategoryIndex(*iter))); - } - if (categoryNames.count() != 0) { - return categoryNames; - } else { - return {}; - } - } else { - return modInfo->nexusId(); - } - } else if (role == IndexRole) { - return modIndex; - } else if (role == AggrRole) { - switch (column) { - case COL_MODID: - return QtGroupingProxy::AGGR_FIRST; - case COL_VERSION: - return QtGroupingProxy::AGGR_MAX; - case COL_CATEGORY: - return QtGroupingProxy::AGGR_FIRST; - case COL_PRIORITY: - return QtGroupingProxy::AGGR_MIN; - default: - return QtGroupingProxy::AGGR_NONE; - } - } else if (role == GameNameRole) { - return modInfo->gameName(); - } else if (role == PriorityRole) { - return m_Profile->getModPriority(modIndex); - } else if (role == Qt::FontRole) { - QFont result; - if (column == COL_NAME) { - if (modInfo->isSeparator()) { - result.setItalic(true); - result.setBold(true); - } else if (modInfo->getHighlight() & ModInfo::HIGHLIGHT_INVALID) { - result.setItalic(true); - } - } else if (column == COL_CATEGORY && modInfo->isForeign()) { - result.setItalic(true); - } else if (column == COL_VERSION) { - if (modInfo->updateAvailable() || modInfo->downgradeAvailable()) { - result.setWeight(QFont::Bold); - } - if (modInfo->canBeUpdated()) { - result.setItalic(true); - } - } - return result; - } else if (role == Qt::DecorationRole) { - if (column == COL_VERSION) { - if (modInfo->updateAvailable()) { - return QIcon(":/MO/gui/update_available"); - } else if (modInfo->downgradeAvailable()) { - return QIcon(":/MO/gui/warning"); - } else if (modInfo->version().scheme() == VersionInfo::SCHEME_DATE) { - return QIcon(":/MO/gui/version_date"); - } - } - return {}; - } else if (role == Qt::ForegroundRole) { - if (column == COL_NAME) { - int const highlight = modInfo->getHighlight(); - if (highlight & ModInfo::HIGHLIGHT_IMPORTANT) { - return QBrush(Qt::darkRed); - } else if (highlight & ModInfo::HIGHLIGHT_INVALID) { - return QBrush(Qt::darkGray); - } - } else if (column == COL_VERSION) { - if (!modInfo->newestVersion().isValid()) { - return {}; - } else if (modInfo->updateAvailable() || modInfo->downgradeAvailable()) { - return QBrush(Qt::red); - } else { - return QBrush(Qt::darkGreen); - } - } - return {}; - } else if (role == Qt::BackgroundRole || role == ScrollMarkRole) { - if (column == COL_NOTES && modInfo->color().isValid()) { - return modInfo->color(); - } else if (modInfo->isSeparator() && modInfo->color().isValid() && - (role != ScrollMarkRole || - Settings::instance().colors().colorSeparatorScrollbar())) { - return modInfo->color(); - } else { - return {}; - } - } else if (role == Qt::ToolTipRole) { - if (column == COL_FLAGS) { - QString result; - - for (ModInfo::EFlag const flag : modInfo->getFlags()) { - if (result.length() != 0) - result += "
    "; - result += getFlagText(flag, modInfo); - } - - return result; - } else if (column == COL_CONFLICTFLAGS) { - QString result; - - for (ModInfo::EConflictFlag const flag : modInfo->getConflictFlags()) { - if (result.length() != 0) - result += "
    "; - result += getConflictFlagText(flag, modInfo); - } - - return result; - } else if (column == COL_NAME) { - try { - return modInfo->getDescription(); - } catch (const std::exception& e) { - log::error("invalid mod description: {}", e.what()); - return QString(); - } - } else if (column == COL_VERSION) { - QString text = tr(R"(installed version: "%1", newest version: "%2")") - .arg(modInfo->version().displayString(3)) - .arg(modInfo->newestVersion().displayString(3)); - if (modInfo->downgradeAvailable()) { - text += - "
    " + tr("The newest version on Nexus seems to be older than the one " - "you have installed. This could either mean the version you " - "have has been withdrawn " - "(i.e. due to a bug) or the author uses a non-standard " - "versioning scheme and that newest version is actually newer. " - "Either way you may want to \"upgrade\"."); - } - if (modInfo->getNexusFileStatus() == NexusInterface::FileStatus::OLD_VERSION) { - text += "
    " + tr("This file has been marked as \"Old\". There is most " - "likely an updated version of this file available."); - } else if (modInfo->getNexusFileStatus() == NexusInterface::FileStatus::REMOVED || - modInfo->getNexusFileStatus() == - NexusInterface::FileStatus::ARCHIVED || - modInfo->getNexusFileStatus() == - NexusInterface::FileStatus::ARCHIVED_HIDDEN) { - text += - "
    " + tr("This file has been marked as \"Deleted\"! You may want to " - "check for an update or remove the nexus ID from this mod!"); - } - if (modInfo->nexusId() > 0) { - if (!modInfo->canBeUpdated()) { - qint64 const remains = - QDateTime::currentDateTimeUtc().secsTo(modInfo->getExpires()); - qint64 const minutes = remains / 60; - qint64 const seconds = remains % 60; - QString const remainsStr( - tr("%1 minute(s) and %2 second(s)").arg(minutes).arg(seconds)); - text += - "
    " + tr("This mod will be available to check in %2.").arg(remainsStr); - } - } - return text; - } else if (column == COL_CATEGORY) { - const std::set& categories = modInfo->getCategories(); - if (categories.empty()) { - return QString(); - } - std::wostringstream categoryString; - categoryString << ToWString(tr("Categories:
    ")); - CategoryFactory const& categoryFactory = CategoryFactory::instance(); - for (std::set::const_iterator catIter = categories.begin(); - catIter != categories.end(); ++catIter) { - if (catIter != categories.begin()) { - categoryString << " , "; - } - try { - categoryString << "" - << ToWString(categoryFactory.getCategoryName( - categoryFactory.getCategoryIndex(*catIter))) - << ""; - } catch (const std::exception& e) { - log::error("failed to generate tooltip: {}", e.what()); - return QString(); - } - } - - return ToQString(categoryString.str()); - } else if (column == COL_NOTES) { - return getFlagText(ModInfo::FLAG_NOTES, modInfo); - } else { - return {}; - } - } else { - return {}; - } -} - -bool ModList::renameMod(int index, const QString& newName) -{ - QString nameFixed = newName; - if (!fixDirectoryName(nameFixed) || nameFixed.isEmpty()) { - MessageDialog::showMessage(tr("Invalid name"), nullptr); - return false; - } - - if (ModList::allMods().contains(nameFixed, Qt::CaseInsensitive) && - nameFixed.toLower() != ModInfo::getByIndex(index)->name().toLower()) { - MessageDialog::showMessage(tr("Name is already in use by another mod"), nullptr); - return false; - } - - ModInfo::Ptr const modInfo = ModInfo::getByIndex(index); - QString const oldName = modInfo->name(); - if (nameFixed != oldName) { - // before we rename, ensure there is no scheduled asynchronous to rewrite - m_Profile->cancelModlistWrite(); - - if (modInfo->setName(nameFixed)) { - // Notice there is a good chance that setName() updated the modinfo indexes - // the modRenamed() call will refresh the indexes in the current profile - // and update the modlists in all profiles - emit modRenamed(oldName, nameFixed); - } - - // invalidate the currently displayed state of this list - notifyChange(-1); - } - return true; -} - -bool ModList::setData(const QModelIndex& index, const QVariant& value, int role) -{ - if (m_Profile == nullptr) - return false; - - if (static_cast(index.row()) >= ModInfo::getNumMods()) { - return false; - } - - int const modID = index.row(); - - ModInfo::Ptr const info = ModInfo::getByIndex(modID); - IModList::ModStates const oldState = state(modID); - - bool result = false; - - emit aboutToChangeData(); - - if (role == Qt::CheckStateRole) { - bool const enabled = value.toInt() == Qt::Checked; - if (m_Profile->modEnabled(modID) != enabled) { - m_Profile->setModEnabled(modID, enabled); - m_Modified = true; - m_LastCheck.restart(); - emit tutorialModlistUpdate(); - } - result = true; - emit dataChanged(index, index); - } else if (role == Qt::EditRole) { - switch (index.column()) { - case COL_NAME: { - result = renameMod(modID, makeInternalName(info, value.toString())); - } break; - case COL_PRIORITY: { - bool ok = false; - int const newPriority = value.toInt(&ok); - if (ok) { - changeModPriority(modID, newPriority); - result = true; - } else { - result = false; - } - } break; - case COL_MODID: { - bool ok = false; - int const newID = value.toInt(&ok); - if (ok) { - info->setNexusID(newID); - emit tutorialModlistUpdate(); - result = true; - } else { - result = false; - } - } break; - case COL_VERSION: { - VersionInfo::VersionScheme const scheme = info->version().scheme(); - VersionInfo const version(value.toString(), scheme, true); - if (version.isValid()) { - info->setVersion(version); - result = true; - } else { - result = false; - } - } break; - case COL_NOTES: { - info->setComments(value.toString()); - result = true; - } break; - default: { - log::warn("edit on column \"{}\" not supported", - getColumnName(index.column()).toUtf8().constData()); - result = false; - } break; - } - if (result) { - emit dataChanged(index, index); - } - } - - emit postDataChanged(); - return result; -} - -QVariant ModList::headerData(int section, Qt::Orientation orientation, int role) const -{ - if (orientation == Qt::Horizontal) { - if (role == Qt::DisplayRole) { - return getColumnName(section); - } else if (role == Qt::ToolTipRole) { - return getColumnToolTip(section); - } else if (role == Qt::TextAlignmentRole) { - return {Qt::AlignCenter}; - } else if (role == MOBase::EnabledColumnRole) { - if (section == COL_CONTENT) { - return !m_Organizer->modDataContents().empty(); - } else { - return true; - } - } - } - return QAbstractItemModel::headerData(section, orientation, role); -} - -Qt::ItemFlags ModList::flags(const QModelIndex& modelIndex) const -{ - Qt::ItemFlags result = QAbstractItemModel::flags(modelIndex); - if (modelIndex.internalId() < 0) { - return Qt::ItemIsEnabled; - } - if (modelIndex.isValid()) { - ModInfo::Ptr const modInfo = ModInfo::getByIndex(modelIndex.row()); - if (!modInfo->hasAutomaticPriority()) { - result |= Qt::ItemIsDragEnabled; - result |= Qt::ItemIsUserCheckable; - if ((modelIndex.column() == COL_PRIORITY) || - (modelIndex.column() == COL_VERSION) || (modelIndex.column() == COL_MODID)) { - result |= Qt::ItemIsEditable; - } - if ((modelIndex.column() == COL_NAME || modelIndex.column() == COL_NOTES) && - !modInfo->isForeign()) { - result |= Qt::ItemIsEditable; - } - } - if (modInfo->isSeparator() || m_DropOnMod) { - result |= Qt::ItemIsDropEnabled; - } - } else if (!m_DropOnMod) { - result |= Qt::ItemIsDropEnabled; - } - - return result; -} - -QStringList ModList::mimeTypes() const -{ - QStringList result = QAbstractItemModel::mimeTypes(); - result.append("text/uri-list"); - return result; -} - -QMimeData* ModList::mimeData(const QModelIndexList& indexes) const -{ - QMimeData* result = QAbstractItemModel::mimeData(indexes); - result->setData("text/plain", ModListDropInfo::ModText); - return result; -} - -void ModList::changeModPriority(std::vector sourceIndices, int newPriority) -{ - if (m_Profile == nullptr) - return; - - sourceIndices.erase( - std::remove_if(sourceIndices.begin(), sourceIndices.end(), [](int index) { - return index < 0 || static_cast(index) >= ModInfo::getNumMods(); - }), - sourceIndices.end()); - - if (sourceIndices.empty()) { - return; - } - - const bool anyNeedsMove = std::any_of( - sourceIndices.begin(), sourceIndices.end(), - [this, newPriority](int index) { return m_Profile->getModPriority(index) != newPriority; }); - if (!anyNeedsMove) { - return; - } - - emit layoutAboutToBeChanged(); - - // sort the moving mods by ascending priorities - std::sort(sourceIndices.begin(), sourceIndices.end(), - [=, this](const int& LHS, const int& RHS) { - return m_Profile->getModPriority(LHS) > m_Profile->getModPriority(RHS); - }); - - // move mods that are decreasing in priority - for (const auto& index : sourceIndices) { - int const oldPriority = m_Profile->getModPriority(index); - if (oldPriority > newPriority) { - if (m_Profile->setModPriority(index, newPriority)) { - m_ModMoved(ModInfo::getByIndex(index)->name(), oldPriority, newPriority); - } - } - } - - // sort the moving mods by descending priorities - std::sort(sourceIndices.begin(), sourceIndices.end(), - [=, this](const int& LHS, const int& RHS) { - return m_Profile->getModPriority(LHS) < m_Profile->getModPriority(RHS); - }); - - // if at least one mod is increasing in priority, the target index is - // that of the row BELOW the dropped location, otherwise it's the one above - for (const auto& index : sourceIndices) { - int const oldPriority = m_Profile->getModPriority(index); - if (oldPriority < newPriority) { - --newPriority; - break; - } - } - - // move mods that are increasing in priority - for (const auto& index : sourceIndices) { - int const oldPriority = m_Profile->getModPriority(index); - if (oldPriority < newPriority) { - if (m_Profile->setModPriority(index, newPriority)) { - m_ModMoved(ModInfo::getByIndex(index)->name(), oldPriority, newPriority); - } - } - } - - emit layoutChanged(); - - QModelIndexList indices; - for (auto& idx : sourceIndices) { - indices.append(index(idx, 0, QModelIndex())); - } - - emit modPrioritiesChanged(indices); -} - -void ModList::changeModPriority(int sourceIndex, int newPriority) -{ - if (m_Profile == nullptr) - return; - emit layoutAboutToBeChanged(); - - m_Profile->setModPriority(sourceIndex, newPriority); - - emit layoutChanged(); - emit modPrioritiesChanged({index(sourceIndex, 0)}); -} - -void ModList::setPluginContainer(PluginContainer* pluginContianer) -{ - m_PluginContainer = pluginContianer; -} - -bool ModList::modInfoAboutToChange(ModInfo::Ptr info) -{ - if (m_ChangeInfo.name.isEmpty()) { - m_ChangeInfo.name = info->name(); - m_ChangeInfo.state = state(info->name()); - return true; - } else { - return false; - } -} - -void ModList::modInfoChanged(ModInfo::Ptr info) -{ - if (info->name() == m_ChangeInfo.name) { - IModList::ModStates newState = state(info->name()); - if (m_ChangeInfo.state != newState) { - m_ModStateChanged({{info->name(), newState}}); - } - - int const row = ModInfo::getIndex(info->name()); - info->diskContentModified(); - emit aboutToChangeData(); - emit dataChanged(index(row, 0), index(row, columnCount())); - emit postDataChanged(); - } else { - log::error("modInfoChanged not called after modInfoAboutToChange"); - } - m_ChangeInfo.name = QString(); -} - -void ModList::disconnectSlots() -{ - m_ModMoved.disconnect_all_slots(); - m_ModStateChanged.disconnect_all_slots(); -} - -int ModList::timeElapsedSinceLastChecked() const -{ - return m_LastCheck.elapsed(); -} - -IModList::ModStates ModList::state(unsigned int modIndex) const -{ - IModList::ModStates result; - if (modIndex != UINT_MAX) { - result |= IModList::STATE_EXISTS; - ModInfo::Ptr const modInfo = ModInfo::getByIndex(modIndex); - if (modInfo->isEmpty()) { - result |= IModList::STATE_EMPTY; - } - if (modInfo->endorsedState() == EndorsedState::ENDORSED_TRUE) { - result |= IModList::STATE_ENDORSED; - } - if (modInfo->isValid()) { - result |= IModList::STATE_VALID; - } - if (modInfo->isRegular()) { - QSharedPointer const modInfoRegular = - modInfo.staticCast(); - if (modInfoRegular->isAlternate() && !modInfoRegular->isConverted()) - result |= IModList::STATE_ALTERNATE; - if (!modInfo->isValid() && modInfoRegular->isValidated()) - result |= IModList::STATE_VALID; - } - if (modInfo->canBeEnabled()) { - if (m_Profile->modEnabled(modIndex)) { - result |= IModList::STATE_ACTIVE; - } - } else { - result |= IModList::STATE_ESSENTIAL; - } - } - return result; -} - -QString ModList::displayName(const QString& internalName) -{ - unsigned int const modIndex = ModInfo::getIndex(internalName); - if (modIndex == UINT_MAX) { - // might be better to throw an exception? - return internalName; - } else { - return ModInfo::getByIndex(modIndex)->name(); - } -} - -QStringList ModList::allMods() -{ - QStringList result; - for (unsigned int i = 0; i < ModInfo::getNumMods(); ++i) { - result.append(ModInfo::getByIndex(i)->internalName()); - } - return result; -} - -QStringList ModList::allModsByProfilePriority(MOBase::IProfile* profile) const -{ - Profile* mo2Profile = profile == nullptr ? m_Organizer->currentProfile().get() - : static_cast(profile); - - QStringList res; - for (auto& [priority, index] : mo2Profile->getAllIndexesByPriority()) { - auto modInfo = ModInfo::getByIndex(index); - if (!modInfo->isBackup() && !modInfo->isOverwrite()) { - res.push_back(modInfo->internalName()); - } - } - return res; -} - -MOBase::IModInterface* ModList::getMod(const QString& name) -{ - unsigned int const index = ModInfo::getIndex(name); - return index == UINT_MAX ? nullptr : ModInfo::getByIndex(index).data(); -} - -bool ModList::removeMod(MOBase::IModInterface* mod) const -{ - bool const result = ModInfo::removeMod(ModInfo::getIndex(mod->name())); - if (result) { - notifyModRemoved(mod->name()); - } - return result; -} - -MOBase::IModInterface* ModList::renameMod(MOBase::IModInterface* mod, - const QString& name) -{ - unsigned int const index = ModInfo::getIndex(mod->name()); - if (index == UINT_MAX) { - if (auto* p = dynamic_cast(mod)) { - p->setName(name); - return p; - } else { - return nullptr; - } - } else { - if (renameMod(index, name)) { - return ModInfo::getByName(name).get(); - } else { - return nullptr; - } - } -} - -IModList::ModStates ModList::state(const QString& name) const -{ - unsigned int const modIndex = ModInfo::getIndex(name); - - return state(modIndex); -} - -bool ModList::setActive(const QString& name, bool active) -{ - unsigned int const modIndex = ModInfo::getIndex(name); - if (modIndex == UINT_MAX) { - log::debug("Trying to {} mod {} which does not exist.", - active ? "enable" : "disable", name); - return false; - } else { - m_Profile->setModEnabled(modIndex, active); - return true; - } -} - -int ModList::setActive(const QStringList& names, bool active) -{ - - // We only add indices for mods that exist (modIndex != UINT_MAX) - // and that can be enabled / disabled. - QList indices; - for (const auto& name : names) { - auto modIndex = ModInfo::getIndex(name); - if (modIndex != UINT_MAX) { - indices.append(modIndex); - } else { - log::debug("Trying to {} mod {} which does not exist.", - active ? "enable" : "disable", name); - } - } - - if (active) { - m_Profile->setModsEnabled(indices, {}); - } else { - m_Profile->setModsEnabled({}, indices); - } - - return indices.size(); -} - -int ModList::priority(const QString& name) const -{ - unsigned int const modIndex = ModInfo::getIndex(name); - if (modIndex == UINT_MAX) { - return -1; - } else { - return m_Profile->getModPriority(modIndex); - } -} - -bool ModList::setPriority(const QString& name, int newPriority) -{ - unsigned int const index = ModInfo::getIndex(name); - if (index == UINT_MAX) { - return false; - } else { - if (m_Profile->setModPriority(index, newPriority)) { - notifyChange(index); - } - return true; - } -} - -boost::signals2::connection -ModList::onModInstalled(const std::function& func) -{ - return m_ModInstalled.connect(func); -} - -boost::signals2::connection -ModList::onModRemoved(const std::function& func) -{ - return m_ModRemoved.connect(func); -} - -boost::signals2::connection ModList::onModStateChanged( - const std::function&)>& func) -{ - return m_ModStateChanged.connect(func); -} - -void ModList::notifyModInstalled(MOBase::IModInterface* mod) const -{ - m_ModInstalled(mod); -} - -void ModList::notifyModRemoved(QString const& modName) const -{ - m_ModRemoved(modName); -} - -void ModList::notifyModStateChanged(QList modIndices) const -{ - QModelIndexList indices; - std::map mods; - for (auto modIndex : modIndices) { - indices.append(index(modIndex, 0)); - ModInfo::Ptr const modInfo = ModInfo::getByIndex(modIndex); - mods.emplace(modInfo->name(), state(modIndex)); - } - - emit modStatesChanged(indices); - m_ModStateChanged(mods); -} - -boost::signals2::connection -ModList::onModMoved(const std::function& func) -{ - return m_ModMoved.connect(func); -} - -int ModList::dropPriority(int row, const QModelIndex& parent) const -{ - if (row == -1) { - row = parent.row(); - } - - if ((row < 0) || (static_cast(row) >= ModInfo::getNumMods())) { - return -1; - } - - int newPriority = 0; - { - if (row < 0 || row >= rowCount()) { - newPriority = Profile::MaximumPriority; - } else { - newPriority = m_Profile->getModPriority(row); - } - } - - return newPriority; -} - -bool ModList::dropLocalFiles(const ModListDropInfo& dropInfo, int row, - const QModelIndex& parent) -{ - if (row == -1) { - row = parent.row(); - } - ModInfo::Ptr const modInfo = ModInfo::getByIndex(row); - QDir const modDir = QDir(modInfo->absolutePath()); - - QStringList sourceList; - QStringList targetList; - QList> relativePathList; - - for (const auto& localUrl : dropInfo.localUrls()) { - QFileInfo const sourceInfo(localUrl.url.toLocalFile()); - if (localUrl.originName.compare("overwrite", Qt::CaseInsensitive) == 0) { - bool needsMove = true; - if (sourceInfo.isDir()) { - for (const auto& dir : m_Organizer->managedGame()->getModMappings().keys()) { - QDir const overDir(m_Organizer->overwritePath()); - if (sourceInfo.canonicalFilePath().compare(overDir.absoluteFilePath(dir), - Qt::CaseInsensitive) == 0) { - needsMove = false; - - QDirIterator dirIter(overDir.absoluteFilePath(dir), - QDir::AllDirs | QDir::Files | QDir::NoDotAndDotDot); - while (dirIter.hasNext()) { - auto entry = dirIter.nextFileInfo(); - QString const sourceFile = entry.canonicalFilePath(); - - QFileInfo const targetInfo(modDir.absoluteFilePath( - overDir.relativeFilePath(entry.absoluteFilePath()))); - sourceList << sourceFile; - targetList << targetInfo.absoluteFilePath(); - relativePathList << QPair(localUrl.relativePath, - localUrl.originName); - } - } - } - } - if (needsMove) { - QString const sourceFile = sourceInfo.canonicalFilePath(); - - QFileInfo const targetInfo(modDir.absoluteFilePath(localUrl.relativePath)); - sourceList << sourceFile; - targetList << targetInfo.absoluteFilePath(); - relativePathList << QPair(localUrl.relativePath, - localUrl.originName); - } - } else { - QString const sourceFile = sourceInfo.canonicalFilePath(); - - QFileInfo const targetInfo(modDir.absoluteFilePath(localUrl.relativePath)); - sourceList << sourceFile; - targetList << targetInfo.absoluteFilePath(); - relativePathList << QPair(localUrl.relativePath, - localUrl.originName); - } - } - - if (sourceList.count()) { - if (!shellMove(sourceList, targetList)) { - log::debug("Failed to move file (error {})", ::GetLastError()); - return false; - } - } - - for (const auto& iter : relativePathList) { - emit fileMoved(iter.first, iter.second, modInfo->name()); - } - - if (!modInfo->isValid()) { - modInfo->diskContentModified(); - } - - return true; -} - -void ModList::onDragEnter(const QMimeData* mimeData) -{ - m_DropOnMod = ModListDropInfo(mimeData, *m_Organizer).isLocalFileDrop(); -} - -bool ModList::canDropMimeData(const QMimeData* mimeData, Qt::DropAction action, int row, - int column, const QModelIndex& parent) const -{ - if (action == Qt::IgnoreAction) { - return false; - } - - ModListDropInfo const dropInfo(mimeData, *m_Organizer); - - if (dropInfo.isLocalFileDrop()) { - if (row == -1 && parent.isValid()) { - ModInfo::Ptr const modInfo = ModInfo::getByIndex(parent.row()); - return modInfo->isRegular() && !modInfo->isSeparator(); - } - } else if (dropInfo.isValid()) { - // drop on item - if (row == -1 && parent.isValid()) { - return true; - } else if (hasIndex(row, column, parent)) { - ModInfo::Ptr const modInfo = ModInfo::getByIndex(row); - return !modInfo->isBackup() && (modInfo->isSeparator() || !parent.isValid()); - } else { - return true; - } - } - - return false; -} - -bool ModList::dropMimeData(const QMimeData* mimeData, Qt::DropAction action, int row, - int, const QModelIndex& parent) -{ - if (action == Qt::IgnoreAction) { - return true; - } - - ModListDropInfo const dropInfo(mimeData, *m_Organizer); - - if (!m_Profile || !dropInfo.isValid()) { - return false; - } - - int const dropPriority = this->dropPriority(row, parent); - if (dropPriority == -1) { - return false; - } - - if (dropInfo.isLocalFileDrop()) { - return dropLocalFiles(dropInfo, row, parent); - } else { - if (dropInfo.isModDrop()) { - std::vector sourceRows = dropInfo.rows(); - sourceRows.erase( - std::remove_if(sourceRows.begin(), sourceRows.end(), [](int index) { - return index < 0 || static_cast(index) >= ModInfo::getNumMods(); - }), - sourceRows.end()); - - if (sourceRows.empty()) { - return true; - } - - changeModPriority(sourceRows, dropPriority); - return true; - } else if (dropInfo.isDownloadDrop()) { - emit downloadArchiveDropped(dropInfo.download(), dropPriority); - return true; - } else if (dropInfo.isExternalArchiveDrop()) { - emit externalArchiveDropped(dropInfo.externalUrl(), dropPriority); - return true; - } else if (dropInfo.isExternalFolderDrop()) { - emit externalFolderDropped(dropInfo.externalUrl(), dropPriority); - return true; - } else { - return false; - } - } - return true; -} - -void ModList::removeRowForce(int row, const QModelIndex& parent) -{ - if (static_cast(row) >= ModInfo::getNumMods()) { - return; - } - if (m_Profile == nullptr) - return; - - ModInfo::Ptr const modInfo = ModInfo::getByIndex(row); - - bool const wasEnabled = m_Profile->modEnabled(row); - - m_Profile->setModEnabled(row, false); - - m_Profile->cancelModlistWrite(); - beginRemoveRows(parent, row, row); - ModInfo::removeMod(row); - m_Profile->refreshModStatus(); // removes the mod from the status list - endRemoveRows(); - m_Profile->writeModlist(); // this ensures the modified list gets written back before - // new mods can be installed - - notifyModRemoved(modInfo->name()); - - if (wasEnabled) { - emit removeOrigin(modInfo->name()); - } - if (!modInfo->isBackup()) { - emit modUninstalled(modInfo->installationFile()); - } -} - -bool ModList::removeRows(int row, int count, const QModelIndex& parent) -{ - if (static_cast(row) >= ModInfo::getNumMods()) { - return false; - } - if (m_Profile == nullptr) { - return false; - } - - bool success = false; - - if (count == 1) { - ModInfo::Ptr const modInfo = ModInfo::getByIndex(row); - if (modInfo->isOverwrite() && QDir(modInfo->absolutePath()).count() > 2) { - emit clearOverwrite(); - success = true; - } - } - - for (int i = 0; i < count; ++i) { - ModInfo::Ptr const modInfo = ModInfo::getByIndex(row + i); - if (!modInfo->isRegular()) { - continue; - } - - success = true; - - QMessageBox confirmBox( - QMessageBox::Question, tr("Confirm"), - tr("Are you sure you want to remove \"%1\"?").arg(getDisplayName(modInfo)), - QMessageBox::Yes | QMessageBox::No); - - if (confirmBox.exec() == QMessageBox::Yes) { - m_Profile->setModEnabled(row + i, false); - removeRowForce(row + i, parent); - } - } - - return success; -} - -void ModList::notifyChange(int rowStart, int rowEnd) -{ - // this function can emit dataChanged(), which can eventually recurse back - // here; for example: - // - // - a filter is active in the mod list, such as "no categories" - // - mods are selected and a category is set on them - // - these mods get updated here and disappear from the list because they're - // not in "no categories" anymore - // - dataChanged() is emitted - // - it's picked up in MainWindow::modlistSelectionsChanged() because the - // selected mods are gone - // - it calls setOverwriteMarkers(), which calls notifyChange() again and - // ends up here - // - dataChanged() is emitted again - // - // at this point, MO crashes because dataChanged() is not reentrant: it's in - // the middle of modifying internal data and crashes when trying to change an - // internal vector - // - // long story short, this prevents reentrancy - if (m_InNotifyChange) { - return; - } - - m_InNotifyChange = true; - Guard const g([&] { - m_InNotifyChange = false; - }); - - if (rowStart < 0) { - beginResetModel(); - endResetModel(); - } else { - if (rowEnd == -1) { - rowEnd = rowStart; - } - emit dataChanged(this->index(rowStart, 0), - this->index(rowEnd, this->columnCount() - 1)); - } -} - -QModelIndex ModList::index(int row, int column, const QModelIndex&) const -{ - if ((row < 0) || (row >= rowCount()) || (column < 0) || (column >= columnCount())) { - return {}; - } - QModelIndex res = createIndex(row, column, row); - return res; -} - -QModelIndex ModList::parent(const QModelIndex&) const -{ - return {}; -} - -QMap ModList::itemData(const QModelIndex& index) const -{ - QMap result = QAbstractItemModel::itemData(index); - for (int role = Qt::UserRole; role < ModUserRole; ++role) { - result[role] = data(index, role); - } - return result; -} - -QString ModList::getColumnName(int column) -{ - switch (column) { - case COL_CONFLICTFLAGS: - return tr("Conflicts"); - case COL_FLAGS: - return tr("Flags"); - case COL_CONTENT: - return tr("Content"); - case COL_NAME: - return tr("Mod Name"); - case COL_VERSION: - return tr("Version"); - case COL_PRIORITY: - return tr("Priority"); - case COL_CATEGORY: - return tr("Category"); - case COL_AUTHOR: - return tr("Author"); - case COL_UPLOADER: - return tr("Uploader"); - case COL_GAME: - return tr("Source Game"); - case COL_MODID: - return tr("Nexus ID"); - case COL_INSTALLTIME: - return tr("Installation"); - case COL_NOTES: - return tr("Notes"); - default: - return tr("unknown"); - } -} - -QString ModList::getColumnToolTip(int column) const -{ - switch (column) { - case COL_NAME: - return tr("Name of your mods"); - case COL_VERSION: - return tr("Version of the mod (if available)"); - case COL_PRIORITY: - return tr("Installation priority of your mod. The higher, the more \"important\" " - "it is and thus " - "overwrites files from mods with lower priority."); - case COL_CATEGORY: - return tr("Primary category of the mod."); - case COL_AUTHOR: - return tr("Author(s) of the mod."); - case COL_UPLOADER: - return tr("Uploader of the mod. This is not necessarily the same as the author."); - case COL_GAME: - return tr("The source game which was the origin of this mod."); - case COL_MODID: - return tr("Id of the mod as used on Nexus."); - case COL_CONFLICTFLAGS: - return tr("Indicators of file conflicts between mods."); - case COL_FLAGS: - return tr("Emblems to highlight things that might require attention."); - case COL_CONTENT: { - auto& contents = m_Organizer->modDataContents(); - if (m_Organizer->modDataContents().empty()) { - return {}; - } - QString result = - tr("Depicts the content of the mod:") + "
    " + ""; - m_Organizer->modDataContents().forEachContent([&result](auto const& content) { - result += QString("") - .arg(content.icon()) - .arg(content.name()); - }); - return result + "
    %2
    "; - }; - case COL_INSTALLTIME: - return tr("Time this mod was installed"); - case COL_NOTES: - return tr("User notes about the mod"); - default: - return tr("unknown"); - } -} - -void ModList::shiftModsPriority(const QModelIndexList& indices, int offset) -{ - // retrieve the mod index and sort them by priority to avoid issue - // when moving them - std::vector allIndex; - for (auto& idx : indices) { - auto index = idx.data(IndexRole).toInt(); - allIndex.push_back(index); - } - std::sort(allIndex.begin(), allIndex.end(), [=, this](int lhs, int rhs) { - bool const cmp = m_Profile->getModPriority(lhs) < m_Profile->getModPriority(rhs); - return offset > 0 ? !cmp : cmp; - }); - - emit layoutAboutToBeChanged(); - - std::vector notify; - for (auto index : allIndex) { - int newPriority = m_Profile->getModPriority(index) + offset; - if (m_Profile->setModPriority(index, newPriority)) { - notify.push_back(index); - } - } - - emit layoutChanged(); - - for (auto index : notify) { - notifyChange(index); - } - - emit modPrioritiesChanged(indices); -} - -void ModList::changeModsPriority(const QModelIndexList& indices, int priority) -{ - if (indices.isEmpty()) { - return; - } - - std::vector allIndex; - for (auto& idx : indices) { - auto index = idx.data(IndexRole).toInt(); - allIndex.push_back(index); - } - - if (allIndex.size() == 1) { - changeModPriority(allIndex[0], priority); - } else { - changeModPriority(allIndex, priority); - } -} - -bool ModList::toggleState(const QModelIndexList& indices) -{ - emit aboutToChangeData(); - - QList modsToEnable; - QList modsToDisable; - for (auto index : indices) { - auto idx = index.data(IndexRole).toInt(); - if (m_Profile->modEnabled(idx)) { - modsToDisable.append(idx); - } else { - modsToEnable.append(idx); - } - } - - m_Profile->setModsEnabled(modsToEnable, modsToDisable); - - emit tutorialModlistUpdate(); - - m_Modified = true; - m_LastCheck.restart(); - - emit dataChanged(index(0, 0), index(rowCount() - 1, columnCount() - 1)); - - emit postDataChanged(); - - return true; -} - -void ModList::setActive(const QModelIndexList& indices, bool active) -{ - QList mods; - for (auto& index : indices) { - mods.append(index.data(IndexRole).toInt()); - } - - if (active) { - m_Profile->setModsEnabled(mods, {}); - } else { - m_Profile->setModsEnabled({}, mods); - } -} +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "modlist.h" + +#include "messagedialog.h" +#include "modinforegular.h" +#include "modlistdropinfo.h" +#include "modlistsortproxy.h" +#include "organizercore.h" +#include "pluginlist.h" +#include "qtgroupingproxy.h" +#include "settings.h" +#include "shared/directoryentry.h" +#include "shared/fileentry.h" +#include "shared/filesorigin.h" +#include "viewmarkingscrollbar.h" +#include "widgetutility.h" + +#include "filesystemutilities.h" +#include "shared/appconfig.h" +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +using namespace MOBase; + +ModList::ModList(PluginContainer* pluginContainer, OrganizerCore* organizer) + : QAbstractItemModel(organizer), m_Organizer(organizer), m_Profile(nullptr), + m_NexusInterface(nullptr), m_Modified(false), m_InNotifyChange(false), + m_FontMetrics(QFont()), m_PluginContainer(pluginContainer) +{ + m_LastCheck.start(); +} + +ModList::~ModList() +{ + m_ModInstalled.disconnect_all_slots(); + m_ModRemoved.disconnect_all_slots(); + m_ModStateChanged.disconnect_all_slots(); + m_ModMoved.disconnect_all_slots(); +} + +void ModList::setProfile(Profile* profile) +{ + m_Profile = profile; +} + +int ModList::rowCount(const QModelIndex& parent) const +{ + if (!parent.isValid()) { + return ModInfo::getNumMods(); + } else { + return 0; + } +} + +bool ModList::hasChildren(const QModelIndex& parent) const +{ + if (!parent.isValid()) { + return ModInfo::getNumMods() > 0; + } else { + return false; + } +} + +int ModList::columnCount(const QModelIndex&) const +{ + return COL_LASTCOLUMN + 1; +} + +QString ModList::getDisplayName(ModInfo::Ptr info) +{ + QString name = info->name(); + if (info->isSeparator()) { + name = name.replace("_separator", ""); + } + return name; +} + +QString ModList::makeInternalName(ModInfo::Ptr info, QString name) +{ + if (info->isSeparator()) { + name += "_separator"; + } + return name; +} + +QString ModList::getFlagText(ModInfo::EFlag flag, ModInfo::Ptr modInfo) +{ + switch (flag) { + case ModInfo::FLAG_BACKUP: + return tr("Backup"); + case ModInfo::FLAG_SEPARATOR: + return tr("Separator"); + case ModInfo::FLAG_INVALID: + return tr("No valid game data"); + case ModInfo::FLAG_NOTENDORSED: + return tr("Not endorsed yet"); + case ModInfo::FLAG_NOTES: { + QStringList output; + if (!modInfo->comments().isEmpty()) + output << QString("%1").arg(modInfo->comments()); + if (!modInfo->notes().isEmpty()) + output << QString("%1").arg(modInfo->notes()); + return output.join(""); + } + case ModInfo::FLAG_ALTERNATE_GAME: + return tr("This mod is for a different
    game, " + "make sure it's compatible or it could cause crashes."); + case ModInfo::FLAG_TRACKED: + return tr("Mod is being tracked on the website"); + case ModInfo::FLAG_HIDDEN_FILES: + return tr("Contains hidden files"); + default: + return ""; + } +} + +QString ModList::getConflictFlagText(ModInfo::EConflictFlag flag, + ModInfo::Ptr modInfo) +{ + switch (flag) { + case ModInfo::FLAG_CONFLICT_OVERWRITE: + return tr("Overwrites loose files"); + case ModInfo::FLAG_CONFLICT_OVERWRITTEN: + return tr("Overwritten loose files"); + case ModInfo::FLAG_CONFLICT_MIXED: + return tr("Loose files Overwrites & Overwritten"); + case ModInfo::FLAG_CONFLICT_REDUNDANT: + return tr("Redundant"); + case ModInfo::FLAG_ARCHIVE_LOOSE_CONFLICT_OVERWRITE: + return tr("Overwrites an archive with loose files"); + case ModInfo::FLAG_ARCHIVE_LOOSE_CONFLICT_OVERWRITTEN: + return tr("Archive is overwritten by loose files"); + case ModInfo::FLAG_ARCHIVE_CONFLICT_OVERWRITE: + return tr("Overwrites another archive file"); + case ModInfo::FLAG_ARCHIVE_CONFLICT_OVERWRITTEN: + return tr("Overwritten by another archive file"); + case ModInfo::FLAG_ARCHIVE_CONFLICT_MIXED: + return tr("Archive files overwrites & overwritten"); + default: + return ""; + } +} + +QVariant ModList::data(const QModelIndex& modelIndex, int role) const +{ + if (m_Profile == nullptr) + return {}; + if (!modelIndex.isValid()) + return {}; + unsigned int const modIndex = modelIndex.row(); + int const column = modelIndex.column(); + + ModInfo::Ptr const modInfo = ModInfo::getByIndex(modIndex); + if ((role == Qt::DisplayRole) || (role == Qt::EditRole)) { + if ((column == COL_FLAGS) || (column == COL_CONTENT) || + (column == COL_CONFLICTFLAGS)) { + return {}; + } else if (column == COL_NAME) { + return getDisplayName(modInfo); + } else if (column == COL_VERSION) { + VersionInfo const verInfo = modInfo->version(); + QString version = verInfo.displayString(); + if (role != Qt::EditRole) { + if (version.isEmpty() && modInfo->canBeUpdated()) { + version = "?"; + } + } + return version; + } else if (column == COL_PRIORITY) { + if (modInfo->hasAutomaticPriority()) { + return {}; // hide priority for mods where it's fixed + } else { + return QString::number(m_Profile->getModPriority(modIndex)); + } + } else if (column == COL_MODID) { + int const modID = modInfo->nexusId(); + if (modID > 0) { + return modID; + } else { + return {}; + } + } else if (column == COL_GAME) { + if (m_PluginContainer != nullptr) { + for (auto *game : m_PluginContainer->plugins()) { + if (game->gameShortName().compare(modInfo->gameName(), Qt::CaseInsensitive) == + 0) + return game->gameName(); + } + } + return modInfo->gameName(); + } else if (column == COL_CATEGORY) { + if (modInfo->hasFlag(ModInfo::FLAG_FOREIGN)) { + return tr("Non-MO"); + } else { + int category = modInfo->primaryCategory(); + if (category != -1) { + CategoryFactory const& categoryFactory = CategoryFactory::instance(); + if (categoryFactory.categoryExists(category)) { + try { + int const categoryIdx = categoryFactory.getCategoryIndex(category); + return categoryFactory.getCategoryName(categoryIdx); + } catch (const std::exception& e) { + log::error("failed to retrieve category name: {}", e.what()); + return QString(); + } + } else { + log::warn("category {} doesn't exist (may have been removed)", category); + modInfo->setCategory(category, false); + return QString(); + } + } else { + return {}; + } + } + } else if (column == COL_AUTHOR) { + return modInfo->author(); + } else if (column == COL_UPLOADER) { + return modInfo->uploader(); + } else if (column == COL_INSTALLTIME) { + // display installation time for mods that can be updated + if (modInfo->creationTime().isValid()) { + return modInfo->creationTime(); + } else { + return {}; + } + } else if (column == COL_NOTES) { + return modInfo->comments(); + } else { + return tr("invalid"); + } + } else if ((role == Qt::CheckStateRole) && (column == 0)) { + if (modInfo->canBeEnabled()) { + return m_Profile->modEnabled(modIndex) ? Qt::Checked : Qt::Unchecked; + } else { + return {}; + } + } else if (role == Qt::TextAlignmentRole) { + if (column == COL_NAME) { + if (modInfo->getHighlight() & ModInfo::HIGHLIGHT_CENTER) { + return {Qt::AlignCenter | Qt::AlignVCenter}; + } else { + return {Qt::AlignLeft | Qt::AlignVCenter}; + } + } else if (column == COL_VERSION) { + return {Qt::AlignRight | Qt::AlignVCenter}; + } else if (column == COL_NOTES) { + return {Qt::AlignLeft | Qt::AlignVCenter}; + } else { + return {Qt::AlignCenter | Qt::AlignVCenter}; + } + } else if (role == GroupingRole) { + if (column == COL_CATEGORY) { + QVariantList categoryNames; + std::set const categories = modInfo->getCategories(); + CategoryFactory const& categoryFactory = CategoryFactory::instance(); + for (auto iter = categories.begin(); iter != categories.end(); ++iter) { + categoryNames.append( + categoryFactory.getCategoryName(categoryFactory.getCategoryIndex(*iter))); + } + if (categoryNames.count() != 0) { + return categoryNames; + } else { + return {}; + } + } else { + return modInfo->nexusId(); + } + } else if (role == IndexRole) { + return modIndex; + } else if (role == AggrRole) { + switch (column) { + case COL_MODID: + return QtGroupingProxy::AGGR_FIRST; + case COL_VERSION: + return QtGroupingProxy::AGGR_MAX; + case COL_CATEGORY: + return QtGroupingProxy::AGGR_FIRST; + case COL_PRIORITY: + return QtGroupingProxy::AGGR_MIN; + default: + return QtGroupingProxy::AGGR_NONE; + } + } else if (role == GameNameRole) { + return modInfo->gameName(); + } else if (role == PriorityRole) { + return m_Profile->getModPriority(modIndex); + } else if (role == Qt::FontRole) { + QFont result; + if (column == COL_NAME) { + if (modInfo->isSeparator()) { + result.setItalic(true); + result.setBold(true); + } else if (modInfo->getHighlight() & ModInfo::HIGHLIGHT_INVALID) { + result.setItalic(true); + } + } else if (column == COL_CATEGORY && modInfo->isForeign()) { + result.setItalic(true); + } else if (column == COL_VERSION) { + if (modInfo->updateAvailable() || modInfo->downgradeAvailable()) { + result.setWeight(QFont::Bold); + } + if (modInfo->canBeUpdated()) { + result.setItalic(true); + } + } + return result; + } else if (role == Qt::DecorationRole) { + if (column == COL_VERSION) { + if (modInfo->updateAvailable()) { + return QIcon(":/MO/gui/update_available"); + } else if (modInfo->downgradeAvailable()) { + return QIcon(":/MO/gui/warning"); + } else if (modInfo->version().scheme() == VersionInfo::SCHEME_DATE) { + return QIcon(":/MO/gui/version_date"); + } + } + return {}; + } else if (role == Qt::ForegroundRole) { + if (column == COL_NAME) { + int const highlight = modInfo->getHighlight(); + if (highlight & ModInfo::HIGHLIGHT_IMPORTANT) { + return QBrush(Qt::darkRed); + } else if (highlight & ModInfo::HIGHLIGHT_INVALID) { + return QBrush(Qt::darkGray); + } + } else if (column == COL_VERSION) { + if (!modInfo->newestVersion().isValid()) { + return {}; + } else if (modInfo->updateAvailable() || modInfo->downgradeAvailable()) { + return QBrush(Qt::red); + } else { + return QBrush(Qt::darkGreen); + } + } + return {}; + } else if (role == Qt::BackgroundRole || role == ScrollMarkRole) { + if (column == COL_NOTES && modInfo->color().isValid()) { + return modInfo->color(); + } else if (modInfo->isSeparator() && modInfo->color().isValid() && + (role != ScrollMarkRole || + Settings::instance().colors().colorSeparatorScrollbar())) { + return modInfo->color(); + } else { + return {}; + } + } else if (role == Qt::ToolTipRole) { + if (column == COL_FLAGS) { + QString result; + + for (ModInfo::EFlag const flag : modInfo->getFlags()) { + if (result.length() != 0) + result += "
    "; + result += getFlagText(flag, modInfo); + } + + return result; + } else if (column == COL_CONFLICTFLAGS) { + QString result; + + for (ModInfo::EConflictFlag const flag : modInfo->getConflictFlags()) { + if (result.length() != 0) + result += "
    "; + result += getConflictFlagText(flag, modInfo); + } + + return result; + } else if (column == COL_NAME) { + try { + return modInfo->getDescription(); + } catch (const std::exception& e) { + log::error("invalid mod description: {}", e.what()); + return QString(); + } + } else if (column == COL_VERSION) { + QString text = tr(R"(installed version: "%1", newest version: "%2")") + .arg(modInfo->version().displayString(3)) + .arg(modInfo->newestVersion().displayString(3)); + if (modInfo->downgradeAvailable()) { + text += + "
    " + tr("The newest version on Nexus seems to be older than the one " + "you have installed. This could either mean the version you " + "have has been withdrawn " + "(i.e. due to a bug) or the author uses a non-standard " + "versioning scheme and that newest version is actually newer. " + "Either way you may want to \"upgrade\"."); + } + if (modInfo->getNexusFileStatus() == NexusInterface::FileStatus::OLD_VERSION) { + text += "
    " + tr("This file has been marked as \"Old\". There is most " + "likely an updated version of this file available."); + } else if (modInfo->getNexusFileStatus() == NexusInterface::FileStatus::REMOVED || + modInfo->getNexusFileStatus() == + NexusInterface::FileStatus::ARCHIVED || + modInfo->getNexusFileStatus() == + NexusInterface::FileStatus::ARCHIVED_HIDDEN) { + text += + "
    " + tr("This file has been marked as \"Deleted\"! You may want to " + "check for an update or remove the nexus ID from this mod!"); + } + if (modInfo->nexusId() > 0) { + if (!modInfo->canBeUpdated()) { + qint64 const remains = + QDateTime::currentDateTimeUtc().secsTo(modInfo->getExpires()); + qint64 const minutes = remains / 60; + qint64 const seconds = remains % 60; + QString const remainsStr( + tr("%1 minute(s) and %2 second(s)").arg(minutes).arg(seconds)); + text += + "
    " + tr("This mod will be available to check in %2.").arg(remainsStr); + } + } + return text; + } else if (column == COL_CATEGORY) { + const std::set& categories = modInfo->getCategories(); + if (categories.empty()) { + return QString(); + } + std::wostringstream categoryString; + categoryString << ToWString(tr("Categories:
    ")); + CategoryFactory const& categoryFactory = CategoryFactory::instance(); + for (std::set::const_iterator catIter = categories.begin(); + catIter != categories.end(); ++catIter) { + if (catIter != categories.begin()) { + categoryString << " , "; + } + try { + categoryString << "" + << ToWString(categoryFactory.getCategoryName( + categoryFactory.getCategoryIndex(*catIter))) + << ""; + } catch (const std::exception& e) { + log::error("failed to generate tooltip: {}", e.what()); + return QString(); + } + } + + return ToQString(categoryString.str()); + } else if (column == COL_NOTES) { + return getFlagText(ModInfo::FLAG_NOTES, modInfo); + } else { + return {}; + } + } else { + return {}; + } + + return {}; +} + +bool ModList::renameMod(int index, const QString& newName) +{ + QString nameFixed = newName; + if (!fixDirectoryName(nameFixed) || nameFixed.isEmpty()) { + MessageDialog::showMessage(tr("Invalid name"), nullptr); + return false; + } + + if (ModList::allMods().contains(nameFixed, Qt::CaseInsensitive) && + nameFixed.toLower() != ModInfo::getByIndex(index)->name().toLower()) { + MessageDialog::showMessage(tr("Name is already in use by another mod"), nullptr); + return false; + } + + ModInfo::Ptr const modInfo = ModInfo::getByIndex(index); + QString const oldName = modInfo->name(); + if (nameFixed != oldName) { + // before we rename, ensure there is no scheduled asynchronous to rewrite + m_Profile->cancelModlistWrite(); + + if (modInfo->setName(nameFixed)) { + // Notice there is a good chance that setName() updated the modinfo indexes + // the modRenamed() call will refresh the indexes in the current profile + // and update the modlists in all profiles + emit modRenamed(oldName, nameFixed); + } + + // invalidate the currently displayed state of this list + notifyChange(-1); + } + return true; +} + +bool ModList::setData(const QModelIndex& index, const QVariant& value, int role) +{ + if (m_Profile == nullptr) + return false; + + if (static_cast(index.row()) >= ModInfo::getNumMods()) { + return false; + } + + int const modID = index.row(); + + ModInfo::Ptr const info = ModInfo::getByIndex(modID); + + bool result = false; + + emit aboutToChangeData(); + + if (role == Qt::CheckStateRole) { + bool const enabled = value.toInt() == Qt::Checked; + if (m_Profile->modEnabled(modID) != enabled) { + m_Profile->setModEnabled(modID, enabled); + m_Modified = true; + m_LastCheck.restart(); + emit tutorialModlistUpdate(); + } + result = true; + emit dataChanged(index, index); + } else if (role == Qt::EditRole) { + switch (index.column()) { + case COL_NAME: { + result = renameMod(modID, makeInternalName(info, value.toString())); + } break; + case COL_PRIORITY: { + bool ok = false; + int const newPriority = value.toInt(&ok); + if (ok) { + changeModPriority(modID, newPriority); + result = true; + } else { + result = false; + } + } break; + case COL_MODID: { + bool ok = false; + int const newID = value.toInt(&ok); + if (ok) { + info->setNexusID(newID); + emit tutorialModlistUpdate(); + result = true; + } else { + result = false; + } + } break; + case COL_VERSION: { + VersionInfo::VersionScheme const scheme = info->version().scheme(); + VersionInfo const version(value.toString(), scheme, true); + if (version.isValid()) { + info->setVersion(version); + result = true; + } else { + result = false; + } + } break; + case COL_NOTES: { + info->setComments(value.toString()); + result = true; + } break; + default: { + log::warn("edit on column \"{}\" not supported", + getColumnName(index.column()).toUtf8().constData()); + result = false; + } break; + } + if (result) { + emit dataChanged(index, index); + } + } + + emit postDataChanged(); + return result; +} + +QVariant ModList::headerData(int section, Qt::Orientation orientation, int role) const +{ + if (orientation == Qt::Horizontal) { + if (role == Qt::DisplayRole) { + return getColumnName(section); + } else if (role == Qt::ToolTipRole) { + return getColumnToolTip(section); + } else if (role == Qt::TextAlignmentRole) { + return {Qt::AlignCenter}; + } else if (role == MOBase::EnabledColumnRole) { + if (section == COL_CONTENT) { + return !m_Organizer->modDataContents().empty(); + } else { + return true; + } + } + } + return QAbstractItemModel::headerData(section, orientation, role); +} + +Qt::ItemFlags ModList::flags(const QModelIndex& modelIndex) const +{ + Qt::ItemFlags result = QAbstractItemModel::flags(modelIndex); + if (modelIndex.internalId() < 0) { + return Qt::ItemIsEnabled; + } + if (modelIndex.isValid()) { + ModInfo::Ptr const modInfo = ModInfo::getByIndex(modelIndex.row()); + if (!modInfo->hasAutomaticPriority()) { + result |= Qt::ItemIsDragEnabled; + result |= Qt::ItemIsUserCheckable; + if ((modelIndex.column() == COL_PRIORITY) || + (modelIndex.column() == COL_VERSION) || (modelIndex.column() == COL_MODID)) { + result |= Qt::ItemIsEditable; + } + if ((modelIndex.column() == COL_NAME || modelIndex.column() == COL_NOTES) && + !modInfo->isForeign()) { + result |= Qt::ItemIsEditable; + } + } + if (modInfo->isSeparator() || m_DropOnMod) { + result |= Qt::ItemIsDropEnabled; + } + } else if (!m_DropOnMod) { + result |= Qt::ItemIsDropEnabled; + } + + return result; +} + +QStringList ModList::mimeTypes() const +{ + QStringList result = QAbstractItemModel::mimeTypes(); + result.append("text/uri-list"); + return result; +} + +QMimeData* ModList::mimeData(const QModelIndexList& indexes) const +{ + QMimeData* result = QAbstractItemModel::mimeData(indexes); + result->setData("text/plain", ModListDropInfo::ModText); + return result; +} + +void ModList::changeModPriority(std::vector sourceIndices, int newPriority) +{ + if (m_Profile == nullptr) + return; + + sourceIndices.erase( + std::remove_if(sourceIndices.begin(), sourceIndices.end(), [](int index) { + return index < 0 || static_cast(index) >= ModInfo::getNumMods(); + }), + sourceIndices.end()); + + if (sourceIndices.empty()) { + return; + } + + const bool anyNeedsMove = std::any_of( + sourceIndices.begin(), sourceIndices.end(), + [this, newPriority](int index) { return m_Profile->getModPriority(index) != newPriority; }); + if (!anyNeedsMove) { + return; + } + + emit layoutAboutToBeChanged(); + + // sort the moving mods by ascending priorities + std::sort(sourceIndices.begin(), sourceIndices.end(), + [=, this](const int& LHS, const int& RHS) { + return m_Profile->getModPriority(LHS) > m_Profile->getModPriority(RHS); + }); + + // move mods that are decreasing in priority + for (const auto& index : sourceIndices) { + int const oldPriority = m_Profile->getModPriority(index); + if (oldPriority > newPriority) { + if (m_Profile->setModPriority(index, newPriority)) { + m_ModMoved(ModInfo::getByIndex(index)->name(), oldPriority, newPriority); + } + } + } + + // sort the moving mods by descending priorities + std::sort(sourceIndices.begin(), sourceIndices.end(), + [=, this](const int& LHS, const int& RHS) { + return m_Profile->getModPriority(LHS) < m_Profile->getModPriority(RHS); + }); + + // if at least one mod is increasing in priority, the target index is + // that of the row BELOW the dropped location, otherwise it's the one above + for (const auto& index : sourceIndices) { + int const oldPriority = m_Profile->getModPriority(index); + if (oldPriority < newPriority) { + --newPriority; + break; + } + } + + // move mods that are increasing in priority + for (const auto& index : sourceIndices) { + int const oldPriority = m_Profile->getModPriority(index); + if (oldPriority < newPriority) { + if (m_Profile->setModPriority(index, newPriority)) { + m_ModMoved(ModInfo::getByIndex(index)->name(), oldPriority, newPriority); + } + } + } + + emit layoutChanged(); + + QModelIndexList indices; + for (auto& idx : sourceIndices) { + indices.append(index(idx, 0, QModelIndex())); + } + + emit modPrioritiesChanged(indices); +} + +void ModList::changeModPriority(int sourceIndex, int newPriority) +{ + if (m_Profile == nullptr) + return; + emit layoutAboutToBeChanged(); + + m_Profile->setModPriority(sourceIndex, newPriority); + + emit layoutChanged(); + emit modPrioritiesChanged({index(sourceIndex, 0)}); +} + +void ModList::setPluginContainer(PluginContainer* pluginContianer) +{ + m_PluginContainer = pluginContianer; +} + +bool ModList::modInfoAboutToChange(ModInfo::Ptr info) +{ + if (m_ChangeInfo.name.isEmpty()) { + m_ChangeInfo.name = info->name(); + m_ChangeInfo.state = state(info->name()); + return true; + } else { + return false; + } +} + +void ModList::modInfoChanged(ModInfo::Ptr info) +{ + if (info->name() == m_ChangeInfo.name) { + IModList::ModStates newState = state(info->name()); + if (m_ChangeInfo.state != newState) { + m_ModStateChanged({{info->name(), newState}}); + } + + int const row = ModInfo::getIndex(info->name()); + info->diskContentModified(); + emit aboutToChangeData(); + emit dataChanged(index(row, 0), index(row, columnCount())); + emit postDataChanged(); + } else { + log::error("modInfoChanged not called after modInfoAboutToChange"); + } + m_ChangeInfo.name = QString(); +} + +void ModList::disconnectSlots() +{ + m_ModMoved.disconnect_all_slots(); + m_ModStateChanged.disconnect_all_slots(); +} + +int ModList::timeElapsedSinceLastChecked() const +{ + return m_LastCheck.elapsed(); +} + +IModList::ModStates ModList::state(unsigned int modIndex) const +{ + IModList::ModStates result; + if (modIndex != UINT_MAX) { + result |= IModList::STATE_EXISTS; + ModInfo::Ptr const modInfo = ModInfo::getByIndex(modIndex); + if (modInfo->isEmpty()) { + result |= IModList::STATE_EMPTY; + } + if (modInfo->endorsedState() == EndorsedState::ENDORSED_TRUE) { + result |= IModList::STATE_ENDORSED; + } + if (modInfo->isValid()) { + result |= IModList::STATE_VALID; + } + if (modInfo->isRegular()) { + QSharedPointer const modInfoRegular = + modInfo.staticCast(); + if (modInfoRegular->isAlternate() && !modInfoRegular->isConverted()) + result |= IModList::STATE_ALTERNATE; + if (!modInfo->isValid() && modInfoRegular->isValidated()) + result |= IModList::STATE_VALID; + } + if (modInfo->canBeEnabled()) { + if (m_Profile->modEnabled(modIndex)) { + result |= IModList::STATE_ACTIVE; + } + } else { + result |= IModList::STATE_ESSENTIAL; + } + } + return result; +} + +QString ModList::displayName(const QString& internalName) +{ + unsigned int const modIndex = ModInfo::getIndex(internalName); + if (modIndex == UINT_MAX) { + // might be better to throw an exception? + return internalName; + } else { + return ModInfo::getByIndex(modIndex)->name(); + } +} + +QStringList ModList::allMods() +{ + QStringList result; + for (unsigned int i = 0; i < ModInfo::getNumMods(); ++i) { + result.append(ModInfo::getByIndex(i)->internalName()); + } + return result; +} + +QStringList ModList::allModsByProfilePriority(MOBase::IProfile* profile) const +{ + Profile* mo2Profile = profile == nullptr ? m_Organizer->currentProfile().get() + : static_cast(profile); + + QStringList res; + for (const auto& [priority, index] : mo2Profile->getAllIndexesByPriority()) { + auto modInfo = ModInfo::getByIndex(index); + if (!modInfo->isBackup() && !modInfo->isOverwrite()) { + res.push_back(modInfo->internalName()); + } + } + return res; +} + +MOBase::IModInterface* ModList::getMod(const QString& name) +{ + unsigned int const index = ModInfo::getIndex(name); + return index == UINT_MAX ? nullptr : ModInfo::getByIndex(index).data(); +} + +bool ModList::removeMod(MOBase::IModInterface* mod) const +{ + bool const result = ModInfo::removeMod(ModInfo::getIndex(mod->name())); + if (result) { + notifyModRemoved(mod->name()); + } + return result; +} + +MOBase::IModInterface* ModList::renameMod(MOBase::IModInterface* mod, + const QString& name) +{ + unsigned int const index = ModInfo::getIndex(mod->name()); + if (index == UINT_MAX) { + if (auto* p = dynamic_cast(mod)) { + p->setName(name); + return p; + } else { + return nullptr; + } + } else { + if (renameMod(index, name)) { + return ModInfo::getByName(name).get(); + } else { + return nullptr; + } + } +} + +IModList::ModStates ModList::state(const QString& name) const +{ + unsigned int const modIndex = ModInfo::getIndex(name); + + return state(modIndex); +} + +bool ModList::setActive(const QString& name, bool active) +{ + unsigned int const modIndex = ModInfo::getIndex(name); + if (modIndex == UINT_MAX) { + log::debug("Trying to {} mod {} which does not exist.", + active ? "enable" : "disable", name); + return false; + } else { + m_Profile->setModEnabled(modIndex, active); + return true; + } +} + +int ModList::setActive(const QStringList& names, bool active) +{ + + // We only add indices for mods that exist (modIndex != UINT_MAX) + // and that can be enabled / disabled. + QList indices; + for (const auto& name : names) { + auto modIndex = ModInfo::getIndex(name); + if (modIndex != UINT_MAX) { + indices.append(modIndex); + } else { + log::debug("Trying to {} mod {} which does not exist.", + active ? "enable" : "disable", name); + } + } + + if (active) { + m_Profile->setModsEnabled(indices, {}); + } else { + m_Profile->setModsEnabled({}, indices); + } + + return indices.size(); +} + +int ModList::priority(const QString& name) const +{ + unsigned int const modIndex = ModInfo::getIndex(name); + if (modIndex == UINT_MAX) { + return -1; + } else { + return m_Profile->getModPriority(modIndex); + } +} + +bool ModList::setPriority(const QString& name, int newPriority) +{ + unsigned int const index = ModInfo::getIndex(name); + if (index == UINT_MAX) { + return false; + } else { + if (m_Profile->setModPriority(index, newPriority)) { + notifyChange(index); + } + return true; + } +} + +boost::signals2::connection +ModList::onModInstalled(const std::function& func) +{ + return m_ModInstalled.connect(func); +} + +boost::signals2::connection +ModList::onModRemoved(const std::function& func) +{ + return m_ModRemoved.connect(func); +} + +boost::signals2::connection ModList::onModStateChanged( + const std::function&)>& func) +{ + return m_ModStateChanged.connect(func); +} + +void ModList::notifyModInstalled(MOBase::IModInterface* mod) const +{ + m_ModInstalled(mod); +} + +void ModList::notifyModRemoved(QString const& modName) const +{ + m_ModRemoved(modName); +} + +void ModList::notifyModStateChanged(QList modIndices) const +{ + QModelIndexList indices; + std::map mods; + for (auto modIndex : modIndices) { + indices.append(index(modIndex, 0)); + ModInfo::Ptr const modInfo = ModInfo::getByIndex(modIndex); + mods.emplace(modInfo->name(), state(modIndex)); + } + + emit modStatesChanged(indices); + m_ModStateChanged(mods); +} + +boost::signals2::connection +ModList::onModMoved(const std::function& func) +{ + return m_ModMoved.connect(func); +} + +int ModList::dropPriority(int row, const QModelIndex& parent) const +{ + if (row == -1) { + row = parent.row(); + } + + if ((row < 0) || (static_cast(row) >= ModInfo::getNumMods())) { + return -1; + } + + int newPriority = 0; + { + if (row < 0 || row >= rowCount()) { + newPriority = Profile::MaximumPriority; + } else { + newPriority = m_Profile->getModPriority(row); + } + } + + return newPriority; +} + +bool ModList::dropLocalFiles(const ModListDropInfo& dropInfo, int row, + const QModelIndex& parent) +{ + if (row == -1) { + row = parent.row(); + } + ModInfo::Ptr const modInfo = ModInfo::getByIndex(row); + QDir const modDir = QDir(modInfo->absolutePath()); + + QStringList sourceList; + QStringList targetList; + QList> relativePathList; + + for (const auto& localUrl : dropInfo.localUrls()) { + QFileInfo const sourceInfo(localUrl.url.toLocalFile()); + if (localUrl.originName.compare("overwrite", Qt::CaseInsensitive) == 0) { + bool needsMove = true; + if (sourceInfo.isDir()) { + for (const auto& dir : m_Organizer->managedGame()->getModMappings().keys()) { + QDir const overDir(m_Organizer->overwritePath()); + if (sourceInfo.canonicalFilePath().compare(overDir.absoluteFilePath(dir), + Qt::CaseInsensitive) == 0) { + needsMove = false; + + QDirIterator dirIter(overDir.absoluteFilePath(dir), + QDir::AllDirs | QDir::Files | QDir::NoDotAndDotDot); + while (dirIter.hasNext()) { + auto entry = dirIter.nextFileInfo(); + QString const sourceFile = entry.canonicalFilePath(); + + QFileInfo const targetInfo(modDir.absoluteFilePath( + overDir.relativeFilePath(entry.absoluteFilePath()))); + sourceList << sourceFile; + targetList << targetInfo.absoluteFilePath(); + relativePathList << QPair(localUrl.relativePath, + localUrl.originName); + } + } + } + } + if (needsMove) { + QString const sourceFile = sourceInfo.canonicalFilePath(); + + QFileInfo const targetInfo(modDir.absoluteFilePath(localUrl.relativePath)); + sourceList << sourceFile; + targetList << targetInfo.absoluteFilePath(); + relativePathList << QPair(localUrl.relativePath, + localUrl.originName); + } + } else { + QString const sourceFile = sourceInfo.canonicalFilePath(); + + QFileInfo const targetInfo(modDir.absoluteFilePath(localUrl.relativePath)); + sourceList << sourceFile; + targetList << targetInfo.absoluteFilePath(); + relativePathList << QPair(localUrl.relativePath, + localUrl.originName); + } + } + + if (sourceList.count()) { + if (!shellMove(sourceList, targetList)) { + log::debug("Failed to move file (error {})", ::GetLastError()); + return false; + } + } + + for (const auto& iter : relativePathList) { + emit fileMoved(iter.first, iter.second, modInfo->name()); + } + + if (!modInfo->isValid()) { + modInfo->diskContentModified(); + } + + return true; +} + +void ModList::onDragEnter(const QMimeData* mimeData) +{ + m_DropOnMod = ModListDropInfo(mimeData, *m_Organizer).isLocalFileDrop(); +} + +bool ModList::canDropMimeData(const QMimeData* mimeData, Qt::DropAction action, int row, + int column, const QModelIndex& parent) const +{ + if (action == Qt::IgnoreAction) { + return false; + } + + ModListDropInfo const dropInfo(mimeData, *m_Organizer); + + if (dropInfo.isLocalFileDrop()) { + if (row == -1 && parent.isValid()) { + ModInfo::Ptr const modInfo = ModInfo::getByIndex(parent.row()); + return modInfo->isRegular() && !modInfo->isSeparator(); + } + } else if (dropInfo.isValid()) { + // drop on item + if (row == -1 && parent.isValid()) { + return true; + } else if (hasIndex(row, column, parent)) { + ModInfo::Ptr const modInfo = ModInfo::getByIndex(row); + return !modInfo->isBackup() && (modInfo->isSeparator() || !parent.isValid()); + } else { + return true; + } + } + + return false; +} + +bool ModList::dropMimeData(const QMimeData* mimeData, Qt::DropAction action, int row, + int, const QModelIndex& parent) +{ + if (action == Qt::IgnoreAction) { + return true; + } + + ModListDropInfo const dropInfo(mimeData, *m_Organizer); + + if (!m_Profile || !dropInfo.isValid()) { + return false; + } + + int const dropPriority = this->dropPriority(row, parent); + if (dropPriority == -1) { + return false; + } + + if (dropInfo.isLocalFileDrop()) { + return dropLocalFiles(dropInfo, row, parent); + } else { + if (dropInfo.isModDrop()) { + std::vector sourceRows = dropInfo.rows(); + sourceRows.erase( + std::remove_if(sourceRows.begin(), sourceRows.end(), [](int index) { + return index < 0 || static_cast(index) >= ModInfo::getNumMods(); + }), + sourceRows.end()); + + if (sourceRows.empty()) { + return true; + } + + changeModPriority(sourceRows, dropPriority); + return true; + } else if (dropInfo.isDownloadDrop()) { + emit downloadArchiveDropped(dropInfo.download(), dropPriority); + return true; + } else if (dropInfo.isExternalArchiveDrop()) { + emit externalArchiveDropped(dropInfo.externalUrl(), dropPriority); + return true; + } else if (dropInfo.isExternalFolderDrop()) { + emit externalFolderDropped(dropInfo.externalUrl(), dropPriority); + return true; + } else { + return false; + } + } + return true; +} + +void ModList::removeRowForce(int row, const QModelIndex& parent) +{ + if (static_cast(row) >= ModInfo::getNumMods()) { + return; + } + if (m_Profile == nullptr) + return; + + ModInfo::Ptr const modInfo = ModInfo::getByIndex(row); + + bool const wasEnabled = m_Profile->modEnabled(row); + + m_Profile->setModEnabled(row, false); + + m_Profile->cancelModlistWrite(); + beginRemoveRows(parent, row, row); + ModInfo::removeMod(row); + m_Profile->refreshModStatus(); // removes the mod from the status list + endRemoveRows(); + m_Profile->writeModlist(); // this ensures the modified list gets written back before + // new mods can be installed + + notifyModRemoved(modInfo->name()); + + if (wasEnabled) { + emit removeOrigin(modInfo->name()); + } + if (!modInfo->isBackup()) { + emit modUninstalled(modInfo->installationFile()); + } +} + +bool ModList::removeRows(int row, int count, const QModelIndex& parent) +{ + if (static_cast(row) >= ModInfo::getNumMods()) { + return false; + } + if (m_Profile == nullptr) { + return false; + } + + bool success = false; + + if (count == 1) { + ModInfo::Ptr const modInfo = ModInfo::getByIndex(row); + if (modInfo->isOverwrite() && QDir(modInfo->absolutePath()).count() > 2) { + emit clearOverwrite(); + success = true; + } + } + + for (int i = 0; i < count; ++i) { + ModInfo::Ptr const modInfo = ModInfo::getByIndex(row + i); + if (!modInfo->isRegular()) { + continue; + } + + success = true; + + QMessageBox confirmBox( + QMessageBox::Question, tr("Confirm"), + tr("Are you sure you want to remove \"%1\"?").arg(getDisplayName(modInfo)), + QMessageBox::Yes | QMessageBox::No); + + if (confirmBox.exec() == QMessageBox::Yes) { + m_Profile->setModEnabled(row + i, false); + removeRowForce(row + i, parent); + } + } + + return success; +} + +void ModList::notifyChange(int rowStart, int rowEnd) +{ + // this function can emit dataChanged(), which can eventually recurse back + // here; for example: + // + // - a filter is active in the mod list, such as "no categories" + // - mods are selected and a category is set on them + // - these mods get updated here and disappear from the list because they're + // not in "no categories" anymore + // - dataChanged() is emitted + // - it's picked up in MainWindow::modlistSelectionsChanged() because the + // selected mods are gone + // - it calls setOverwriteMarkers(), which calls notifyChange() again and + // ends up here + // - dataChanged() is emitted again + // + // at this point, MO crashes because dataChanged() is not reentrant: it's in + // the middle of modifying internal data and crashes when trying to change an + // internal vector + // + // long story short, this prevents reentrancy + if (m_InNotifyChange) { + return; + } + + m_InNotifyChange = true; + Guard const g([&] { + m_InNotifyChange = false; + }); + + if (rowStart < 0) { + beginResetModel(); + endResetModel(); + } else { + if (rowEnd == -1) { + rowEnd = rowStart; + } + emit dataChanged(this->index(rowStart, 0), + this->index(rowEnd, this->columnCount() - 1)); + } +} + +QModelIndex ModList::index(int row, int column, const QModelIndex&) const +{ + if ((row < 0) || (row >= rowCount()) || (column < 0) || (column >= columnCount())) { + return {}; + } + QModelIndex res = createIndex(row, column, row); + return res; +} + +QModelIndex ModList::parent(const QModelIndex&) const +{ + return {}; +} + +QMap ModList::itemData(const QModelIndex& index) const +{ + QMap result = QAbstractItemModel::itemData(index); + for (int role = Qt::UserRole; role < ModUserRole; ++role) { + result[role] = data(index, role); + } + return result; +} + +QString ModList::getColumnName(int column) +{ + switch (column) { + case COL_CONFLICTFLAGS: + return tr("Conflicts"); + case COL_FLAGS: + return tr("Flags"); + case COL_CONTENT: + return tr("Content"); + case COL_NAME: + return tr("Mod Name"); + case COL_VERSION: + return tr("Version"); + case COL_PRIORITY: + return tr("Priority"); + case COL_CATEGORY: + return tr("Category"); + case COL_AUTHOR: + return tr("Author"); + case COL_UPLOADER: + return tr("Uploader"); + case COL_GAME: + return tr("Source Game"); + case COL_MODID: + return tr("Nexus ID"); + case COL_INSTALLTIME: + return tr("Installation"); + case COL_NOTES: + return tr("Notes"); + default: + return tr("unknown"); + } +} + +QString ModList::getColumnToolTip(int column) const +{ + switch (column) { + case COL_NAME: + return tr("Name of your mods"); + case COL_VERSION: + return tr("Version of the mod (if available)"); + case COL_PRIORITY: + return tr("Installation priority of your mod. The higher, the more \"important\" " + "it is and thus " + "overwrites files from mods with lower priority."); + case COL_CATEGORY: + return tr("Primary category of the mod."); + case COL_AUTHOR: + return tr("Author(s) of the mod."); + case COL_UPLOADER: + return tr("Uploader of the mod. This is not necessarily the same as the author."); + case COL_GAME: + return tr("The source game which was the origin of this mod."); + case COL_MODID: + return tr("Id of the mod as used on Nexus."); + case COL_CONFLICTFLAGS: + return tr("Indicators of file conflicts between mods."); + case COL_FLAGS: + return tr("Emblems to highlight things that might require attention."); + case COL_CONTENT: { + const auto& contents = m_Organizer->modDataContents(); + if (m_Organizer->modDataContents().empty()) { + return {}; + } + QString result = + tr("Depicts the content of the mod:") + "
    " + ""; + m_Organizer->modDataContents().forEachContent([&result](auto const& content) { + result += QString("") + .arg(content.icon()) + .arg(content.name()); + }); + return result + "
    %2
    "; + }; + case COL_INSTALLTIME: + return tr("Time this mod was installed"); + case COL_NOTES: + return tr("User notes about the mod"); + default: + return tr("unknown"); + } +} + +void ModList::shiftModsPriority(const QModelIndexList& indices, int offset) +{ + // retrieve the mod index and sort them by priority to avoid issue + // when moving them + std::vector allIndex; + for (const auto& idx : indices) { + auto index = idx.data(IndexRole).toInt(); + allIndex.push_back(index); + } + std::sort(allIndex.begin(), allIndex.end(), [=, this](int lhs, int rhs) { + bool const cmp = m_Profile->getModPriority(lhs) < m_Profile->getModPriority(rhs); + return offset > 0 ? !cmp : cmp; + }); + + emit layoutAboutToBeChanged(); + + std::vector notify; + for (auto index : allIndex) { + int newPriority = m_Profile->getModPriority(index) + offset; + if (m_Profile->setModPriority(index, newPriority)) { + notify.push_back(index); + } + } + + emit layoutChanged(); + + for (auto index : notify) { + notifyChange(index); + } + + emit modPrioritiesChanged(indices); +} + +void ModList::changeModsPriority(const QModelIndexList& indices, int priority) +{ + if (indices.isEmpty()) { + return; + } + + std::vector allIndex; + for (const auto& idx : indices) { + auto index = idx.data(IndexRole).toInt(); + allIndex.push_back(index); + } + + if (allIndex.size() == 1) { + changeModPriority(allIndex[0], priority); + } else { + changeModPriority(allIndex, priority); + } +} + +bool ModList::toggleState(const QModelIndexList& indices) +{ + emit aboutToChangeData(); + + QList modsToEnable; + QList modsToDisable; + for (auto index : indices) { + auto idx = index.data(IndexRole).toInt(); + if (m_Profile->modEnabled(idx)) { + modsToDisable.append(idx); + } else { + modsToEnable.append(idx); + } + } + + m_Profile->setModsEnabled(modsToEnable, modsToDisable); + + emit tutorialModlistUpdate(); + + m_Modified = true; + m_LastCheck.restart(); + + emit dataChanged(index(0, 0), index(rowCount() - 1, columnCount() - 1)); + + emit postDataChanged(); + + return true; +} + +void ModList::setActive(const QModelIndexList& indices, bool active) +{ + QList mods; + for (const auto& index : indices) { + mods.append(index.data(IndexRole).toInt()); + } + + if (active) { + m_Profile->setModsEnabled(mods, {}); + } else { + m_Profile->setModsEnabled({}, mods); + } +} diff --git a/src/src/modlistbypriorityproxy.cpp b/src/src/modlistbypriorityproxy.cpp index d70e88a..f2cd9d6 100644 --- a/src/src/modlistbypriorityproxy.cpp +++ b/src/src/modlistbypriorityproxy.cpp @@ -1,416 +1,418 @@ -#include "modlistbypriorityproxy.h" - -#include "log.h" -#include "modinfo.h" -#include "modlist.h" -#include "modlistdropinfo.h" -#include "organizercore.h" -#include "profile.h" - -ModListByPriorityProxy::ModListByPriorityProxy(Profile* profile, OrganizerCore& core, - QObject* parent) - : QAbstractProxyModel(parent), m_core(core), m_profile(profile) -{} - -ModListByPriorityProxy::~ModListByPriorityProxy() = default; - -void ModListByPriorityProxy::setSourceModel(QAbstractItemModel* model) -{ - if (sourceModel()) { - disconnect(sourceModel(), nullptr, this, nullptr); - } - - QAbstractProxyModel::setSourceModel(model); - - if (sourceModel()) { - connect(sourceModel(), &QAbstractItemModel::layoutChanged, this, - &ModListByPriorityProxy::onModelLayoutChanged, Qt::UniqueConnection); - connect(sourceModel(), &QAbstractItemModel::rowsRemoved, this, - &ModListByPriorityProxy::onModelRowsRemoved, Qt::UniqueConnection); - connect(sourceModel(), &QAbstractItemModel::modelReset, this, - &ModListByPriorityProxy::onModelReset, Qt::UniqueConnection); - connect(sourceModel(), &QAbstractItemModel::dataChanged, this, - &ModListByPriorityProxy::onModelDataChanged, Qt::UniqueConnection); - - onModelReset(); - } -} - -void ModListByPriorityProxy::setProfile(Profile* profile) -{ - m_profile = profile; -} - -void ModListByPriorityProxy::setSortOrder(Qt::SortOrder order) -{ - if (m_sortOrder != order) { - m_sortOrder = order; - onModelLayoutChanged(); - } -} - -void ModListByPriorityProxy::buildMapping() -{ - m_IndexToItem.clear(); - for (unsigned int index = 0; index < ModInfo::getNumMods(); ++index) { - m_IndexToItem[index] = - std::make_unique(ModInfo::getByIndex(index), index); - } -} - -void ModListByPriorityProxy::buildTree() -{ - if (!sourceModel()) - return; - - // reset the root - m_Root = {}; - - // clear all children - for (auto& [index, item] : m_IndexToItem) { - item->children.clear(); - } - - TreeItem* root = &m_Root; - TreeItem* overwrite; - std::vector backups; - - auto fn = [&](const auto& p) { - auto& [priority, index] = p; - ModInfo::Ptr const modInfo = ModInfo::getByIndex(index); - TreeItem* item = m_IndexToItem[index].get(); - - if (modInfo->isSeparator()) { - item->parent = &m_Root; - m_Root.children.push_back(item); - root = item; - } else if (modInfo->isOverwrite()) { - // do not push here, because the overwrite is usually not at the right position - item->parent = &m_Root; - overwrite = item; - } else if (modInfo->isBackup()) { - // do not push here, because backups are usually not at the right position - item->parent = &m_Root; - backups.push_back(item); - } else { - item->parent = root; - root->children.push_back(item); - } - }; - - auto& ibp = m_profile->getAllIndexesByPriority(); - if (m_sortOrder == Qt::AscendingOrder) { - std::for_each(ibp.begin(), ibp.end(), fn); - m_Root.children.insert(m_Root.children.begin(), - std::make_move_iterator(backups.begin()), - std::make_move_iterator(backups.end())); - m_Root.children.push_back(std::move(overwrite)); - } else { - std::for_each(ibp.rbegin(), ibp.rend(), fn); - m_Root.children.insert(m_Root.children.begin(), std::move(overwrite)); - m_Root.children.insert(m_Root.children.end(), - std::make_move_iterator(backups.begin()), - std::make_move_iterator(backups.end())); - } -} - -void ModListByPriorityProxy::onModelRowsRemoved(const QModelIndex& parent, int first, - int last) -{ - onModelReset(); -} - -void ModListByPriorityProxy::onModelLayoutChanged(const QList&, - LayoutChangeHint hint) -{ - emit layoutAboutToBeChanged(); - auto persistent = persistentIndexList(); - buildTree(); - - QModelIndexList toPersistent; - for (auto& idx : persistent) { - // we can still access the TreeItem* because we did not destroy them - auto* item = static_cast(idx.internalPointer()); - toPersistent.append( - createIndex(item->parent->childIndex(item), idx.column(), item)); - } - changePersistentIndexList(persistent, toPersistent); - - emit layoutChanged({}, hint); -} - -void ModListByPriorityProxy::onModelReset() -{ - beginResetModel(); - buildMapping(); - buildTree(); - endResetModel(); -} - -void ModListByPriorityProxy::onModelDataChanged(const QModelIndex& topLeft, - const QModelIndex& bottomRight, - const QVector& roles) -{ - QModelIndex const proxyTopLeft = mapFromSource(topLeft); - if (!proxyTopLeft.isValid()) { - return; - } - - if (topLeft == bottomRight) { - emit dataChanged(proxyTopLeft, proxyTopLeft); - } else { - QModelIndex const proxyBottomRight = mapFromSource(bottomRight); - emit dataChanged(proxyTopLeft, proxyBottomRight); - } -} - -QModelIndex ModListByPriorityProxy::mapFromSource(const QModelIndex& sourceIndex) const -{ - if (!sourceIndex.isValid()) { - return {}; - } - - auto* item = m_IndexToItem.at(sourceIndex.row()).get(); - return createIndex(item->parent->childIndex(item), sourceIndex.column(), item); -} - -QModelIndex ModListByPriorityProxy::mapToSource(const QModelIndex& proxyIndex) const -{ - if (!proxyIndex.isValid()) { - return {}; - } - auto* item = static_cast(proxyIndex.internalPointer()); - - return sourceModel()->index(item->index, proxyIndex.column()); -} - -int ModListByPriorityProxy::rowCount(const QModelIndex& parent) const -{ - if (!parent.isValid()) { - return m_Root.children.size(); - } - - auto* item = static_cast(parent.internalPointer()); - - if (item->mod->isSeparator()) { - return item->children.size(); - } - - return 0; -} - -int ModListByPriorityProxy::columnCount(const QModelIndex& index) const -{ - return sourceModel()->columnCount(mapToSource(index)); -} - -QModelIndex ModListByPriorityProxy::parent(const QModelIndex& child) const -{ - if (!child.isValid()) { - return {}; - } - - auto* item = static_cast(child.internalPointer()); - - if (!item->parent || item->parent == &m_Root) { - return {}; - } - - return createIndex(item->parent->parent->childIndex(item->parent), 0, item->parent); -} - -bool ModListByPriorityProxy::hasChildren(const QModelIndex& parent) const -{ - if (!parent.isValid()) { - return !m_Root.children.empty(); - } - auto* item = static_cast(parent.internalPointer()); - return !item->children.empty(); -} - -bool ModListByPriorityProxy::canDropMimeData(const QMimeData* data, - Qt::DropAction action, int row, int column, - const QModelIndex& parent) const -{ - ModListDropInfo const dropInfo(data, m_core); - - if (!dropInfo.isValid() || dropInfo.isLocalFileDrop()) { - return QAbstractProxyModel::canDropMimeData(data, action, row, column, parent); - } - - if (dropInfo.isModDrop()) { - bool hasSeparator = false; - unsigned int firstRowIndex = -1; - - int firstRowPriority = Profile::MaximumPriority; - for (auto sourceRow : dropInfo.rows()) { - hasSeparator = hasSeparator || ModInfo::getByIndex(sourceRow)->isSeparator(); - if (m_sortOrder == Qt::AscendingOrder && - m_profile->getModPriority(sourceRow) < firstRowPriority || - m_sortOrder == Qt::DescendingOrder && - m_profile->getModPriority(sourceRow) > firstRowPriority) { - firstRowIndex = sourceRow; - firstRowPriority = m_profile->getModPriority(sourceRow); - } - } - - bool const firstRowSeparator = - firstRowIndex != -1 && ModInfo::getByIndex(firstRowIndex)->isSeparator(); - - // row = -1 and valid parent means we're dropping onto an item, we don't want to - // drop separators onto items or items into their own separator - if (row == -1 && parent.isValid()) { - auto* parentItem = static_cast(parent.internalPointer()); - if (hasSeparator) { - return !parentItem->mod->isSeparator(); - } - - for (auto row : dropInfo.rows()) { - auto it = m_IndexToItem.find(row); - if (it != m_IndexToItem.end() && it->second->parent == parentItem) { - return false; - } - } - } - - // first row is a separator, we can drop it anywhere - if (firstRowSeparator) { - return QAbstractProxyModel::canDropMimeData(data, action, row, column, parent); - } - } - - // the row may be outside of the children list if we insert at the end - if (!parent.isValid() && row >= m_Root.children.size()) { - return false; - } - - return QAbstractProxyModel::canDropMimeData(data, action, row, column, parent); -} - -bool ModListByPriorityProxy::dropMimeData(const QMimeData* data, Qt::DropAction action, - int row, int column, - const QModelIndex& parent) -{ - // we need to fix the source model row if we are dropping at a - // given priority (not a local file) - const ModListDropInfo dropInfo(data, m_core); - int sourceRow = -1; - - if (dropInfo.isLocalFileDrop()) { - if (parent.isValid()) { - sourceRow = static_cast(parent.internalPointer())->index; - } - } else { - - if (row >= 0) { - if (!parent.isValid()) { - if (row < m_Root.children.size()) { - +#include "modlistbypriorityproxy.h" + +#include + +#include "log.h" +#include "modinfo.h" +#include "modlist.h" +#include "modlistdropinfo.h" +#include "organizercore.h" +#include "profile.h" + +ModListByPriorityProxy::ModListByPriorityProxy(Profile* profile, OrganizerCore& core, + QObject* parent) + : QAbstractProxyModel(parent), m_core(core), m_profile(profile) +{} + +ModListByPriorityProxy::~ModListByPriorityProxy() = default; + +void ModListByPriorityProxy::setSourceModel(QAbstractItemModel* model) +{ + if (sourceModel()) { + disconnect(sourceModel(), nullptr, this, nullptr); + } + + QAbstractProxyModel::setSourceModel(model); + + if (sourceModel()) { + connect(sourceModel(), &QAbstractItemModel::layoutChanged, this, + &ModListByPriorityProxy::onModelLayoutChanged, Qt::UniqueConnection); + connect(sourceModel(), &QAbstractItemModel::rowsRemoved, this, + &ModListByPriorityProxy::onModelRowsRemoved, Qt::UniqueConnection); + connect(sourceModel(), &QAbstractItemModel::modelReset, this, + &ModListByPriorityProxy::onModelReset, Qt::UniqueConnection); + connect(sourceModel(), &QAbstractItemModel::dataChanged, this, + &ModListByPriorityProxy::onModelDataChanged, Qt::UniqueConnection); + + onModelReset(); + } +} + +void ModListByPriorityProxy::setProfile(Profile* profile) +{ + m_profile = profile; +} + +void ModListByPriorityProxy::setSortOrder(Qt::SortOrder order) +{ + if (m_sortOrder != order) { + m_sortOrder = order; + onModelLayoutChanged(); + } +} + +void ModListByPriorityProxy::buildMapping() +{ + m_IndexToItem.clear(); + for (unsigned int index = 0; index < ModInfo::getNumMods(); ++index) { + m_IndexToItem[index] = + std::make_unique(ModInfo::getByIndex(index), index); + } +} + +void ModListByPriorityProxy::buildTree() +{ + if (!sourceModel()) + return; + + // reset the root + m_Root = {}; + + // clear all children + for (auto& [index, item] : m_IndexToItem) { + item->children.clear(); + } + + TreeItem* root = &m_Root; + TreeItem* overwrite; + std::vector backups; + + auto fn = [&](const auto& p) { + auto& [priority, index] = p; + ModInfo::Ptr const modInfo = ModInfo::getByIndex(index); + TreeItem* item = m_IndexToItem[index].get(); + + if (modInfo->isSeparator()) { + item->parent = &m_Root; + m_Root.children.push_back(item); + root = item; + } else if (modInfo->isOverwrite()) { + // do not push here, because the overwrite is usually not at the right position + item->parent = &m_Root; + overwrite = item; + } else if (modInfo->isBackup()) { + // do not push here, because backups are usually not at the right position + item->parent = &m_Root; + backups.push_back(item); + } else { + item->parent = root; + root->children.push_back(item); + } + }; + + const auto& ibp = m_profile->getAllIndexesByPriority(); + if (m_sortOrder == Qt::AscendingOrder) { + std::for_each(ibp.begin(), ibp.end(), fn); + m_Root.children.insert(m_Root.children.begin(), + std::make_move_iterator(backups.begin()), + std::make_move_iterator(backups.end())); + m_Root.children.push_back(std::move(overwrite)); + } else { + std::for_each(ibp.rbegin(), ibp.rend(), fn); + m_Root.children.insert(m_Root.children.begin(), std::move(overwrite)); + m_Root.children.insert(m_Root.children.end(), + std::make_move_iterator(backups.begin()), + std::make_move_iterator(backups.end())); + } +} + +void ModListByPriorityProxy::onModelRowsRemoved(const QModelIndex& parent, int first, + int last) +{ + onModelReset(); +} + +void ModListByPriorityProxy::onModelLayoutChanged(const QList&, + LayoutChangeHint hint) +{ + emit layoutAboutToBeChanged(); + auto persistent = persistentIndexList(); + buildTree(); + + QModelIndexList toPersistent; + for (auto& idx : persistent) { + // we can still access the TreeItem* because we did not destroy them + auto* item = static_cast(idx.internalPointer()); + toPersistent.append( + createIndex(item->parent->childIndex(item), idx.column(), item)); + } + changePersistentIndexList(persistent, toPersistent); + + emit layoutChanged({}, hint); +} + +void ModListByPriorityProxy::onModelReset() +{ + beginResetModel(); + buildMapping(); + buildTree(); + endResetModel(); +} + +void ModListByPriorityProxy::onModelDataChanged(const QModelIndex& topLeft, + const QModelIndex& bottomRight, + const QVector& roles) +{ + QModelIndex const proxyTopLeft = mapFromSource(topLeft); + if (!proxyTopLeft.isValid()) { + return; + } + + if (topLeft == bottomRight) { + emit dataChanged(proxyTopLeft, proxyTopLeft); + } else { + QModelIndex const proxyBottomRight = mapFromSource(bottomRight); + emit dataChanged(proxyTopLeft, proxyBottomRight); + } +} + +QModelIndex ModListByPriorityProxy::mapFromSource(const QModelIndex& sourceIndex) const +{ + if (!sourceIndex.isValid()) { + return {}; + } + + auto* item = m_IndexToItem.at(sourceIndex.row()).get(); + return createIndex(item->parent->childIndex(item), sourceIndex.column(), item); +} + +QModelIndex ModListByPriorityProxy::mapToSource(const QModelIndex& proxyIndex) const +{ + if (!proxyIndex.isValid()) { + return {}; + } + auto* item = static_cast(proxyIndex.internalPointer()); + + return sourceModel()->index(item->index, proxyIndex.column()); +} + +int ModListByPriorityProxy::rowCount(const QModelIndex& parent) const +{ + if (!parent.isValid()) { + return m_Root.children.size(); + } + + auto* item = static_cast(parent.internalPointer()); + + if (item->mod->isSeparator()) { + return item->children.size(); + } + + return 0; +} + +int ModListByPriorityProxy::columnCount(const QModelIndex& index) const +{ + return sourceModel()->columnCount(mapToSource(index)); +} + +QModelIndex ModListByPriorityProxy::parent(const QModelIndex& child) const +{ + if (!child.isValid()) { + return {}; + } + + auto* item = static_cast(child.internalPointer()); + + if (!item->parent || item->parent == &m_Root) { + return {}; + } + + return createIndex(item->parent->parent->childIndex(item->parent), 0, item->parent); +} + +bool ModListByPriorityProxy::hasChildren(const QModelIndex& parent) const +{ + if (!parent.isValid()) { + return !m_Root.children.empty(); + } + auto* item = static_cast(parent.internalPointer()); + return !item->children.empty(); +} + +bool ModListByPriorityProxy::canDropMimeData(const QMimeData* data, + Qt::DropAction action, int row, int column, + const QModelIndex& parent) const +{ + ModListDropInfo const dropInfo(data, m_core); + + if (!dropInfo.isValid() || dropInfo.isLocalFileDrop()) { + return QAbstractProxyModel::canDropMimeData(data, action, row, column, parent); + } + + if (dropInfo.isModDrop()) { + bool hasSeparator = false; + unsigned int firstRowIndex = -1; + + int firstRowPriority = Profile::MaximumPriority; + for (auto sourceRow : dropInfo.rows()) { + hasSeparator = hasSeparator || ModInfo::getByIndex(sourceRow)->isSeparator(); + if (m_sortOrder == Qt::AscendingOrder && + m_profile->getModPriority(sourceRow) < firstRowPriority || + m_sortOrder == Qt::DescendingOrder && + m_profile->getModPriority(sourceRow) > firstRowPriority) { + firstRowIndex = sourceRow; + firstRowPriority = m_profile->getModPriority(sourceRow); + } + } + + bool const firstRowSeparator = + std::cmp_not_equal(firstRowIndex , -1) && ModInfo::getByIndex(firstRowIndex)->isSeparator(); + + // row = -1 and valid parent means we're dropping onto an item, we don't want to + // drop separators onto items or items into their own separator + if (row == -1 && parent.isValid()) { + auto* parentItem = static_cast(parent.internalPointer()); + if (hasSeparator) { + return !parentItem->mod->isSeparator(); + } + + for (auto row : dropInfo.rows()) { + auto it = m_IndexToItem.find(row); + if (it != m_IndexToItem.end() && it->second->parent == parentItem) { + return false; + } + } + } + + // first row is a separator, we can drop it anywhere + if (firstRowSeparator) { + return QAbstractProxyModel::canDropMimeData(data, action, row, column, parent); + } + } + + // the row may be outside of the children list if we insert at the end + if (!parent.isValid() && row >= m_Root.children.size()) { + return false; + } + + return QAbstractProxyModel::canDropMimeData(data, action, row, column, parent); +} + +bool ModListByPriorityProxy::dropMimeData(const QMimeData* data, Qt::DropAction action, + int row, int column, + const QModelIndex& parent) +{ + // we need to fix the source model row if we are dropping at a + // given priority (not a local file) + const ModListDropInfo dropInfo(data, m_core); + int sourceRow = -1; + + if (dropInfo.isLocalFileDrop()) { + if (parent.isValid()) { + sourceRow = static_cast(parent.internalPointer())->index; + } + } else { + + if (row >= 0) { + if (!parent.isValid()) { + if (row < m_Root.children.size()) { + if (m_sortOrder == Qt::AscendingOrder) { sourceRow = m_Root.children[row]->index; - - // fix bug when dropping a mod just below an expanded separator - // - // by default, Qt consider it's a drop at the end of that separator - // but we want to make it a drop at the beginning - if (row > 0 && m_sortOrder == Qt::AscendingOrder && - m_Root.children[row - 1]->mod->isSeparator() && - !m_Root.children[row - 1]->children.empty() && m_dropExpanded && - m_dropPosition == ModListView::DropPosition::BelowItem) { - sourceRow = m_Root.children[row - 1]->children[0]->index; - } + + // fix bug when dropping a mod just below an expanded separator + // + // by default, Qt consider it's a drop at the end of that separator + // but we want to make it a drop at the beginning + if (row > 0 && m_sortOrder == Qt::AscendingOrder && + m_Root.children[row - 1]->mod->isSeparator() && + !m_Root.children[row - 1]->children.empty() && m_dropExpanded && + m_dropPosition == ModListView::DropPosition::BelowItem) { + sourceRow = m_Root.children[row - 1]->children[0]->index; + } } else { sourceRow = (row == 0) ? m_Root.children[0]->index : m_Root.children[row - 1]->index; - - // fix drop below a collapsed separator or at the end of an expanded - // separator, above the next item - if (row > 0 && m_sortOrder == Qt::DescendingOrder && - m_Root.children[row - 1]->mod->isSeparator() && - !m_Root.children[row - 1]->children.empty() && - (!m_dropExpanded || - m_dropPosition == ModListView::DropPosition::AboveItem)) { - sourceRow = m_Root.children[row - 1]->children.back()->index; - } - } - } else { - sourceRow = ModInfo::getNumMods(); - } - } - - // the parent is valid, we are dropping in a separator - else { - auto* item = static_cast(parent.internalPointer()); - - // we usually need to decrement the row in descending priority, but if - // it's the first row, we need to go back to the separator itself - if (m_sortOrder == Qt::DescendingOrder && row == 0 && - m_dropPosition == ModListView::DropPosition::AboveItem) { - sourceRow = item->index; - } else { - - // in descending priority, we decrement the row to fix the drop position - // because this is not done by the sort proxy for us - if (m_sortOrder == Qt::DescendingOrder) { - row--; - } - - if (row < item->children.size()) { - sourceRow = item->children[row]->index; - } else if (parent.row() + 1 < m_Root.children.size()) { - sourceRow = m_Root.children[parent.row() + 1]->index; - } - } - } - } - - // row < 0 and valid parent means we are dropping into an item, - // which can only be a separator since dropping into non-separators - // is disabled - // - // we want to drop at the end of the separator, so we need to find - // the right priority - else if (parent.isValid()) { - - // in ascending priority, we take the priority of the next top-level - // item, which can be a separator or the overwrite mod, but is guaranteed - // to exist - if (m_sortOrder == Qt::AscendingOrder) { - sourceRow = m_Root.children[parent.row() + 1]->index; - } - - // in descending priority, we take the separator itself if it's empty or - // its last children - else { - auto* item = m_Root.children[parent.row()]; - sourceRow = item->children.empty() ? item->index : item->children.back()->index; - } - } - } - - return sourceModel()->dropMimeData(data, action, sourceRow, column, QModelIndex()); -} - -QModelIndex ModListByPriorityProxy::index(int row, int column, - const QModelIndex& parent) const -{ - if (!hasIndex(row, column, parent)) { - return {}; - } - - const TreeItem* parentItem; - if (!parent.isValid()) { - parentItem = &m_Root; - } else { - parentItem = static_cast(parent.internalPointer()); - } - - return createIndex(row, column, parentItem->children[row]); -} - -void ModListByPriorityProxy::onDropEnter(const QMimeData*, bool dropExpanded, - ModListView::DropPosition dropPosition) -{ - m_dropExpanded = dropExpanded; - m_dropPosition = dropPosition; -} + + // fix drop below a collapsed separator or at the end of an expanded + // separator, above the next item + if (row > 0 && m_sortOrder == Qt::DescendingOrder && + m_Root.children[row - 1]->mod->isSeparator() && + !m_Root.children[row - 1]->children.empty() && + (!m_dropExpanded || + m_dropPosition == ModListView::DropPosition::AboveItem)) { + sourceRow = m_Root.children[row - 1]->children.back()->index; + } + } + } else { + sourceRow = ModInfo::getNumMods(); + } + } + + // the parent is valid, we are dropping in a separator + else { + auto* item = static_cast(parent.internalPointer()); + + // we usually need to decrement the row in descending priority, but if + // it's the first row, we need to go back to the separator itself + if (m_sortOrder == Qt::DescendingOrder && row == 0 && + m_dropPosition == ModListView::DropPosition::AboveItem) { + sourceRow = item->index; + } else { + + // in descending priority, we decrement the row to fix the drop position + // because this is not done by the sort proxy for us + if (m_sortOrder == Qt::DescendingOrder) { + row--; + } + + if (row < item->children.size()) { + sourceRow = item->children[row]->index; + } else if (parent.row() + 1 < m_Root.children.size()) { + sourceRow = m_Root.children[parent.row() + 1]->index; + } + } + } + } + + // row < 0 and valid parent means we are dropping into an item, + // which can only be a separator since dropping into non-separators + // is disabled + // + // we want to drop at the end of the separator, so we need to find + // the right priority + else if (parent.isValid()) { + + // in ascending priority, we take the priority of the next top-level + // item, which can be a separator or the overwrite mod, but is guaranteed + // to exist + if (m_sortOrder == Qt::AscendingOrder) { + sourceRow = m_Root.children[parent.row() + 1]->index; + } + + // in descending priority, we take the separator itself if it's empty or + // its last children + else { + auto* item = m_Root.children[parent.row()]; + sourceRow = item->children.empty() ? item->index : item->children.back()->index; + } + } + } + + return sourceModel()->dropMimeData(data, action, sourceRow, column, QModelIndex()); +} + +QModelIndex ModListByPriorityProxy::index(int row, int column, + const QModelIndex& parent) const +{ + if (!hasIndex(row, column, parent)) { + return {}; + } + + const TreeItem* parentItem; + if (!parent.isValid()) { + parentItem = &m_Root; + } else { + parentItem = static_cast(parent.internalPointer()); + } + + return createIndex(row, column, parentItem->children[row]); +} + +void ModListByPriorityProxy::onDropEnter(const QMimeData*, bool dropExpanded, + ModListView::DropPosition dropPosition) +{ + m_dropExpanded = dropExpanded; + m_dropPosition = dropPosition; +} diff --git a/src/src/modlistcontextmenu.cpp b/src/src/modlistcontextmenu.cpp index 9d2d640..fbd1c47 100644 --- a/src/src/modlistcontextmenu.cpp +++ b/src/src/modlistcontextmenu.cpp @@ -1,639 +1,641 @@ -#include "modlistcontextmenu.h" - -#include - -#include "modlist.h" -#include "modlistview.h" -#include "modlistviewactions.h" -#include "organizercore.h" - -#include - -using namespace MOBase; - -ModListGlobalContextMenu::ModListGlobalContextMenu(OrganizerCore& core, - ModListView* view, QWidget* parent) - : ModListGlobalContextMenu(core, view, QModelIndex(), parent) -{} - -ModListGlobalContextMenu::ModListGlobalContextMenu(OrganizerCore& core, - ModListView* view, - const QModelIndex& index, - QWidget* parent) - : QMenu(parent) -{ - connect(this, &QMenu::aboutToShow, [=, this, &core] { - populate(core, view, index); - }); -} - -void ModListGlobalContextMenu::populate(OrganizerCore& core, ModListView* view, - const QModelIndex& index) -{ - clear(); - - auto modIndex = index.data(ModList::IndexRole); - if (modIndex.isValid() && view->sortColumn() == ModList::COL_PRIORITY) { - auto info = ModInfo::getByIndex(modIndex.toInt()); - if (!info->isBackup()) { - - // the mod are not created/installed at the same position depending - // on the clicked mod and the sort order - QString installText = tr("Install mod above... "); - QString createText = tr("Create empty mod above"); - if (info->isSeparator()) { - installText = tr("Install mod inside... "); - createText = tr("Create empty mod inside"); - } else if (view->sortOrder() == Qt::DescendingOrder) { - installText = tr("Install mod below... "); - createText = tr("Create empty mod below"); - } - - addAction(installText, [=, this]() { - view->actions().installMod("", index); - }); - addAction(createText, [=, this]() { - view->actions().createEmptyMod(index); - }); - addAction(tr("Create separator above"), [=, this]() { - view->actions().createSeparator(index); - }); - } - } else { - addAction(tr("Install mod..."), [=, this]() { - view->actions().installMod(); - }); - addAction(tr("Create empty mod"), [=, this]() { - view->actions().createEmptyMod(); - }); - addAction(tr("Create separator"), [=, this]() { - view->actions().createSeparator(); - }); - } - - if (view->hasCollapsibleSeparators()) { - addSeparator(); - addAction(tr("Collapse all"), view, &QTreeView::collapseAll); - addAction(tr("Expand all"), view, &QTreeView::expandAll); - } - - addSeparator(); - - QString enableTxt = tr("Enable all"), disableTxt = tr("Disable all"); - - if (view->isFilterActive()) { - enableTxt = tr("Enable all matching mods"); - disableTxt = tr("Disable all matching mods"); - } - - addAction(enableTxt, [=, this] { - view->actions().setAllMatchingModsEnabled(true); - }); - addAction(disableTxt, [=, this] { - view->actions().setAllMatchingModsEnabled(false); - }); - - addAction(tr("Check for updates"), [=, this]() { - view->actions().checkModsForUpdates(); - }); - addAction(tr("Auto assign categories"), [=, this]() { - view->actions().assignCategories(); - }); - addAction(tr("Refresh"), &core, &OrganizerCore::refresh); - addAction(tr("Export to csv..."), [=, this]() { - view->actions().exportModListCSV(); - }); -} - -ModListChangeCategoryMenu::ModListChangeCategoryMenu(CategoryFactory* categories, - ModInfo::Ptr mod, QMenu* parent) - : QMenu(tr("Change Categories"), parent) -{ - populate(this, categories, mod); -} - -std::vector> ModListChangeCategoryMenu::categories() const -{ - return categories(this); -} - -std::vector> -ModListChangeCategoryMenu::categories(const QMenu* menu) const -{ - std::vector> cats; - for (QAction* action : menu->actions()) { - if (action->menu() != nullptr) { - auto pcats = categories(action->menu()); - cats.insert(cats.end(), pcats.begin(), pcats.end()); - } else { - QWidgetAction* widgetAction = qobject_cast(action); - if (widgetAction != nullptr) { - QCheckBox* checkbox = qobject_cast(widgetAction->defaultWidget()); - cats.emplace_back(widgetAction->data().toInt(), checkbox->isChecked()); - } - } - } - return cats; -} - -bool ModListChangeCategoryMenu::populate(QMenu* menu, CategoryFactory* factory, - ModInfo::Ptr mod, int targetId) -{ - const std::set& categories = mod->getCategories(); - - bool childEnabled = false; - - for (unsigned int i = 1; i < factory->numCategories(); ++i) { - if (factory->getParentID(i) == targetId) { - QMenu* targetMenu = menu; - if (factory->hasChildren(i)) { - targetMenu = menu->addMenu(factory->getCategoryName(i).replace('&', "&&")); - } - - int const id = factory->getCategoryID(i); - auto checkBox = std::make_unique(targetMenu); - bool const enabled = categories.contains(id); - checkBox->setText(factory->getCategoryName(i).replace('&', "&&")); - if (enabled) { - childEnabled = true; - } - checkBox->setChecked(enabled ? Qt::Checked : Qt::Unchecked); - - auto checkableAction = std::make_unique(targetMenu); - checkableAction->setDefaultWidget(checkBox.release()); - checkableAction->setData(id); - targetMenu->addAction(checkableAction.release()); - - if (factory->hasChildren(i)) { - if (populate(targetMenu, factory, mod, factory->getCategoryID(i)) || enabled) { - targetMenu->setIcon(QIcon(":/MO/gui/resources/check.png")); - } - } - } - } - return childEnabled; -} - -ModListPrimaryCategoryMenu::ModListPrimaryCategoryMenu(CategoryFactory* categories, - ModInfo::Ptr mod, QMenu* parent) - : QMenu(tr("Primary Category"), parent) -{ - connect(this, &QMenu::aboutToShow, [=, this]() { - populate(categories, mod); - }); -} - -void ModListPrimaryCategoryMenu::populate(const CategoryFactory* factory, - ModInfo::Ptr mod) -{ - clear(); - const std::set& categories = mod->getCategories(); - for (int const categoryID : categories) { - int const catIdx = factory->getCategoryIndex(categoryID); - QWidgetAction* action = new QWidgetAction(this); - try { - QRadioButton* categoryBox = - new QRadioButton(factory->getCategoryName(catIdx).replace('&', "&&"), this); - categoryBox->setChecked(categoryID == mod->primaryCategory()); - action->setDefaultWidget(categoryBox); - action->setData(categoryID); - } catch (const std::exception& e) { - log::error("failed to create category checkbox: {}", e.what()); - } - - action->setData(categoryID); - addAction(action); - } -} - -int ModListPrimaryCategoryMenu::primaryCategory() const -{ - for (QAction* action : actions()) { - QWidgetAction* widgetAction = qobject_cast(action); - if (widgetAction) { - QRadioButton* button = qobject_cast(widgetAction->defaultWidget()); - if (button && button->isChecked()) { - return widgetAction->data().toInt(); - } - } - } - return -1; -} - -ModListContextMenu::ModListContextMenu(const QModelIndex& index, OrganizerCore& core, - CategoryFactory* categories, ModListView* view) - : QMenu(view), m_core(core), m_categories(categories), - m_index(index.model() == view->model() ? view->indexViewToModel(index) : index), - m_view(view), m_actions(view->actions()) -{ - if (view->selectionModel()->hasSelection()) { - m_selected = view->indexViewToModel(view->selectionModel()->selectedRows()); - } else { - m_selected = {index}; - } - - ModInfo::Ptr const info = ModInfo::getByIndex(index.data(ModList::IndexRole).toInt()); - - QMenu* allMods = - new ModListGlobalContextMenu(core, view, m_index, view->topLevelWidget()); - allMods->setTitle(tr("All Mods")); - addMenu(allMods); - - auto viewIndex = view->indexModelToView(m_index); - if (view->model()->hasChildren(viewIndex)) { - bool const expanded = view->isExpanded(viewIndex); - addSeparator(); - addAction(tr("Collapse all"), view, &QTreeView::collapseAll); - addAction(tr("Collapse others"), [=, this]() { - m_view->collapseAll(); - m_view->setExpanded(viewIndex, expanded); - }); - addAction(tr("Expand all"), view, &QTreeView::expandAll); - } - - addSeparator(); - - // Add type-specific items - if (info->isOverwrite()) { - addOverwriteActions(info); - } else if (info->isBackup()) { - addBackupActions(info); - } else if (info->isSeparator()) { - addSeparatorActions(info); - } else if (info->isForeign()) { - addForeignActions(info); - } else { - addRegularActions(info); - } - - // add information for all except foreign - if (!info->isForeign()) { - QAction* infoAction = addAction(tr("Information..."), [=, this]() { - view->actions().displayModInformation(m_index.data(ModList::IndexRole).toInt()); - }); - setDefaultAction(infoAction); - } -} - -void ModListContextMenu::addMenuAsPushButton(QMenu* menu) -{ - QPushButton* pushBtn = new QPushButton(menu->title()); - pushBtn->setMenu(menu); - QWidgetAction* action = new QWidgetAction(this); - action->setDefaultWidget(pushBtn); - addAction(action); -} - -void ModListContextMenu::addSendToContextMenu() -{ - static const std::vector overwritten_flags{ - ModInfo::EConflictFlag::FLAG_CONFLICT_MIXED, - ModInfo::EConflictFlag::FLAG_CONFLICT_OVERWRITTEN, - ModInfo::EConflictFlag::FLAG_CONFLICT_REDUNDANT}; - - static const std::vector overwrite_flags{ - ModInfo::EConflictFlag::FLAG_CONFLICT_MIXED, - ModInfo::EConflictFlag::FLAG_CONFLICT_OVERWRITE}; - - bool overwrite = false, overwritten = false; - for (auto& idx : m_selected) { - auto index = idx.data(ModList::IndexRole); - if (index.isValid()) { - auto info = ModInfo::getByIndex(index.toInt()); - auto flags = info->getConflictFlags(); - if (std::find_first_of(flags.begin(), flags.end(), overwritten_flags.begin(), - overwritten_flags.end()) != flags.end()) { - overwritten = true; - } - if (std::find_first_of(flags.begin(), flags.end(), overwrite_flags.begin(), - overwrite_flags.end()) != flags.end()) { - overwrite = true; - } - } - } - - QMenu* menu = new QMenu(m_view); - menu->setTitle(tr("Send to... ")); - menu->addAction(tr("Lowest priority"), [this] { - m_actions.sendModsToTop(m_selected); - }); - menu->addAction(tr("Highest priority"), [this] { - m_actions.sendModsToBottom(m_selected); - }); - menu->addAction(tr("Priority..."), [this] { - m_actions.sendModsToPriority(m_selected); - }); - menu->addAction(tr("Separator..."), [this] { - m_actions.sendModsToSeparator(m_selected); - }); - if (overwrite) { - menu->addAction(tr("First conflict"), [this] { - m_actions.sendModsToFirstConflict(m_selected); - }); - } - if (overwritten) { - menu->addAction(tr("Last conflict"), [this] { - m_actions.sendModsToLastConflict(m_selected); - }); - } - addMenu(menu); -} - -void ModListContextMenu::addCategoryContextMenus(ModInfo::Ptr mod) -{ - ModListChangeCategoryMenu* categoriesMenu = - new ModListChangeCategoryMenu(m_categories, mod, this); - connect(categoriesMenu, &QMenu::aboutToHide, [=, this]() { - m_actions.setCategories(m_selected, m_index, categoriesMenu->categories()); - }); - addMenuAsPushButton(categoriesMenu); - - ModListPrimaryCategoryMenu* primaryCategoryMenu = - new ModListPrimaryCategoryMenu(m_categories, mod, this); - connect(primaryCategoryMenu, &QMenu::aboutToHide, [=, this]() { - int const category = primaryCategoryMenu->primaryCategory(); - if (category != -1) { - m_actions.setPrimaryCategory(m_selected, category); - } - }); - addMenuAsPushButton(primaryCategoryMenu); -} - -void ModListContextMenu::addOverwriteActions(ModInfo::Ptr mod) -{ - if (QDir(mod->absolutePath()).count() > 2) { - addAction(tr("Sync to Mods..."), [=, this]() { - m_core.syncOverwrite(); - }); - addAction(tr("Create Mod..."), [=, this]() { - m_actions.createModFromOverwrite(); - }); - addAction(tr("Move content to Mod..."), [=, this]() { - m_actions.moveOverwriteContentToExistingMod(); - }); - addAction(tr("Clear Overwrite..."), [=, this]() { - m_actions.clearOverwrite(); - }); - } - addAction(tr("Open in Explorer"), [=, this]() { - m_actions.openExplorer(m_selected); - }); -} - -void ModListContextMenu::addSeparatorActions(ModInfo::Ptr mod) -{ - addCategoryContextMenus(mod); - addSeparator(); - - addAction(tr("Rename Separator..."), [=, this]() { - m_actions.renameMod(m_index); - }); - addAction(tr("Remove Separator..."), [=, this]() { - m_actions.removeMods(m_selected); - }); - addSeparator(); - - if (m_view->sortColumn() == ModList::COL_PRIORITY) { - addSendToContextMenu(); - addSeparator(); - } - addAction(tr("Select Color..."), [=, this]() { - m_actions.setColor(m_selected, m_index); - }); - - if (mod->color().isValid()) { - addAction(tr("Reset Color"), [=, this]() { - m_actions.resetColor(m_selected); - }); - } - - addSeparator(); -} - -void ModListContextMenu::addForeignActions(ModInfo::Ptr mod) -{ - if (m_view->sortColumn() == ModList::COL_PRIORITY) { - addSendToContextMenu(); - } -} - -void ModListContextMenu::addBackupActions(ModInfo::Ptr mod) -{ - auto flags = mod->getFlags(); - addAction(tr("Restore Backup"), [=, this]() { - m_actions.restoreBackup(m_index); - }); - addAction(tr("Remove Backup..."), [=, this]() { - m_actions.removeMods(m_selected); - }); - addSeparator(); - if (std::find(flags.begin(), flags.end(), ModInfo::FLAG_INVALID) != flags.end()) { - addAction(tr("Ignore missing data"), [=, this]() { - m_actions.ignoreMissingData(m_selected); - }); - } - if (std::find(flags.begin(), flags.end(), ModInfo::FLAG_ALTERNATE_GAME) != - flags.end()) { - addAction(tr("Mark as converted/working"), [=, this]() { - m_actions.markConverted(m_selected); - }); - } - addSeparator(); - if (mod->nexusId() > 0) { - addAction(tr("Visit on Nexus"), [=, this]() { - m_actions.visitOnNexus(m_selected); - }); - } - - if (!mod->uploaderUrl().isEmpty()) { - addAction(tr("Visit the uploader's profile"), [=, this]() { - m_actions.visitUploaderProfile(m_selected); - }); - } - - const auto url = mod->parseCustomURL(); - if (url.isValid()) { - addAction(tr("Visit on %1").arg(url.host()), [=, this]() { - m_actions.visitWebPage(m_selected); - }); - } - - addAction(tr("Open in Explorer"), [=, this]() { - m_actions.openExplorer(m_selected); - }); -} - -void ModListContextMenu::addRegularActions(ModInfo::Ptr mod) -{ - auto flags = mod->getFlags(); - - addCategoryContextMenus(mod); - addSeparator(); - - if (mod->downgradeAvailable()) { - addAction(tr("Change versioning scheme"), [=, this]() { - m_actions.changeVersioningScheme(m_index); - }); - } - - if (mod->nexusId() > 0) - addAction(tr("Force-check updates"), [=, this]() { - m_actions.checkModsForUpdates(m_selected); - }); - if (mod->updateIgnored()) { - addAction(tr("Un-ignore update"), [=, this]() { - m_actions.setIgnoreUpdate(m_selected, false); - }); - } else { - if (mod->updateAvailable() || mod->downgradeAvailable()) { - addAction(tr("Ignore update"), [=, this]() { - m_actions.setIgnoreUpdate(m_selected, true); - }); - } - } - addSeparator(); - - addAction(tr("Enable selected"), [=, this]() { - m_core.modList()->setActive(m_selected, true); - }); - addAction(tr("Disable selected"), [=, this]() { - m_core.modList()->setActive(m_selected, false); - }); - - addSeparator(); - - if (m_view->sortColumn() == ModList::COL_PRIORITY) { - addSendToContextMenu(); - addSeparator(); - } - - addAction(tr("Rename Mod..."), [=, this]() { - m_actions.renameMod(m_index); - }); - addAction(tr("Reinstall Mod"), [=, this]() { - m_actions.reinstallMod(m_index); - }); - addAction(tr("Remove Mod..."), [=, this]() { - m_actions.removeMods(m_selected); - }); - addAction(tr("Create Backup"), [=, this]() { - m_actions.createBackup(m_index); - }); - - if (std::find(flags.begin(), flags.end(), ModInfo::FLAG_HIDDEN_FILES) != - flags.end()) { - addAction(tr("Restore hidden files"), [=, this]() { - m_actions.restoreHiddenFiles(m_selected); - }); - } - - addSeparator(); - - if (m_index.column() == ModList::COL_NOTES) { - addAction(tr("Select Color..."), [=, this]() { - m_actions.setColor(m_selected, m_index); - }); - if (mod->color().isValid()) { - addAction(tr("Reset Color"), [=, this]() { - m_actions.resetColor(m_selected); - }); - } - addSeparator(); - } - - if (mod->nexusId() > 0 && Settings::instance().nexus().endorsementIntegration()) { - switch (mod->endorsedState()) { - case EndorsedState::ENDORSED_TRUE: { - addAction(tr("Un-Endorse"), [=, this]() { - m_actions.setEndorsed(m_selected, false); - }); - } break; - case EndorsedState::ENDORSED_FALSE: { - addAction(tr("Endorse"), [=, this]() { - m_actions.setEndorsed(m_selected, true); - }); - addAction(tr("Won't endorse"), [=, this]() { - m_actions.willNotEndorsed(m_selected); - }); - } break; - case EndorsedState::ENDORSED_NEVER: { - addAction(tr("Endorse"), [=, this]() { - m_actions.setEndorsed(m_selected, true); - }); - } break; - default: { - QAction* action = new QAction(tr("Endorsement state unknown"), this); - action->setEnabled(false); - addAction(action); - } break; - } - } - - if (mod->nexusId() > 0 && - (mod->getNexusCategory() > 0 || !mod->installationFile().isEmpty()) && - !mod->isSeparator()) { - addAction(tr("Remap Category (From Nexus)"), [=, this]() { - m_actions.remapCategory(m_selected); - }); - } - - if (mod->nexusId() > 0 && Settings::instance().nexus().trackedIntegration()) { - switch (mod->trackedState()) { - case TrackedState::TRACKED_FALSE: { - addAction(tr("Start tracking"), [=, this]() { - m_actions.setTracked(m_selected, true); - }); - } break; - case TrackedState::TRACKED_TRUE: { - addAction(tr("Stop tracking"), [=, this]() { - m_actions.setTracked(m_selected, false); - }); - } break; - default: { - QAction* action = new QAction(tr("Tracked state unknown"), this); - action->setEnabled(false); - addAction(action); - } break; - } - } - - addSeparator(); - - if (std::find(flags.begin(), flags.end(), ModInfo::FLAG_INVALID) != flags.end()) { - addAction(tr("Ignore missing data"), [=, this]() { - m_actions.ignoreMissingData(m_selected); - }); - } - - if (std::find(flags.begin(), flags.end(), ModInfo::FLAG_ALTERNATE_GAME) != - flags.end()) { - addAction(tr("Mark as converted/working"), [=, this]() { - m_actions.markConverted(m_selected); - }); - } - - addSeparator(); - - if (mod->nexusId() > 0) { - addAction(tr("Visit on Nexus"), [=, this]() { - m_actions.visitOnNexus(m_selected); - }); - } - - if (!mod->uploaderUrl().isEmpty()) { - addAction(tr("Visit the uploader's profile"), [=, this]() { - m_actions.visitUploaderProfile(m_selected); - }); - } - - const auto url = mod->parseCustomURL(); - if (url.isValid()) { - addAction(tr("Visit on %1").arg(url.host()), [=, this]() { - m_actions.visitWebPage(m_selected); - }); - } - - addAction(tr("Open in Explorer"), [=, this]() { - m_actions.openExplorer(m_selected); - }); -} +#include "modlistcontextmenu.h" + +#include + +#include "modlist.h" +#include "modlistview.h" +#include "modlistviewactions.h" +#include "organizercore.h" + +#include + +using namespace MOBase; + +ModListGlobalContextMenu::ModListGlobalContextMenu(OrganizerCore& core, + ModListView* view, QWidget* parent) + : ModListGlobalContextMenu(core, view, QModelIndex(), parent) +{} + +ModListGlobalContextMenu::ModListGlobalContextMenu(OrganizerCore& core, + ModListView* view, + const QModelIndex& index, + QWidget* parent) + : QMenu(parent) +{ + connect(this, &QMenu::aboutToShow, [=, this, &core] { + populate(core, view, index); + }); +} + +void ModListGlobalContextMenu::populate(OrganizerCore& core, ModListView* view, + const QModelIndex& index) +{ + clear(); + + auto modIndex = index.data(ModList::IndexRole); + if (modIndex.isValid() && view->sortColumn() == ModList::COL_PRIORITY) { + auto info = ModInfo::getByIndex(modIndex.toInt()); + if (!info->isBackup()) { + + // the mod are not created/installed at the same position depending + // on the clicked mod and the sort order + QString installText = tr("Install mod above... "); + QString createText = tr("Create empty mod above"); + if (info->isSeparator()) { + installText = tr("Install mod inside... "); + createText = tr("Create empty mod inside"); + } else if (view->sortOrder() == Qt::DescendingOrder) { + installText = tr("Install mod below... "); + createText = tr("Create empty mod below"); + } + + addAction(installText, [=, this]() { + view->actions().installMod("", index); + }); + addAction(createText, [=, this]() { + view->actions().createEmptyMod(index); + }); + addAction(tr("Create separator above"), [=, this]() { + view->actions().createSeparator(index); + }); + } + } else { + addAction(tr("Install mod..."), [=, this]() { + view->actions().installMod(); + }); + addAction(tr("Create empty mod"), [=, this]() { + view->actions().createEmptyMod(); + }); + addAction(tr("Create separator"), [=, this]() { + view->actions().createSeparator(); + }); + } + + if (view->hasCollapsibleSeparators()) { + addSeparator(); + addAction(tr("Collapse all"), view, &QTreeView::collapseAll); + addAction(tr("Expand all"), view, &QTreeView::expandAll); + } + + addSeparator(); + + QString enableTxt = tr("Enable all"); + QString disableTxt = tr("Disable all"); + + if (view->isFilterActive()) { + enableTxt = tr("Enable all matching mods"); + disableTxt = tr("Disable all matching mods"); + } + + addAction(enableTxt, [=, this] { + view->actions().setAllMatchingModsEnabled(true); + }); + addAction(disableTxt, [=, this] { + view->actions().setAllMatchingModsEnabled(false); + }); + + addAction(tr("Check for updates"), [=, this]() { + view->actions().checkModsForUpdates(); + }); + addAction(tr("Auto assign categories"), [=, this]() { + view->actions().assignCategories(); + }); + addAction(tr("Refresh"), &core, &OrganizerCore::refresh); + addAction(tr("Export to csv..."), [=, this]() { + view->actions().exportModListCSV(); + }); +} + +ModListChangeCategoryMenu::ModListChangeCategoryMenu(CategoryFactory* categories, + ModInfo::Ptr mod, QMenu* parent) + : QMenu(tr("Change Categories"), parent) +{ + populate(this, categories, mod); +} + +std::vector> ModListChangeCategoryMenu::categories() const +{ + return categories(this); +} + +std::vector> +ModListChangeCategoryMenu::categories(const QMenu* menu) const +{ + std::vector> cats; + for (QAction* action : menu->actions()) { + if (action->menu() != nullptr) { + auto pcats = categories(action->menu()); + cats.insert(cats.end(), pcats.begin(), pcats.end()); + } else { + QWidgetAction* widgetAction = qobject_cast(action); + if (widgetAction != nullptr) { + QCheckBox* checkbox = qobject_cast(widgetAction->defaultWidget()); + cats.emplace_back(widgetAction->data().toInt(), checkbox->isChecked()); + } + } + } + return cats; +} + +bool ModListChangeCategoryMenu::populate(QMenu* menu, CategoryFactory* factory, + ModInfo::Ptr mod, int targetId) +{ + const std::set& categories = mod->getCategories(); + + bool childEnabled = false; + + for (unsigned int i = 1; i < factory->numCategories(); ++i) { + if (factory->getParentID(i) == targetId) { + QMenu* targetMenu = menu; + if (factory->hasChildren(i)) { + targetMenu = menu->addMenu(factory->getCategoryName(i).replace('&', "&&")); + } + + int const id = factory->getCategoryID(i); + auto checkBox = std::make_unique(targetMenu); + bool const enabled = categories.contains(id); + checkBox->setText(factory->getCategoryName(i).replace('&', "&&")); + if (enabled) { + childEnabled = true; + } + checkBox->setChecked(enabled ? Qt::Checked : Qt::Unchecked); + + auto checkableAction = std::make_unique(targetMenu); + checkableAction->setDefaultWidget(checkBox.release()); + checkableAction->setData(id); + targetMenu->addAction(checkableAction.release()); + + if (factory->hasChildren(i)) { + if (populate(targetMenu, factory, mod, factory->getCategoryID(i)) || enabled) { + targetMenu->setIcon(QIcon(":/MO/gui/resources/check.png")); + } + } + } + } + return childEnabled; +} + +ModListPrimaryCategoryMenu::ModListPrimaryCategoryMenu(CategoryFactory* categories, + ModInfo::Ptr mod, QMenu* parent) + : QMenu(tr("Primary Category"), parent) +{ + connect(this, &QMenu::aboutToShow, [=, this]() { + populate(categories, mod); + }); +} + +void ModListPrimaryCategoryMenu::populate(const CategoryFactory* factory, + ModInfo::Ptr mod) +{ + clear(); + const std::set& categories = mod->getCategories(); + for (int const categoryID : categories) { + int const catIdx = factory->getCategoryIndex(categoryID); + QWidgetAction* action = new QWidgetAction(this); + try { + QRadioButton* categoryBox = + new QRadioButton(factory->getCategoryName(catIdx).replace('&', "&&"), this); + categoryBox->setChecked(categoryID == mod->primaryCategory()); + action->setDefaultWidget(categoryBox); + action->setData(categoryID); + } catch (const std::exception& e) { + log::error("failed to create category checkbox: {}", e.what()); + } + + action->setData(categoryID); + addAction(action); + } +} + +int ModListPrimaryCategoryMenu::primaryCategory() const +{ + for (QAction* action : actions()) { + QWidgetAction* widgetAction = qobject_cast(action); + if (widgetAction) { + QRadioButton* button = qobject_cast(widgetAction->defaultWidget()); + if (button && button->isChecked()) { + return widgetAction->data().toInt(); + } + } + } + return -1; +} + +ModListContextMenu::ModListContextMenu(const QModelIndex& index, OrganizerCore& core, + CategoryFactory* categories, ModListView* view) + : QMenu(view), m_core(core), m_categories(categories), + m_index(index.model() == view->model() ? view->indexViewToModel(index) : index), + m_view(view), m_actions(view->actions()) +{ + if (view->selectionModel()->hasSelection()) { + m_selected = view->indexViewToModel(view->selectionModel()->selectedRows()); + } else { + m_selected = {index}; + } + + ModInfo::Ptr const info = ModInfo::getByIndex(index.data(ModList::IndexRole).toInt()); + + QMenu* allMods = + new ModListGlobalContextMenu(core, view, m_index, view->topLevelWidget()); + allMods->setTitle(tr("All Mods")); + addMenu(allMods); + + auto viewIndex = view->indexModelToView(m_index); + if (view->model()->hasChildren(viewIndex)) { + bool const expanded = view->isExpanded(viewIndex); + addSeparator(); + addAction(tr("Collapse all"), view, &QTreeView::collapseAll); + addAction(tr("Collapse others"), [=, this]() { + m_view->collapseAll(); + m_view->setExpanded(viewIndex, expanded); + }); + addAction(tr("Expand all"), view, &QTreeView::expandAll); + } + + addSeparator(); + + // Add type-specific items + if (info->isOverwrite()) { + addOverwriteActions(info); + } else if (info->isBackup()) { + addBackupActions(info); + } else if (info->isSeparator()) { + addSeparatorActions(info); + } else if (info->isForeign()) { + addForeignActions(info); + } else { + addRegularActions(info); + } + + // add information for all except foreign + if (!info->isForeign()) { + QAction* infoAction = addAction(tr("Information..."), [=, this]() { + view->actions().displayModInformation(m_index.data(ModList::IndexRole).toInt()); + }); + setDefaultAction(infoAction); + } +} + +void ModListContextMenu::addMenuAsPushButton(QMenu* menu) +{ + QPushButton* pushBtn = new QPushButton(menu->title()); + pushBtn->setMenu(menu); + QWidgetAction* action = new QWidgetAction(this); + action->setDefaultWidget(pushBtn); + addAction(action); +} + +void ModListContextMenu::addSendToContextMenu() +{ + static const std::vector overwritten_flags{ + ModInfo::EConflictFlag::FLAG_CONFLICT_MIXED, + ModInfo::EConflictFlag::FLAG_CONFLICT_OVERWRITTEN, + ModInfo::EConflictFlag::FLAG_CONFLICT_REDUNDANT}; + + static const std::vector overwrite_flags{ + ModInfo::EConflictFlag::FLAG_CONFLICT_MIXED, + ModInfo::EConflictFlag::FLAG_CONFLICT_OVERWRITE}; + + bool overwrite = false; + bool overwritten = false; + for (auto& idx : m_selected) { + auto index = idx.data(ModList::IndexRole); + if (index.isValid()) { + auto info = ModInfo::getByIndex(index.toInt()); + auto flags = info->getConflictFlags(); + if (std::find_first_of(flags.begin(), flags.end(), overwritten_flags.begin(), + overwritten_flags.end()) != flags.end()) { + overwritten = true; + } + if (std::find_first_of(flags.begin(), flags.end(), overwrite_flags.begin(), + overwrite_flags.end()) != flags.end()) { + overwrite = true; + } + } + } + + QMenu* menu = new QMenu(m_view); + menu->setTitle(tr("Send to... ")); + menu->addAction(tr("Lowest priority"), [this] { + m_actions.sendModsToTop(m_selected); + }); + menu->addAction(tr("Highest priority"), [this] { + m_actions.sendModsToBottom(m_selected); + }); + menu->addAction(tr("Priority..."), [this] { + m_actions.sendModsToPriority(m_selected); + }); + menu->addAction(tr("Separator..."), [this] { + m_actions.sendModsToSeparator(m_selected); + }); + if (overwrite) { + menu->addAction(tr("First conflict"), [this] { + m_actions.sendModsToFirstConflict(m_selected); + }); + } + if (overwritten) { + menu->addAction(tr("Last conflict"), [this] { + m_actions.sendModsToLastConflict(m_selected); + }); + } + addMenu(menu); +} + +void ModListContextMenu::addCategoryContextMenus(ModInfo::Ptr mod) +{ + ModListChangeCategoryMenu* categoriesMenu = + new ModListChangeCategoryMenu(m_categories, mod, this); + connect(categoriesMenu, &QMenu::aboutToHide, [=, this]() { + m_actions.setCategories(m_selected, m_index, categoriesMenu->categories()); + }); + addMenuAsPushButton(categoriesMenu); + + ModListPrimaryCategoryMenu* primaryCategoryMenu = + new ModListPrimaryCategoryMenu(m_categories, mod, this); + connect(primaryCategoryMenu, &QMenu::aboutToHide, [=, this]() { + int const category = primaryCategoryMenu->primaryCategory(); + if (category != -1) { + m_actions.setPrimaryCategory(m_selected, category); + } + }); + addMenuAsPushButton(primaryCategoryMenu); +} + +void ModListContextMenu::addOverwriteActions(ModInfo::Ptr mod) +{ + if (QDir(mod->absolutePath()).count() > 2) { + addAction(tr("Sync to Mods..."), [=, this]() { + m_core.syncOverwrite(); + }); + addAction(tr("Create Mod..."), [=, this]() { + m_actions.createModFromOverwrite(); + }); + addAction(tr("Move content to Mod..."), [=, this]() { + m_actions.moveOverwriteContentToExistingMod(); + }); + addAction(tr("Clear Overwrite..."), [=, this]() { + m_actions.clearOverwrite(); + }); + } + addAction(tr("Open in Explorer"), [=, this]() { + ModListViewActions::openExplorer(m_selected); + }); +} + +void ModListContextMenu::addSeparatorActions(ModInfo::Ptr mod) +{ + addCategoryContextMenus(mod); + addSeparator(); + + addAction(tr("Rename Separator..."), [=, this]() { + m_actions.renameMod(m_index); + }); + addAction(tr("Remove Separator..."), [=, this]() { + m_actions.removeMods(m_selected); + }); + addSeparator(); + + if (m_view->sortColumn() == ModList::COL_PRIORITY) { + addSendToContextMenu(); + addSeparator(); + } + addAction(tr("Select Color..."), [=, this]() { + m_actions.setColor(m_selected, m_index); + }); + + if (mod->color().isValid()) { + addAction(tr("Reset Color"), [=, this]() { + m_actions.resetColor(m_selected); + }); + } + + addSeparator(); +} + +void ModListContextMenu::addForeignActions(ModInfo::Ptr mod) +{ + if (m_view->sortColumn() == ModList::COL_PRIORITY) { + addSendToContextMenu(); + } +} + +void ModListContextMenu::addBackupActions(ModInfo::Ptr mod) +{ + auto flags = mod->getFlags(); + addAction(tr("Restore Backup"), [=, this]() { + m_actions.restoreBackup(m_index); + }); + addAction(tr("Remove Backup..."), [=, this]() { + m_actions.removeMods(m_selected); + }); + addSeparator(); + if (std::find(flags.begin(), flags.end(), ModInfo::FLAG_INVALID) != flags.end()) { + addAction(tr("Ignore missing data"), [=, this]() { + m_actions.ignoreMissingData(m_selected); + }); + } + if (std::find(flags.begin(), flags.end(), ModInfo::FLAG_ALTERNATE_GAME) != + flags.end()) { + addAction(tr("Mark as converted/working"), [=, this]() { + m_actions.markConverted(m_selected); + }); + } + addSeparator(); + if (mod->nexusId() > 0) { + addAction(tr("Visit on Nexus"), [=, this]() { + m_actions.visitOnNexus(m_selected); + }); + } + + if (!mod->uploaderUrl().isEmpty()) { + addAction(tr("Visit the uploader's profile"), [=, this]() { + m_actions.visitUploaderProfile(m_selected); + }); + } + + const auto url = mod->parseCustomURL(); + if (url.isValid()) { + addAction(tr("Visit on %1").arg(url.host()), [=, this]() { + m_actions.visitWebPage(m_selected); + }); + } + + addAction(tr("Open in Explorer"), [=, this]() { + ModListViewActions::openExplorer(m_selected); + }); +} + +void ModListContextMenu::addRegularActions(ModInfo::Ptr mod) +{ + auto flags = mod->getFlags(); + + addCategoryContextMenus(mod); + addSeparator(); + + if (mod->downgradeAvailable()) { + addAction(tr("Change versioning scheme"), [=, this]() { + m_actions.changeVersioningScheme(m_index); + }); + } + + if (mod->nexusId() > 0) + addAction(tr("Force-check updates"), [=, this]() { + m_actions.checkModsForUpdates(m_selected); + }); + if (mod->updateIgnored()) { + addAction(tr("Un-ignore update"), [=, this]() { + m_actions.setIgnoreUpdate(m_selected, false); + }); + } else { + if (mod->updateAvailable() || mod->downgradeAvailable()) { + addAction(tr("Ignore update"), [=, this]() { + m_actions.setIgnoreUpdate(m_selected, true); + }); + } + } + addSeparator(); + + addAction(tr("Enable selected"), [=, this]() { + m_core.modList()->setActive(m_selected, true); + }); + addAction(tr("Disable selected"), [=, this]() { + m_core.modList()->setActive(m_selected, false); + }); + + addSeparator(); + + if (m_view->sortColumn() == ModList::COL_PRIORITY) { + addSendToContextMenu(); + addSeparator(); + } + + addAction(tr("Rename Mod..."), [=, this]() { + m_actions.renameMod(m_index); + }); + addAction(tr("Reinstall Mod"), [=, this]() { + m_actions.reinstallMod(m_index); + }); + addAction(tr("Remove Mod..."), [=, this]() { + m_actions.removeMods(m_selected); + }); + addAction(tr("Create Backup"), [=, this]() { + m_actions.createBackup(m_index); + }); + + if (std::find(flags.begin(), flags.end(), ModInfo::FLAG_HIDDEN_FILES) != + flags.end()) { + addAction(tr("Restore hidden files"), [=, this]() { + m_actions.restoreHiddenFiles(m_selected); + }); + } + + addSeparator(); + + if (m_index.column() == ModList::COL_NOTES) { + addAction(tr("Select Color..."), [=, this]() { + m_actions.setColor(m_selected, m_index); + }); + if (mod->color().isValid()) { + addAction(tr("Reset Color"), [=, this]() { + m_actions.resetColor(m_selected); + }); + } + addSeparator(); + } + + if (mod->nexusId() > 0 && Settings::instance().nexus().endorsementIntegration()) { + switch (mod->endorsedState()) { + case EndorsedState::ENDORSED_TRUE: { + addAction(tr("Un-Endorse"), [=, this]() { + m_actions.setEndorsed(m_selected, false); + }); + } break; + case EndorsedState::ENDORSED_FALSE: { + addAction(tr("Endorse"), [=, this]() { + m_actions.setEndorsed(m_selected, true); + }); + addAction(tr("Won't endorse"), [=, this]() { + ModListViewActions::willNotEndorsed(m_selected); + }); + } break; + case EndorsedState::ENDORSED_NEVER: { + addAction(tr("Endorse"), [=, this]() { + m_actions.setEndorsed(m_selected, true); + }); + } break; + default: { + QAction* action = new QAction(tr("Endorsement state unknown"), this); + action->setEnabled(false); + addAction(action); + } break; + } + } + + if (mod->nexusId() > 0 && + (mod->getNexusCategory() > 0 || !mod->installationFile().isEmpty()) && + !mod->isSeparator()) { + addAction(tr("Remap Category (From Nexus)"), [=, this]() { + m_actions.remapCategory(m_selected); + }); + } + + if (mod->nexusId() > 0 && Settings::instance().nexus().trackedIntegration()) { + switch (mod->trackedState()) { + case TrackedState::TRACKED_FALSE: { + addAction(tr("Start tracking"), [=, this]() { + m_actions.setTracked(m_selected, true); + }); + } break; + case TrackedState::TRACKED_TRUE: { + addAction(tr("Stop tracking"), [=, this]() { + m_actions.setTracked(m_selected, false); + }); + } break; + default: { + QAction* action = new QAction(tr("Tracked state unknown"), this); + action->setEnabled(false); + addAction(action); + } break; + } + } + + addSeparator(); + + if (std::find(flags.begin(), flags.end(), ModInfo::FLAG_INVALID) != flags.end()) { + addAction(tr("Ignore missing data"), [=, this]() { + m_actions.ignoreMissingData(m_selected); + }); + } + + if (std::find(flags.begin(), flags.end(), ModInfo::FLAG_ALTERNATE_GAME) != + flags.end()) { + addAction(tr("Mark as converted/working"), [=, this]() { + m_actions.markConverted(m_selected); + }); + } + + addSeparator(); + + if (mod->nexusId() > 0) { + addAction(tr("Visit on Nexus"), [=, this]() { + m_actions.visitOnNexus(m_selected); + }); + } + + if (!mod->uploaderUrl().isEmpty()) { + addAction(tr("Visit the uploader's profile"), [=, this]() { + m_actions.visitUploaderProfile(m_selected); + }); + } + + const auto url = mod->parseCustomURL(); + if (url.isValid()) { + addAction(tr("Visit on %1").arg(url.host()), [=, this]() { + m_actions.visitWebPage(m_selected); + }); + } + + addAction(tr("Open in Explorer"), [=, this]() { + ModListViewActions::openExplorer(m_selected); + }); +} diff --git a/src/src/modlistdropinfo.cpp b/src/src/modlistdropinfo.cpp index aec5a08..9e56d57 100644 --- a/src/src/modlistdropinfo.cpp +++ b/src/src/modlistdropinfo.cpp @@ -1,124 +1,125 @@ -#include "modlistdropinfo.h" - -#include "organizercore.h" - -ModListDropInfo::ModListDropInfo(const QMimeData* mimeData, OrganizerCore& core) - -{ - // this only check if the drop is valid, not if the content of the drop - // matches the target, a drop is valid if either - // 1. it contains items from another model (drag&drop in modlist or from download - // list) - // 2. it contains URLs from MO2 (overwrite or from another mod) - // 3. it contains a single URL to an external folder - // 4. it contains a single URL to a valid archive for MO2 - try { - if (mimeData->hasUrls()) { - for (auto& url : mimeData->urls()) { - auto p = relativeUrl(url); - if (p) { - m_localUrls.push_back(*p); - } - } - - // external drop - if (m_localUrls.empty() && mimeData->urls().size() == 1) { - auto url = mimeData->urls()[0]; - if (url.isLocalFile() && !relativeUrl(url)) { - QFileInfo const info(url.toLocalFile()); - if (info.isDir()) { - m_url = url; - } else if (core.installationManager()->getSupportedExtensions().contains( - info.suffix(), Qt::CaseInsensitive)) { - m_url = url; - } - } - } - - } else if (mimeData->hasText()) { - QByteArray encoded = mimeData->data("application/x-qabstractitemmodeldatalist"); - QDataStream stream(&encoded, QIODevice::ReadOnly); - +#include "modlistdropinfo.h" + +#include "organizercore.h" + +ModListDropInfo::ModListDropInfo(const QMimeData* mimeData, OrganizerCore& core) + +{ + // this only check if the drop is valid, not if the content of the drop + // matches the target, a drop is valid if either + // 1. it contains items from another model (drag&drop in modlist or from download + // list) + // 2. it contains URLs from MO2 (overwrite or from another mod) + // 3. it contains a single URL to an external folder + // 4. it contains a single URL to a valid archive for MO2 + try { + if (mimeData->hasUrls()) { + for (auto& url : mimeData->urls()) { + auto p = relativeUrl(url); + if (p) { + m_localUrls.push_back(*p); + } + } + + // external drop + if (m_localUrls.empty() && mimeData->urls().size() == 1) { + auto url = mimeData->urls()[0]; + if (url.isLocalFile() && !relativeUrl(url)) { + QFileInfo const info(url.toLocalFile()); + if (info.isDir()) { + m_url = url; + } else if (core.installationManager()->getSupportedExtensions().contains( + info.suffix(), Qt::CaseInsensitive)) { + m_url = url; + } + } + } + + } else if (mimeData->hasText()) { + QByteArray encoded = mimeData->data("application/x-qabstractitemmodeldatalist"); + QDataStream stream(&encoded, QIODevice::ReadOnly); + while (!stream.atEnd()) { - int sourceRow, col; + int sourceRow; + int col; QMap roleDataMap; stream >> sourceRow >> col >> roleDataMap; if (col == 0 && sourceRow >= 0) { m_rows.push_back(sourceRow); } } - - if (mimeData->text() != ModListDropInfo::ModText) { - if (mimeData->text() == ModListDropInfo::DownloadText && m_rows.size() == 1) { - m_download = m_rows[0]; - } - m_rows = {}; - } - } - } catch (std::exception const&) { - m_rows = {}; - m_download = -1; - m_localUrls.clear(); - m_url = QUrl(); - } -} - -std::optional -ModListDropInfo::relativeUrl(const QUrl& url) -{ - if (!url.isLocalFile()) { - return {}; - } - - QDir const allModsDir(Settings::instance().paths().mods()); - QDir const overwriteDir(Settings::instance().paths().overwrite()); - - QFileInfo const sourceInfo(url.toLocalFile()); - QString const sourceFile = sourceInfo.canonicalFilePath(); - - QString const relativePath; - QString originName; - - if (sourceFile.startsWith(allModsDir.canonicalPath())) { - QDir const relativeDir(allModsDir.relativeFilePath(sourceFile)); - QStringList splitPath = relativeDir.path().split("/"); - originName = splitPath[0]; - splitPath.pop_front(); - return {{url, splitPath.join("/"), originName}}; - } else if (sourceFile.startsWith(overwriteDir.canonicalPath())) { - return {{url, overwriteDir.relativeFilePath(sourceFile), - ModInfo::getOverwrite()->name()}}; - } - - return {}; -} - -bool ModListDropInfo::isValid() const -{ - return isLocalFileDrop() || isModDrop() || isDownloadDrop() || m_url.isLocalFile(); -} - -bool ModListDropInfo::isLocalFileDrop() const -{ - return !m_localUrls.empty(); -} - -bool ModListDropInfo::isModDrop() const -{ - return !m_rows.empty(); -} - -bool ModListDropInfo::isDownloadDrop() const -{ - return m_download != -1; -} - -bool ModListDropInfo::isExternalArchiveDrop() const -{ - return m_url.isLocalFile() && QFileInfo(m_url.toLocalFile()).isFile(); -} - -bool ModListDropInfo::isExternalFolderDrop() const -{ - return m_url.isLocalFile() && QFileInfo(m_url.toLocalFile()).isDir(); -} + + if (mimeData->text() != ModListDropInfo::ModText) { + if (mimeData->text() == ModListDropInfo::DownloadText && m_rows.size() == 1) { + m_download = m_rows[0]; + } + m_rows = {}; + } + } + } catch (std::exception const&) { + m_rows = {}; + m_download = -1; + m_localUrls.clear(); + m_url = QUrl(); + } +} + +std::optional +ModListDropInfo::relativeUrl(const QUrl& url) +{ + if (!url.isLocalFile()) { + return {}; + } + + QDir const allModsDir(Settings::instance().paths().mods()); + QDir const overwriteDir(Settings::instance().paths().overwrite()); + + QFileInfo const sourceInfo(url.toLocalFile()); + QString const sourceFile = sourceInfo.canonicalFilePath(); + + QString const relativePath; + QString originName; + + if (sourceFile.startsWith(allModsDir.canonicalPath())) { + QDir const relativeDir(allModsDir.relativeFilePath(sourceFile)); + QStringList splitPath = relativeDir.path().split("/"); + originName = splitPath[0]; + splitPath.pop_front(); + return {{.url=url, .relativePath=splitPath.join("/"), .originName=originName}}; + } else if (sourceFile.startsWith(overwriteDir.canonicalPath())) { + return {{.url=url, .relativePath=overwriteDir.relativeFilePath(sourceFile), + .originName=ModInfo::getOverwrite()->name()}}; + } + + return {}; +} + +bool ModListDropInfo::isValid() const +{ + return isLocalFileDrop() || isModDrop() || isDownloadDrop() || m_url.isLocalFile(); +} + +bool ModListDropInfo::isLocalFileDrop() const +{ + return !m_localUrls.empty(); +} + +bool ModListDropInfo::isModDrop() const +{ + return !m_rows.empty(); +} + +bool ModListDropInfo::isDownloadDrop() const +{ + return m_download != -1; +} + +bool ModListDropInfo::isExternalArchiveDrop() const +{ + return m_url.isLocalFile() && QFileInfo(m_url.toLocalFile()).isFile(); +} + +bool ModListDropInfo::isExternalFolderDrop() const +{ + return m_url.isLocalFile() && QFileInfo(m_url.toLocalFile()).isDir(); +} diff --git a/src/src/modlistproxy.cpp b/src/src/modlistproxy.cpp index 44b9c1a..f836dbb 100644 --- a/src/src/modlistproxy.cpp +++ b/src/src/modlistproxy.cpp @@ -1,113 +1,113 @@ -#include "modlistproxy.h" -#include "modlist.h" -#include "organizerproxy.h" -#include "proxyutils.h" - -using namespace MOBase; -using namespace MOShared; - -ModListProxy::ModListProxy(OrganizerProxy* oproxy, ModList* modlist) - : m_OrganizerProxy(oproxy), m_Proxied(modlist) -{} - -ModListProxy::~ModListProxy() -{ - disconnectSignals(); -} - -void ModListProxy::connectSignals() -{ - m_Connections.push_back(m_Proxied->onModInstalled( - callSignalIfPluginActive(m_OrganizerProxy, m_ModInstalled))); - m_Connections.push_back( - m_Proxied->onModMoved(callSignalIfPluginActive(m_OrganizerProxy, m_ModMoved))); - m_Connections.push_back(m_Proxied->onModRemoved( - callSignalIfPluginActive(m_OrganizerProxy, m_ModRemoved))); - m_Connections.push_back(m_Proxied->onModStateChanged( - callSignalIfPluginActive(m_OrganizerProxy, m_ModStateChanged))); -} - -void ModListProxy::disconnectSignals() -{ - for (auto& conn : m_Connections) { - conn.disconnect(); - } - m_Connections.clear(); -} - -QString ModListProxy::displayName(const QString& internalName) const -{ - return m_Proxied->displayName(internalName); -} - -QStringList ModListProxy::allMods() const -{ - return m_Proxied->allMods(); -} - -QStringList ModListProxy::allModsByProfilePriority(MOBase::IProfile* profile) const -{ - return m_Proxied->allModsByProfilePriority(profile); -} - -IModInterface* ModListProxy::getMod(const QString& name) const -{ - return m_Proxied->getMod(name); -} - -bool ModListProxy::removeMod(MOBase::IModInterface* mod) -{ - return m_Proxied->removeMod(mod); -} - -MOBase::IModInterface* ModListProxy::renameMod(MOBase::IModInterface* mod, - const QString& name) -{ - return m_Proxied->renameMod(mod, name); -} - -IModList::ModStates ModListProxy::state(const QString& name) const -{ - return m_Proxied->state(name); -} - -bool ModListProxy::setActive(const QString& name, bool active) -{ - return m_Proxied->setActive(name, active); -} - -int ModListProxy::setActive(const QStringList& names, bool active) -{ - return m_Proxied->setActive(names, active); -} - -int ModListProxy::priority(const QString& name) const -{ - return m_Proxied->priority(name); -} - -bool ModListProxy::setPriority(const QString& name, int newPriority) -{ - return m_Proxied->setPriority(name, newPriority); -} - -bool ModListProxy::onModInstalled(const std::function& func) -{ - return m_ModInstalled.connect(func).connected(); -} - -bool ModListProxy::onModRemoved(const std::function& func) -{ - return m_ModRemoved.connect(func).connected(); -} - -bool ModListProxy::onModStateChanged( - const std::function&)>& func) -{ - return m_ModStateChanged.connect(func).connected(); -} - -bool ModListProxy::onModMoved(const std::function& func) -{ - return m_ModMoved.connect(func).connected(); -} +#include "modlistproxy.h" +#include "modlist.h" +#include "organizerproxy.h" +#include "proxyutils.h" + +using namespace MOBase; +using namespace MOShared; + +ModListProxy::ModListProxy(OrganizerProxy* oproxy, ModList* modlist) + : m_OrganizerProxy(oproxy), m_Proxied(modlist) +{} + +ModListProxy::~ModListProxy() +{ + disconnectSignals(); +} + +void ModListProxy::connectSignals() +{ + m_Connections.push_back(m_Proxied->onModInstalled( + callSignalIfPluginActive(m_OrganizerProxy, m_ModInstalled))); + m_Connections.push_back( + m_Proxied->onModMoved(callSignalIfPluginActive(m_OrganizerProxy, m_ModMoved))); + m_Connections.push_back(m_Proxied->onModRemoved( + callSignalIfPluginActive(m_OrganizerProxy, m_ModRemoved))); + m_Connections.push_back(m_Proxied->onModStateChanged( + callSignalIfPluginActive(m_OrganizerProxy, m_ModStateChanged))); +} + +void ModListProxy::disconnectSignals() +{ + for (auto& conn : m_Connections) { + conn.disconnect(); + } + m_Connections.clear(); +} + +QString ModListProxy::displayName(const QString& internalName) const +{ + return ModList::displayName(internalName); +} + +QStringList ModListProxy::allMods() const +{ + return ModList::allMods(); +} + +QStringList ModListProxy::allModsByProfilePriority(MOBase::IProfile* profile) const +{ + return m_Proxied->allModsByProfilePriority(profile); +} + +IModInterface* ModListProxy::getMod(const QString& name) const +{ + return ModList::getMod(name); +} + +bool ModListProxy::removeMod(MOBase::IModInterface* mod) +{ + return m_Proxied->removeMod(mod); +} + +MOBase::IModInterface* ModListProxy::renameMod(MOBase::IModInterface* mod, + const QString& name) +{ + return m_Proxied->renameMod(mod, name); +} + +IModList::ModStates ModListProxy::state(const QString& name) const +{ + return m_Proxied->state(name); +} + +bool ModListProxy::setActive(const QString& name, bool active) +{ + return m_Proxied->setActive(name, active); +} + +int ModListProxy::setActive(const QStringList& names, bool active) +{ + return m_Proxied->setActive(names, active); +} + +int ModListProxy::priority(const QString& name) const +{ + return m_Proxied->priority(name); +} + +bool ModListProxy::setPriority(const QString& name, int newPriority) +{ + return m_Proxied->setPriority(name, newPriority); +} + +bool ModListProxy::onModInstalled(const std::function& func) +{ + return m_ModInstalled.connect(func).connected(); +} + +bool ModListProxy::onModRemoved(const std::function& func) +{ + return m_ModRemoved.connect(func).connected(); +} + +bool ModListProxy::onModStateChanged( + const std::function&)>& func) +{ + return m_ModStateChanged.connect(func).connected(); +} + +bool ModListProxy::onModMoved(const std::function& func) +{ + return m_ModMoved.connect(func).connected(); +} diff --git a/src/src/modlistsortproxy.cpp b/src/src/modlistsortproxy.cpp index 918c39c..fecedc4 100644 --- a/src/src/modlistsortproxy.cpp +++ b/src/src/modlistsortproxy.cpp @@ -1,55 +1,56 @@ -/* -Copyright (C) 2012 Sebastian Herbord. All rights reserved. - -This file is part of Mod Organizer. - -Mod Organizer is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -Mod Organizer is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with Mod Organizer. If not, see . -*/ - -#include "modlistsortproxy.h" -#include "messagedialog.h" -#include "modinfo.h" -#include "modlistbypriorityproxy.h" -#include "modlistdropinfo.h" -#include "organizercore.h" -#include "profile.h" -#include "qtgroupingproxy.h" - -#include -#include -#include -#include -#include -#include -#include -#include - -using namespace MOBase; - -ModListSortProxy::ModListSortProxy(Profile* profile, OrganizerCore* organizer) - : QSortFilterProxyModel(organizer), m_Organizer(organizer), m_Profile(profile) - -{ - setDynamicSortFilter(true); // this seems to work without dynamicsortfilter - // but I don't know why. This should be necessary -} - -void ModListSortProxy::setProfile(Profile* profile) -{ - m_Profile = profile; -} - +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "modlistsortproxy.h" +#include "messagedialog.h" +#include "modinfo.h" +#include "modlistbypriorityproxy.h" +#include "modlistdropinfo.h" +#include "organizercore.h" +#include "profile.h" +#include "qtgroupingproxy.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace MOBase; + +ModListSortProxy::ModListSortProxy(Profile* profile, OrganizerCore* organizer) + : QSortFilterProxyModel(organizer), m_Organizer(organizer), m_Profile(profile) + +{ + setDynamicSortFilter(true); // this seems to work without dynamicsortfilter + // but I don't know why. This should be necessary +} + +void ModListSortProxy::setProfile(Profile* profile) +{ + m_Profile = profile; +} + void ModListSortProxy::updateFilterActive() { m_FilterActive = (!m_Criteria.empty() || !m_Filter.isEmpty()); @@ -68,11 +69,11 @@ void ModListSortProxy::refreshFilter() void ModListSortProxy::setCriteria(const std::vector& criteria) { - // avoid refreshing the filter unless we are checking all mods for update. - const bool changed = (criteria != m_Criteria); - const bool isForUpdates = - (!criteria.empty() && criteria[0].id == CategoryFactory::UpdateAvailable); - + // avoid refreshing the filter unless we are checking all mods for update. + const bool changed = (criteria != m_Criteria); + const bool isForUpdates = + (!criteria.empty() && criteria[0].id == CategoryFactory::UpdateAvailable); + if (changed || isForUpdates) { m_Criteria = criteria; updateFilterActive(); @@ -80,189 +81,190 @@ void ModListSortProxy::setCriteria(const std::vector& criteria) emit filterInvalidated(); } } - -unsigned long ModListSortProxy::flagsId(const std::vector& flags) -{ - unsigned long result = 0; - for (ModInfo::EFlag const flag : flags) { - if ((flag != ModInfo::FLAG_FOREIGN) && (flag != ModInfo::FLAG_OVERWRITE)) { - result += 1 << (int)flag; - } - } - return result; -} - -unsigned long ModListSortProxy::conflictFlagsId( - const std::vector& flags) -{ - unsigned long result = 0; - for (ModInfo::EConflictFlag const flag : flags) { - if ((flag != ModInfo::FLAG_OVERWRITE_CONFLICT)) { - result += 1 << (int)flag; - } - } - return result; -} - -bool ModListSortProxy::lessThan(const QModelIndex& left, const QModelIndex& right) const -{ - if (sourceModel()->hasChildren(left) || sourceModel()->hasChildren(right)) { - // when sorting by priority, we do not want to use the parent lessThan because - // it uses the display role which can be inconsistent (e.g. for backups) - if (sortColumn() != ModList::COL_PRIORITY) { - return QSortFilterProxyModel::lessThan(left, right); - } else if (qobject_cast(sourceModel())) { - // if the underlying proxy is a QtGroupingProxy we need to rely on - // Qt::DisplayRole because the other roles are not correctly handled - // by that kind of proxy - return left.data(Qt::DisplayRole).toInt() < right.data(Qt::DisplayRole).toInt(); - } - } - - bool lOk, rOk; - int const leftIndex = left.data(ModList::IndexRole).toInt(&lOk); - int const rightIndex = right.data(ModList::IndexRole).toInt(&rOk); - - if (!lOk || !rOk) { - return false; - } - - ModInfo::Ptr const leftMod = ModInfo::getByIndex(leftIndex); - ModInfo::Ptr const rightMod = ModInfo::getByIndex(rightIndex); - - bool lt = left.data(ModList::PriorityRole).toInt() < - right.data(ModList::PriorityRole).toInt(); - - switch (left.column()) { - case ModList::COL_FLAGS: { - std::vector const leftFlags = leftMod->getFlags(); - std::vector const rightFlags = rightMod->getFlags(); - if (leftFlags.size() != rightFlags.size()) { - lt = leftFlags.size() < rightFlags.size(); - } else { - lt = flagsId(leftFlags) < flagsId(rightFlags); - } - } break; - case ModList::COL_CONFLICTFLAGS: { - std::vector const leftFlags = leftMod->getConflictFlags(); - std::vector const rightFlags = rightMod->getConflictFlags(); - if (leftFlags.size() != rightFlags.size()) { - lt = leftFlags.size() < rightFlags.size(); - } else { - lt = conflictFlagsId(leftFlags) < conflictFlagsId(rightFlags); - } - } break; - case ModList::COL_CONTENT: { - const auto& lContents = leftMod->getContents(); - const auto& rContents = rightMod->getContents(); - unsigned int lValue = 0; - unsigned int rValue = 0; - m_Organizer->modDataContents().forEachContentIn( - lContents, [&lValue](auto const& content) { - lValue += 2U << static_cast(content.id()); - }); - m_Organizer->modDataContents().forEachContentIn( - rContents, [&rValue](auto const& content) { - rValue += 2U << static_cast(content.id()); - }); - lt = lValue < rValue; - } break; - case ModList::COL_NAME: { - int const comp = QString::compare(leftMod->name(), rightMod->name(), Qt::CaseInsensitive); - if (comp != 0) - lt = comp < 0; - } break; - case ModList::COL_CATEGORY: { - if (leftMod->primaryCategory() != rightMod->primaryCategory()) { - if (leftMod->primaryCategory() < 0) - lt = false; - else if (rightMod->primaryCategory() < 0) - lt = true; - else { - try { - CategoryFactory const& categories = CategoryFactory::instance(); - QString const leftCatName = categories.getCategoryName( - categories.getCategoryIndex(leftMod->primaryCategory())); - QString const rightCatName = categories.getCategoryName( - categories.getCategoryIndex(rightMod->primaryCategory())); - lt = leftCatName < rightCatName; - } catch (const std::exception& e) { - log::error("failed to compare categories: {}", e.what()); - } - } - } - } break; - case ModList::COL_AUTHOR: { - if (leftMod->author() != rightMod->author()) { - lt = leftMod->author() < rightMod->author(); - } else { - int const comp = QString::compare(leftMod->uploader(), rightMod->uploader(), - Qt::CaseInsensitive); - if (comp != 0) - lt = comp < 0; - } - } break; - case ModList::COL_UPLOADER: { - if (leftMod->uploader() != rightMod->uploader()) { - lt = leftMod->uploader() < rightMod->uploader(); - } else { - int const comp = QString::compare(leftMod->uploader(), rightMod->uploader(), - Qt::CaseInsensitive); - if (comp != 0) - lt = comp < 0; - } - } break; - case ModList::COL_MODID: { - if (leftMod->nexusId() != rightMod->nexusId()) - lt = leftMod->nexusId() < rightMod->nexusId(); - } break; - case ModList::COL_VERSION: { - if (leftMod->version() != rightMod->version()) - lt = leftMod->version() < rightMod->version(); - } break; - case ModList::COL_INSTALLTIME: { - QDateTime const leftTime = left.data().toDateTime(); - QDateTime const rightTime = right.data().toDateTime(); - if (leftTime != rightTime) - return leftTime < rightTime; - } break; - case ModList::COL_GAME: { - if (leftMod->gameName() != rightMod->gameName()) { - lt = leftMod->gameName() < rightMod->gameName(); - } else { - int const comp = - QString::compare(leftMod->name(), rightMod->name(), Qt::CaseInsensitive); - if (comp != 0) - lt = comp < 0; - } - } break; - case ModList::COL_NOTES: { - QString const leftComments = leftMod->comments(); - QString const rightComments = rightMod->comments(); - if (leftComments != rightComments) { - if (leftComments.isEmpty()) { - lt = sortOrder() == Qt::DescendingOrder; - } else if (rightComments.isEmpty()) { - lt = sortOrder() == Qt::AscendingOrder; - } else { - lt = leftComments < rightComments; - } - } - } break; - case ModList::COL_PRIORITY: { - if (leftMod->isBackup() != rightMod->isBackup()) { - lt = leftMod->isBackup(); - } else if (leftMod->isOverwrite() != rightMod->isOverwrite()) { - lt = rightMod->isOverwrite(); - } - } break; - default: { - log::warn("Sorting is not defined for column {}", left.column()); - } break; - } - return lt; -} - + +unsigned long ModListSortProxy::flagsId(const std::vector& flags) +{ + unsigned long result = 0; + for (ModInfo::EFlag const flag : flags) { + if ((flag != ModInfo::FLAG_FOREIGN) && (flag != ModInfo::FLAG_OVERWRITE)) { + result += 1 << (int)flag; + } + } + return result; +} + +unsigned long ModListSortProxy::conflictFlagsId( + const std::vector& flags) +{ + unsigned long result = 0; + for (ModInfo::EConflictFlag const flag : flags) { + if ((flag != ModInfo::FLAG_OVERWRITE_CONFLICT)) { + result += 1 << (int)flag; + } + } + return result; +} + +bool ModListSortProxy::lessThan(const QModelIndex& left, const QModelIndex& right) const +{ + if (sourceModel()->hasChildren(left) || sourceModel()->hasChildren(right)) { + // when sorting by priority, we do not want to use the parent lessThan because + // it uses the display role which can be inconsistent (e.g. for backups) + if (sortColumn() != ModList::COL_PRIORITY) { + return QSortFilterProxyModel::lessThan(left, right); + } else if (qobject_cast(sourceModel())) { + // if the underlying proxy is a QtGroupingProxy we need to rely on + // Qt::DisplayRole because the other roles are not correctly handled + // by that kind of proxy + return left.data(Qt::DisplayRole).toInt() < right.data(Qt::DisplayRole).toInt(); + } + } + + bool lOk; + bool rOk; + int const leftIndex = left.data(ModList::IndexRole).toInt(&lOk); + int const rightIndex = right.data(ModList::IndexRole).toInt(&rOk); + + if (!lOk || !rOk) { + return false; + } + + ModInfo::Ptr const leftMod = ModInfo::getByIndex(leftIndex); + ModInfo::Ptr const rightMod = ModInfo::getByIndex(rightIndex); + + bool lt = left.data(ModList::PriorityRole).toInt() < + right.data(ModList::PriorityRole).toInt(); + + switch (left.column()) { + case ModList::COL_FLAGS: { + std::vector const leftFlags = leftMod->getFlags(); + std::vector const rightFlags = rightMod->getFlags(); + if (leftFlags.size() != rightFlags.size()) { + lt = leftFlags.size() < rightFlags.size(); + } else { + lt = flagsId(leftFlags) < flagsId(rightFlags); + } + } break; + case ModList::COL_CONFLICTFLAGS: { + std::vector const leftFlags = leftMod->getConflictFlags(); + std::vector const rightFlags = rightMod->getConflictFlags(); + if (leftFlags.size() != rightFlags.size()) { + lt = leftFlags.size() < rightFlags.size(); + } else { + lt = conflictFlagsId(leftFlags) < conflictFlagsId(rightFlags); + } + } break; + case ModList::COL_CONTENT: { + const auto& lContents = leftMod->getContents(); + const auto& rContents = rightMod->getContents(); + unsigned int lValue = 0; + unsigned int rValue = 0; + m_Organizer->modDataContents().forEachContentIn( + lContents, [&lValue](auto const& content) { + lValue += 2U << static_cast(content.id()); + }); + m_Organizer->modDataContents().forEachContentIn( + rContents, [&rValue](auto const& content) { + rValue += 2U << static_cast(content.id()); + }); + lt = lValue < rValue; + } break; + case ModList::COL_NAME: { + int const comp = QString::compare(leftMod->name(), rightMod->name(), Qt::CaseInsensitive); + if (comp != 0) + lt = comp < 0; + } break; + case ModList::COL_CATEGORY: { + if (leftMod->primaryCategory() != rightMod->primaryCategory()) { + if (leftMod->primaryCategory() < 0) + lt = false; + else if (rightMod->primaryCategory() < 0) + lt = true; + else { + try { + CategoryFactory const& categories = CategoryFactory::instance(); + QString const leftCatName = categories.getCategoryName( + categories.getCategoryIndex(leftMod->primaryCategory())); + QString const rightCatName = categories.getCategoryName( + categories.getCategoryIndex(rightMod->primaryCategory())); + lt = leftCatName < rightCatName; + } catch (const std::exception& e) { + log::error("failed to compare categories: {}", e.what()); + } + } + } + } break; + case ModList::COL_AUTHOR: { + if (leftMod->author() != rightMod->author()) { + lt = leftMod->author() < rightMod->author(); + } else { + int const comp = QString::compare(leftMod->uploader(), rightMod->uploader(), + Qt::CaseInsensitive); + if (comp != 0) + lt = comp < 0; + } + } break; + case ModList::COL_UPLOADER: { + if (leftMod->uploader() != rightMod->uploader()) { + lt = leftMod->uploader() < rightMod->uploader(); + } else { + int const comp = QString::compare(leftMod->uploader(), rightMod->uploader(), + Qt::CaseInsensitive); + if (comp != 0) + lt = comp < 0; + } + } break; + case ModList::COL_MODID: { + if (leftMod->nexusId() != rightMod->nexusId()) + lt = leftMod->nexusId() < rightMod->nexusId(); + } break; + case ModList::COL_VERSION: { + if (leftMod->version() != rightMod->version()) + lt = leftMod->version() < rightMod->version(); + } break; + case ModList::COL_INSTALLTIME: { + QDateTime const leftTime = left.data().toDateTime(); + QDateTime const rightTime = right.data().toDateTime(); + if (leftTime != rightTime) + return leftTime < rightTime; + } break; + case ModList::COL_GAME: { + if (leftMod->gameName() != rightMod->gameName()) { + lt = leftMod->gameName() < rightMod->gameName(); + } else { + int const comp = + QString::compare(leftMod->name(), rightMod->name(), Qt::CaseInsensitive); + if (comp != 0) + lt = comp < 0; + } + } break; + case ModList::COL_NOTES: { + QString const leftComments = leftMod->comments(); + QString const rightComments = rightMod->comments(); + if (leftComments != rightComments) { + if (leftComments.isEmpty()) { + lt = sortOrder() == Qt::DescendingOrder; + } else if (rightComments.isEmpty()) { + lt = sortOrder() == Qt::AscendingOrder; + } else { + lt = leftComments < rightComments; + } + } + } break; + case ModList::COL_PRIORITY: { + if (leftMod->isBackup() != rightMod->isBackup()) { + lt = leftMod->isBackup(); + } else if (leftMod->isOverwrite() != rightMod->isOverwrite()) { + lt = rightMod->isOverwrite(); + } + } break; + default: { + log::warn("Sorting is not defined for column {}", left.column()); + } break; + } + return lt; +} + void ModListSortProxy::updateFilter(const QString& filter) { m_Filter = filter; @@ -270,305 +272,305 @@ void ModListSortProxy::updateFilter(const QString& filter) refreshFilter(); emit filterInvalidated(); } - -bool ModListSortProxy::hasConflictFlag( - const std::vector& flags) -{ - for (ModInfo::EConflictFlag const flag : flags) { - if ((flag == ModInfo::FLAG_CONFLICT_MIXED) || - (flag == ModInfo::FLAG_CONFLICT_OVERWRITE) || - (flag == ModInfo::FLAG_CONFLICT_OVERWRITTEN) || - (flag == ModInfo::FLAG_CONFLICT_REDUNDANT) || - (flag == ModInfo::FLAG_ARCHIVE_CONFLICT_OVERWRITE) || - (flag == ModInfo::FLAG_ARCHIVE_CONFLICT_OVERWRITTEN) || - (flag == ModInfo::FLAG_ARCHIVE_CONFLICT_MIXED) || - (flag == ModInfo::FLAG_ARCHIVE_LOOSE_CONFLICT_OVERWRITE) || - (flag == ModInfo::FLAG_ARCHIVE_LOOSE_CONFLICT_OVERWRITTEN)) { - return true; - } - } - - return false; -} - -bool ModListSortProxy::filterMatchesModAnd(ModInfo::Ptr info, bool enabled) const -{ - for (auto&& c : m_Criteria) { - if (!criteriaMatchMod(info, enabled, c)) { - return false; - } - } - - return true; -} - -bool ModListSortProxy::filterMatchesModOr(ModInfo::Ptr info, bool enabled) const -{ - for (auto&& c : m_Criteria) { - if (criteriaMatchMod(info, enabled, c)) { - return true; - } - } - - if (!m_Criteria.empty()) { - // nothing matched - return false; - } - - return true; -} - -bool ModListSortProxy::optionsMatchMod(ModInfo::Ptr info, bool) -{ - return true; -} - -bool ModListSortProxy::criteriaMatchMod(ModInfo::Ptr info, bool enabled, - const Criteria& c) const -{ - bool b = false; - - switch (c.type) { - case TypeSpecial: // fall-through - case TypeCategory: { - b = categoryMatchesMod(info, enabled, c.id); - break; - } - - case TypeContent: { - b = contentMatchesMod(info, enabled, c.id); - break; - } - - default: { - log::error("bad criteria type {}", c.type); - break; - } - } - - if (c.inverse) { - b = !b; - } - - return b; -} - -bool ModListSortProxy::categoryMatchesMod(ModInfo::Ptr info, bool enabled, - int category) const -{ - bool b = false; - - switch (category) { - case CategoryFactory::Checked: { - b = (enabled || info->alwaysEnabled()); - break; - } - - case CategoryFactory::UpdateAvailable: { - b = (info->updateAvailable() || info->downgradeAvailable()); - break; - } - - case CategoryFactory::HasCategory: { - b = !info->getCategories().empty(); - break; - } - - case CategoryFactory::Conflict: { - b = (hasConflictFlag(info->getConflictFlags())); - break; - } - - case CategoryFactory::HasHiddenFiles: { - b = (info->hasFlag(ModInfo::FLAG_HIDDEN_FILES)); - break; - } - - case CategoryFactory::Endorsed: { - b = (info->endorsedState() == EndorsedState::ENDORSED_TRUE); - break; - } - - case CategoryFactory::Backup: { - b = (info->hasFlag(ModInfo::FLAG_BACKUP)); - break; - } - - case CategoryFactory::Managed: { - b = (!info->hasFlag(ModInfo::FLAG_FOREIGN)); - break; - } - - case CategoryFactory::HasGameData: { - b = !info->hasFlag(ModInfo::FLAG_INVALID); - break; - } - - case CategoryFactory::HasNexusID: { - // never show these - if (info->hasFlag(ModInfo::FLAG_FOREIGN) || info->hasFlag(ModInfo::FLAG_BACKUP) || - info->hasFlag(ModInfo::FLAG_OVERWRITE)) { - return false; - } - - b = (info->nexusId() > 0); - break; - } - - case CategoryFactory::Tracked: { - b = (info->trackedState() == TrackedState::TRACKED_TRUE); - break; - } - - default: { - b = (info->categorySet(category)); - break; - } - } - - return b; -} - -bool ModListSortProxy::contentMatchesMod(ModInfo::Ptr info, bool enabled, - int content) -{ - return info->hasContent(content); -} - -bool ModListSortProxy::filterMatchesMod(ModInfo::Ptr info, bool enabled) const -{ - // don't check if there are no filters selected - if (!m_FilterActive) { - return true; - } - - // special case for separators - if (info->hasFlag(ModInfo::FLAG_SEPARATOR)) { - switch (m_FilterSeparators) { - case SeparatorFilter: { - // filter normally - break; - } - - case SeparatorShow: { - // force visible - return true; - } - - case SeparatorHide: { - // force hide - return false; - } - } - } - - if (!m_Filter.isEmpty()) { - bool display = false; - QString filterCopy = QString(m_Filter); - filterCopy.replace("||", ";").replace("OR", ";").replace("|", ";"); - QStringList const ORList = filterCopy.split(";", Qt::SkipEmptyParts); - - bool segmentGood = true; - - // split in ORSegments that internally use AND logic - for (auto& ORSegment : ORList) { - QStringList const ANDKeywords = ORSegment.split(" ", Qt::SkipEmptyParts); - segmentGood = true; - bool foundKeyword = false; - - // check each word in the segment for match, each word needs to be matched but - // it doesn't matter where. - for (auto& currentKeyword : ANDKeywords) { - foundKeyword = false; - - // search keyword in name - if (m_EnabledColumns[ModList::COL_NAME] && - info->name().contains(currentKeyword, Qt::CaseInsensitive)) { - foundKeyword = true; - } - - // Search by author - if (!foundKeyword && m_EnabledColumns[ModList::COL_AUTHOR] && - info->author().contains(currentKeyword, Qt::CaseInsensitive)) { - foundKeyword = true; - } - - // Search by uploader - if (!foundKeyword && m_EnabledColumns[ModList::COL_UPLOADER] && - info->uploader().contains(currentKeyword, Qt::CaseInsensitive)) { - foundKeyword = true; - } - - // Search by notes - if (!foundKeyword && m_EnabledColumns[ModList::COL_NOTES] && - (info->notes().contains(currentKeyword, Qt::CaseInsensitive) || - info->comments().contains(currentKeyword, Qt::CaseInsensitive))) { - foundKeyword = true; - } - - // Search by categories - if (!foundKeyword && m_EnabledColumns[ModList::COL_CATEGORY]) { - for (const auto& category : info->categories()) { - if (category.contains(currentKeyword, Qt::CaseInsensitive)) { - foundKeyword = true; - break; - } - } - } - - // Search by Nexus ID - if (!foundKeyword && m_EnabledColumns[ModList::COL_MODID]) { - bool ok; - int const filterID = currentKeyword.toInt(&ok); - if (ok) { - int modID = info->nexusId(); - while (modID > 0) { - if (modID == filterID) { - foundKeyword = true; - break; - } - modID = (int)(modID / 10); - } - } - } - - if (!foundKeyword) { - // currentKeword is missing from everything, AND fails and we need to check - // next ORsegment - segmentGood = false; - break; - } - - } // for ANDKeywords loop - - if (segmentGood) { - // the last AND loop didn't break so the ORSegments is true so mod matches - // filter - display = true; - break; - } - - } // for ORList loop - - if (!display) { - return false; - } - } // if (!m_CurrentFilter.isEmpty()) - - if (m_FilterMode == FilterAnd) { - return filterMatchesModAnd(info, enabled); - } else { - return filterMatchesModOr(info, enabled); - } -} - -void ModListSortProxy::setColumnVisible(int column, bool visible) -{ - m_EnabledColumns[column] = visible; -} - -void ModListSortProxy::setOptions(ModListSortProxy::FilterMode mode, - SeparatorsMode separators) + +bool ModListSortProxy::hasConflictFlag( + const std::vector& flags) +{ + for (ModInfo::EConflictFlag const flag : flags) { + if ((flag == ModInfo::FLAG_CONFLICT_MIXED) || + (flag == ModInfo::FLAG_CONFLICT_OVERWRITE) || + (flag == ModInfo::FLAG_CONFLICT_OVERWRITTEN) || + (flag == ModInfo::FLAG_CONFLICT_REDUNDANT) || + (flag == ModInfo::FLAG_ARCHIVE_CONFLICT_OVERWRITE) || + (flag == ModInfo::FLAG_ARCHIVE_CONFLICT_OVERWRITTEN) || + (flag == ModInfo::FLAG_ARCHIVE_CONFLICT_MIXED) || + (flag == ModInfo::FLAG_ARCHIVE_LOOSE_CONFLICT_OVERWRITE) || + (flag == ModInfo::FLAG_ARCHIVE_LOOSE_CONFLICT_OVERWRITTEN)) { + return true; + } + } + + return false; +} + +bool ModListSortProxy::filterMatchesModAnd(ModInfo::Ptr info, bool enabled) const +{ + for (auto&& c : m_Criteria) { + if (!criteriaMatchMod(info, enabled, c)) { + return false; + } + } + + return true; +} + +bool ModListSortProxy::filterMatchesModOr(ModInfo::Ptr info, bool enabled) const +{ + for (auto&& c : m_Criteria) { + if (criteriaMatchMod(info, enabled, c)) { + return true; + } + } + + if (!m_Criteria.empty()) { + // nothing matched + return false; + } + + return true; +} + +bool ModListSortProxy::optionsMatchMod(ModInfo::Ptr info, bool) +{ + return true; +} + +bool ModListSortProxy::criteriaMatchMod(ModInfo::Ptr info, bool enabled, + const Criteria& c) const +{ + bool b = false; + + switch (c.type) { + case TypeSpecial: // fall-through + case TypeCategory: { + b = categoryMatchesMod(info, enabled, c.id); + break; + } + + case TypeContent: { + b = contentMatchesMod(info, enabled, c.id); + break; + } + + default: { + log::error("bad criteria type {}", c.type); + break; + } + } + + if (c.inverse) { + b = !b; + } + + return b; +} + +bool ModListSortProxy::categoryMatchesMod(ModInfo::Ptr info, bool enabled, + int category) const +{ + bool b = false; + + switch (category) { + case CategoryFactory::Checked: { + b = (enabled || info->alwaysEnabled()); + break; + } + + case CategoryFactory::UpdateAvailable: { + b = (info->updateAvailable() || info->downgradeAvailable()); + break; + } + + case CategoryFactory::HasCategory: { + b = !info->getCategories().empty(); + break; + } + + case CategoryFactory::Conflict: { + b = (hasConflictFlag(info->getConflictFlags())); + break; + } + + case CategoryFactory::HasHiddenFiles: { + b = (info->hasFlag(ModInfo::FLAG_HIDDEN_FILES)); + break; + } + + case CategoryFactory::Endorsed: { + b = (info->endorsedState() == EndorsedState::ENDORSED_TRUE); + break; + } + + case CategoryFactory::Backup: { + b = (info->hasFlag(ModInfo::FLAG_BACKUP)); + break; + } + + case CategoryFactory::Managed: { + b = (!info->hasFlag(ModInfo::FLAG_FOREIGN)); + break; + } + + case CategoryFactory::HasGameData: { + b = !info->hasFlag(ModInfo::FLAG_INVALID); + break; + } + + case CategoryFactory::HasNexusID: { + // never show these + if (info->hasFlag(ModInfo::FLAG_FOREIGN) || info->hasFlag(ModInfo::FLAG_BACKUP) || + info->hasFlag(ModInfo::FLAG_OVERWRITE)) { + return false; + } + + b = (info->nexusId() > 0); + break; + } + + case CategoryFactory::Tracked: { + b = (info->trackedState() == TrackedState::TRACKED_TRUE); + break; + } + + default: { + b = (info->categorySet(category)); + break; + } + } + + return b; +} + +bool ModListSortProxy::contentMatchesMod(ModInfo::Ptr info, bool enabled, + int content) +{ + return info->hasContent(content); +} + +bool ModListSortProxy::filterMatchesMod(ModInfo::Ptr info, bool enabled) const +{ + // don't check if there are no filters selected + if (!m_FilterActive) { + return true; + } + + // special case for separators + if (info->hasFlag(ModInfo::FLAG_SEPARATOR)) { + switch (m_FilterSeparators) { + case SeparatorFilter: { + // filter normally + break; + } + + case SeparatorShow: { + // force visible + return true; + } + + case SeparatorHide: { + // force hide + return false; + } + } + } + + if (!m_Filter.isEmpty()) { + bool display = false; + QString filterCopy = QString(m_Filter); + filterCopy.replace("||", ";").replace("OR", ";").replace("|", ";"); + QStringList const ORList = filterCopy.split(";", Qt::SkipEmptyParts); + + bool segmentGood = true; + + // split in ORSegments that internally use AND logic + for (const auto& ORSegment : ORList) { + QStringList const ANDKeywords = ORSegment.split(" ", Qt::SkipEmptyParts); + segmentGood = true; + bool foundKeyword = false; + + // check each word in the segment for match, each word needs to be matched but + // it doesn't matter where. + for (const auto& currentKeyword : ANDKeywords) { + foundKeyword = false; + + // search keyword in name + if (m_EnabledColumns[ModList::COL_NAME] && + info->name().contains(currentKeyword, Qt::CaseInsensitive)) { + foundKeyword = true; + } + + // Search by author + if (!foundKeyword && m_EnabledColumns[ModList::COL_AUTHOR] && + info->author().contains(currentKeyword, Qt::CaseInsensitive)) { + foundKeyword = true; + } + + // Search by uploader + if (!foundKeyword && m_EnabledColumns[ModList::COL_UPLOADER] && + info->uploader().contains(currentKeyword, Qt::CaseInsensitive)) { + foundKeyword = true; + } + + // Search by notes + if (!foundKeyword && m_EnabledColumns[ModList::COL_NOTES] && + (info->notes().contains(currentKeyword, Qt::CaseInsensitive) || + info->comments().contains(currentKeyword, Qt::CaseInsensitive))) { + foundKeyword = true; + } + + // Search by categories + if (!foundKeyword && m_EnabledColumns[ModList::COL_CATEGORY]) { + for (const auto& category : info->categories()) { + if (category.contains(currentKeyword, Qt::CaseInsensitive)) { + foundKeyword = true; + break; + } + } + } + + // Search by Nexus ID + if (!foundKeyword && m_EnabledColumns[ModList::COL_MODID]) { + bool ok; + int const filterID = currentKeyword.toInt(&ok); + if (ok) { + int modID = info->nexusId(); + while (modID > 0) { + if (modID == filterID) { + foundKeyword = true; + break; + } + modID = (int)(modID / 10); + } + } + } + + if (!foundKeyword) { + // currentKeword is missing from everything, AND fails and we need to check + // next ORsegment + segmentGood = false; + break; + } + + } // for ANDKeywords loop + + if (segmentGood) { + // the last AND loop didn't break so the ORSegments is true so mod matches + // filter + display = true; + break; + } + + } // for ORList loop + + if (!display) { + return false; + } + } // if (!m_CurrentFilter.isEmpty()) + + if (m_FilterMode == FilterAnd) { + return filterMatchesModAnd(info, enabled); + } else { + return filterMatchesModOr(info, enabled); + } +} + +void ModListSortProxy::setColumnVisible(int column, bool visible) +{ + m_EnabledColumns[column] = visible; +} + +void ModListSortProxy::setOptions(ModListSortProxy::FilterMode mode, + SeparatorsMode separators) { if (m_FilterMode != mode || separators != m_FilterSeparators) { m_FilterMode = mode; @@ -577,149 +579,149 @@ void ModListSortProxy::setOptions(ModListSortProxy::FilterMode mode, emit filterInvalidated(); } } - -bool ModListSortProxy::filterAcceptsRow(int source_row, const QModelIndex& parent) const -{ - if (m_Profile == nullptr) { - return false; - } - - if (source_row >= static_cast(m_Profile->numMods())) { - log::warn("invalid row index: {}", source_row); - return false; - } - - QModelIndex const idx = sourceModel()->index(source_row, 0, parent); - if (!idx.isValid()) { - log::debug("invalid mod index"); - return false; - } - - unsigned int index = UINT_MAX; - { - bool ok = false; - index = idx.data(ModList::IndexRole).toInt(&ok); - if (!ok) { - index = UINT_MAX; - } - } - - if (sourceModel()->hasChildren(idx)) { - // we need to check the separator itself first - if (index < ModInfo::getNumMods() && ModInfo::getByIndex(index)->isSeparator()) { - if (filterMatchesMod(ModInfo::getByIndex(index), false)) { - return true; - } - } - for (int i = 0; i < sourceModel()->rowCount(idx); ++i) { - if (filterAcceptsRow(i, idx)) { - return true; - } - } - - return false; - } else { - bool const modEnabled = - idx.sibling(source_row, 0).data(Qt::CheckStateRole).toInt() == Qt::Checked; - return filterMatchesMod(ModInfo::getByIndex(index), modEnabled); - } -} - -bool ModListSortProxy::sourceIsByPriorityProxy() const -{ - return dynamic_cast(sourceModel()) != nullptr; -} - -bool ModListSortProxy::canDropMimeData(const QMimeData* data, Qt::DropAction action, - int row, int column, - const QModelIndex& parent) const -{ - ModListDropInfo const dropInfo(data, *m_Organizer); - - if (!dropInfo.isLocalFileDrop() && sortColumn() != ModList::COL_PRIORITY) { - return false; - } - - // disable drop install with group proxy, except the one for collapsible separator - // - it would be nice to be able to "install to category" or something like that but - // it's a bit more complicated since the drop position is based on the category, - // so just disabling for now - if (dropInfo.isDownloadDrop()) { - // maybe there is a cleaner way? - if (qobject_cast(sourceModel())) { - return false; - } - } - - // see dropMimeData for details - if (sortOrder() == Qt::DescendingOrder && row != -1 && !sourceIsByPriorityProxy()) { - --row; - } - - return QSortFilterProxyModel::canDropMimeData(data, action, row, column, parent); -} - -bool ModListSortProxy::dropMimeData(const QMimeData* data, Qt::DropAction action, - int row, int column, const QModelIndex& parent) -{ - ModListDropInfo const dropInfo(data, *m_Organizer); - - if (!dropInfo.isLocalFileDrop() && sortColumn() != ModList::COL_PRIORITY) { - QWidget* wid = qApp->activeWindow()->findChild("modList"); - MessageDialog::showMessage( - tr("Drag&Drop is only supported when sorting by priority"), wid); - return false; - } - - if (row == -1 && column == -1) { - return sourceModel()->dropMimeData(data, action, -1, -1, mapToSource(parent)); - } - - // in the regular model, when dropping between rows, the row-value passed to - // the sourceModel is inconsistent between ascending and descending ordering - // - // we want to fix that, but we cannot do it for the by-priority proxy because - // it messes up with non top-level items, so we simply forward the row and the - // by-priority proxy will fix the row for us - if (sortOrder() == Qt::DescendingOrder && row != -1 && !sourceIsByPriorityProxy()) { - --row; - } - - return QSortFilterProxyModel::dropMimeData(data, action, row, column, parent); -} - -void ModListSortProxy::setSourceModel(QAbstractItemModel* sourceModel) -{ - QSortFilterProxyModel::setSourceModel(sourceModel); - QAbstractProxyModel* proxy = qobject_cast(sourceModel); - if (proxy != nullptr) { - sourceModel = proxy->sourceModel(); - } - if (sourceModel) { - connect(sourceModel, SIGNAL(aboutToChangeData()), this, SLOT(aboutToChangeData()), - Qt::UniqueConnection); - connect(sourceModel, SIGNAL(postDataChanged()), this, SLOT(postDataChanged()), - Qt::UniqueConnection); - } -} - -void ModListSortProxy::aboutToChangeData() -{ - // having a filter active when dataChanged is called caused a crash - // (at least with some Qt versions) - // this may be related to the fact that the item being edited may disappear from the - // view as a result of the edit - m_PreChangeCriteria = m_Criteria; - setCriteria({}); -} - -void ModListSortProxy::postDataChanged() -{ - // if the filter is re-activated right away the editor can't be deleted but becomes - // invisible or at least the view continues to think it's being edited. As a result - // no new editor can be opened - QTimer::singleShot(10, [this]() { - setCriteria(m_PreChangeCriteria); - m_PreChangeCriteria.clear(); - }); -} + +bool ModListSortProxy::filterAcceptsRow(int source_row, const QModelIndex& parent) const +{ + if (m_Profile == nullptr) { + return false; + } + + if (std::cmp_greater_equal(source_row, m_Profile->numMods())) { + log::warn("invalid row index: {}", source_row); + return false; + } + + QModelIndex const idx = sourceModel()->index(source_row, 0, parent); + if (!idx.isValid()) { + log::debug("invalid mod index"); + return false; + } + + unsigned int index = UINT_MAX; + { + bool ok = false; + index = idx.data(ModList::IndexRole).toInt(&ok); + if (!ok) { + index = UINT_MAX; + } + } + + if (sourceModel()->hasChildren(idx)) { + // we need to check the separator itself first + if (index < ModInfo::getNumMods() && ModInfo::getByIndex(index)->isSeparator()) { + if (filterMatchesMod(ModInfo::getByIndex(index), false)) { + return true; + } + } + for (int i = 0; i < sourceModel()->rowCount(idx); ++i) { + if (filterAcceptsRow(i, idx)) { + return true; + } + } + + return false; + } else { + bool const modEnabled = + idx.sibling(source_row, 0).data(Qt::CheckStateRole).toInt() == Qt::Checked; + return filterMatchesMod(ModInfo::getByIndex(index), modEnabled); + } +} + +bool ModListSortProxy::sourceIsByPriorityProxy() const +{ + return dynamic_cast(sourceModel()) != nullptr; +} + +bool ModListSortProxy::canDropMimeData(const QMimeData* data, Qt::DropAction action, + int row, int column, + const QModelIndex& parent) const +{ + ModListDropInfo const dropInfo(data, *m_Organizer); + + if (!dropInfo.isLocalFileDrop() && sortColumn() != ModList::COL_PRIORITY) { + return false; + } + + // disable drop install with group proxy, except the one for collapsible separator + // - it would be nice to be able to "install to category" or something like that but + // it's a bit more complicated since the drop position is based on the category, + // so just disabling for now + if (dropInfo.isDownloadDrop()) { + // maybe there is a cleaner way? + if (qobject_cast(sourceModel())) { + return false; + } + } + + // see dropMimeData for details + if (sortOrder() == Qt::DescendingOrder && row != -1 && !sourceIsByPriorityProxy()) { + --row; + } + + return QSortFilterProxyModel::canDropMimeData(data, action, row, column, parent); +} + +bool ModListSortProxy::dropMimeData(const QMimeData* data, Qt::DropAction action, + int row, int column, const QModelIndex& parent) +{ + ModListDropInfo const dropInfo(data, *m_Organizer); + + if (!dropInfo.isLocalFileDrop() && sortColumn() != ModList::COL_PRIORITY) { + QWidget* wid = qApp->activeWindow()->findChild("modList"); + MessageDialog::showMessage( + tr("Drag&Drop is only supported when sorting by priority"), wid); + return false; + } + + if (row == -1 && column == -1) { + return sourceModel()->dropMimeData(data, action, -1, -1, mapToSource(parent)); + } + + // in the regular model, when dropping between rows, the row-value passed to + // the sourceModel is inconsistent between ascending and descending ordering + // + // we want to fix that, but we cannot do it for the by-priority proxy because + // it messes up with non top-level items, so we simply forward the row and the + // by-priority proxy will fix the row for us + if (sortOrder() == Qt::DescendingOrder && row != -1 && !sourceIsByPriorityProxy()) { + --row; + } + + return QSortFilterProxyModel::dropMimeData(data, action, row, column, parent); +} + +void ModListSortProxy::setSourceModel(QAbstractItemModel* sourceModel) +{ + QSortFilterProxyModel::setSourceModel(sourceModel); + QAbstractProxyModel* proxy = qobject_cast(sourceModel); + if (proxy != nullptr) { + sourceModel = proxy->sourceModel(); + } + if (sourceModel) { + connect(sourceModel, SIGNAL(aboutToChangeData()), this, SLOT(aboutToChangeData()), + Qt::UniqueConnection); + connect(sourceModel, SIGNAL(postDataChanged()), this, SLOT(postDataChanged()), + Qt::UniqueConnection); + } +} + +void ModListSortProxy::aboutToChangeData() +{ + // having a filter active when dataChanged is called caused a crash + // (at least with some Qt versions) + // this may be related to the fact that the item being edited may disappear from the + // view as a result of the edit + m_PreChangeCriteria = m_Criteria; + setCriteria({}); +} + +void ModListSortProxy::postDataChanged() +{ + // if the filter is re-activated right away the editor can't be deleted but becomes + // invisible or at least the view continues to think it's being edited. As a result + // no new editor can be opened + QTimer::singleShot(10, [this]() { + setCriteria(m_PreChangeCriteria); + m_PreChangeCriteria.clear(); + }); +} diff --git a/src/src/modlistversiondelegate.cpp b/src/src/modlistversiondelegate.cpp index c96ac17..6b004d1 100644 --- a/src/src/modlistversiondelegate.cpp +++ b/src/src/modlistversiondelegate.cpp @@ -1,67 +1,68 @@ -#include "modlistversiondelegate.h" - -#include "log.h" -#include "modlistview.h" -#include "settings.h" - -ModListVersionDelegate::ModListVersionDelegate(ModListView* view, Settings& settings) - : QItemDelegate(view), m_view(view), m_settings(settings) -{} - -void ModListVersionDelegate::paint(QPainter* painter, - const QStyleOptionViewItem& option, - const QModelIndex& index) const -{ - m_view->itemDelegate()->paint(painter, option, index); - - if (m_view->hasCollapsibleSeparators() && m_view->model()->hasChildren(index) && - m_settings.interface().collapsibleSeparatorsIcons(ModList::COL_VERSION) && - !m_view->isExpanded(index.sibling(index.row(), 0))) { - auto* model = m_view->model(); - - bool downgrade = false, upgrade = false; - - for (int i = 0; i < model->rowCount(index); ++i) { - const auto mIndex = - model->index(i, index.column(), index).data(ModList::IndexRole); - if (mIndex.isValid()) { - auto info = ModInfo::getByIndex(mIndex.toInt()); - downgrade = downgrade || info->downgradeAvailable(); - upgrade = upgrade || info->updateAvailable(); - } - } - - const int margin = - m_view->style()->pixelMetric(QStyle::PM_FocusFrameHMargin, nullptr, m_view) + 1; - - QStyleOptionViewItem opt(option); - const int sz = - m_view->style()->pixelMetric(QStyle::PM_SmallIconSize, nullptr, m_view); - opt.decorationSize = QSize(sz, sz); - opt.decorationAlignment = Qt::AlignCenter; - - if (upgrade) { - QIcon const icon(":/MO/gui/update_available"); - QPixmap const pixmap = decoration(opt, icon); - - QSize pm = icon.actualSize(opt.decorationSize); - pm.rwidth() += 2 * margin; - opt.rect.setRect(opt.rect.x(), opt.rect.y(), pm.width(), opt.rect.height()); - drawDecoration(painter, opt, opt.rect, pixmap); - - // add margin for next icon (if any) - opt.rect.adjust(opt.decorationSize.width() + margin, 0, 0, 0); - } - - if (downgrade) { - QIcon const icon(":/MO/gui/warning"); - QPixmap const pixmap = decoration(opt, icon); - - QSize pm = icon.actualSize(opt.decorationSize); - pm.rwidth() += 2 * margin; - opt.rect.setRect(opt.rect.x(), opt.rect.y(), pm.width(), opt.rect.height()); - - drawDecoration(painter, opt, opt.rect, pixmap); - } - } -} +#include "modlistversiondelegate.h" + +#include "log.h" +#include "modlistview.h" +#include "settings.h" + +ModListVersionDelegate::ModListVersionDelegate(ModListView* view, Settings& settings) + : QItemDelegate(view), m_view(view), m_settings(settings) +{} + +void ModListVersionDelegate::paint(QPainter* painter, + const QStyleOptionViewItem& option, + const QModelIndex& index) const +{ + m_view->itemDelegate()->paint(painter, option, index); + + if (m_view->hasCollapsibleSeparators() && m_view->model()->hasChildren(index) && + m_settings.interface().collapsibleSeparatorsIcons(ModList::COL_VERSION) && + !m_view->isExpanded(index.sibling(index.row(), 0))) { + auto* model = m_view->model(); + + bool downgrade = false; + bool upgrade = false; + + for (int i = 0; i < model->rowCount(index); ++i) { + const auto mIndex = + model->index(i, index.column(), index).data(ModList::IndexRole); + if (mIndex.isValid()) { + auto info = ModInfo::getByIndex(mIndex.toInt()); + downgrade = downgrade || info->downgradeAvailable(); + upgrade = upgrade || info->updateAvailable(); + } + } + + const int margin = + m_view->style()->pixelMetric(QStyle::PM_FocusFrameHMargin, nullptr, m_view) + 1; + + QStyleOptionViewItem opt(option); + const int sz = + m_view->style()->pixelMetric(QStyle::PM_SmallIconSize, nullptr, m_view); + opt.decorationSize = QSize(sz, sz); + opt.decorationAlignment = Qt::AlignCenter; + + if (upgrade) { + QIcon const icon(":/MO/gui/update_available"); + QPixmap const pixmap = decoration(opt, icon); + + QSize pm = icon.actualSize(opt.decorationSize); + pm.rwidth() += 2 * margin; + opt.rect.setRect(opt.rect.x(), opt.rect.y(), pm.width(), opt.rect.height()); + drawDecoration(painter, opt, opt.rect, pixmap); + + // add margin for next icon (if any) + opt.rect.adjust(opt.decorationSize.width() + margin, 0, 0, 0); + } + + if (downgrade) { + QIcon const icon(":/MO/gui/warning"); + QPixmap const pixmap = decoration(opt, icon); + + QSize pm = icon.actualSize(opt.decorationSize); + pm.rwidth() += 2 * margin; + opt.rect.setRect(opt.rect.x(), opt.rect.y(), pm.width(), opt.rect.height()); + + drawDecoration(painter, opt, opt.rect, pixmap); + } + } +} diff --git a/src/src/modlistview.cpp b/src/src/modlistview.cpp index 4b12ea5..89e0faa 100644 --- a/src/src/modlistview.cpp +++ b/src/src/modlistview.cpp @@ -1,1550 +1,1550 @@ -#include "modlistview.h" -#include -#include -#include - -#include - -#include "filesystemutilities.h" -#include - -#include "ui_mainwindow.h" - -#include "copyeventfilter.h" -#include "filterlist.h" -#include "genericicondelegate.h" -#include "log.h" -#include "mainwindow.h" -#include "modconflicticondelegate.h" -#include "modcontenticondelegate.h" -#include "modelutils.h" -#include "modflagicondelegate.h" -#include "modlist.h" -#include "modlistbypriorityproxy.h" -#include "modlistcontextmenu.h" -#include "modlistdropinfo.h" -#include "modlistsortproxy.h" -#include "modlistversiondelegate.h" -#include "modlistviewactions.h" -#include "organizercore.h" - -using namespace MOBase; -using namespace MOShared; - -// delegate to remove indentation for mods when using collapsible -// separator -// -// the delegate works by removing the indentation of the child items -// before drawing, but unfortunately this normally breaks event -// handling (e.g. checkbox, edit, etc.), so we also need to override -// the visualRect() function from the mod list view. -// -class ModListStyledItemDelegate : public QStyledItemDelegate -{ - ModListView* m_view; - -public: - ModListStyledItemDelegate(ModListView* view) : QStyledItemDelegate(view), m_view(view) - {} - - void initStyleOption(QStyleOptionViewItem* option, - const QModelIndex& index) const override - { - // the parent version always overwrite the background brush, so - // we need to save it and restore it - auto backgroundColor = option->backgroundBrush.color(); - QStyledItemDelegate::initStyleOption(option, index); - - if (backgroundColor.isValid()) { - option->backgroundBrush = backgroundColor; - } - } - - void paint(QPainter* painter, const QStyleOptionViewItem& option, - const QModelIndex& index) const override - { - QStyleOptionViewItem opt(option); - - // remove items indentaiton when using collapsible separators - if (index.column() == 0 && m_view->hasCollapsibleSeparators()) { - if (!index.model()->hasChildren(index) && index.parent().isValid()) { - auto parentIndex = index.parent().data(ModList::IndexRole).toInt(); - if (ModInfo::getByIndex(parentIndex)->isSeparator()) { - opt.rect.adjust(-m_view->indentation(), 0, 0, 0); - } - } - } - - // compute required color from children, otherwise fallback to the - // color from the model, and draw the background here - auto color = m_view->markerColor(index); - if (!color.isValid()) { - color = index.data(Qt::BackgroundRole).value(); - } - opt.backgroundBrush = color; - - // we need to find the background color to compute the ideal text color - // but the mod list view uses alternate color so we need to find the - // right color - auto bg = opt.palette.base().color(); - if (opt.features & QStyleOptionViewItem::Alternate) { - bg = opt.palette.alternateBase().color(); - } - - // compute ideal foreground color for some rows - if (color.isValid()) { - if (((index.column() == ModList::COL_NAME || - index.column() == ModList::COL_PRIORITY) && - ModInfo::getByIndex(index.data(ModList::IndexRole).toInt()) - ->isSeparator()) || - index.column() == ModList::COL_NOTES) { - - // combine the color with the background and then find the "ideal" text color - const auto a = color.alpha() / 255.; - int r = (1 - a) * bg.red() + a * color.red(), - g = (1 - a) * bg.green() + a * color.green(), - b = (1 - a) * bg.blue() + a * color.blue(); - opt.palette.setBrush(QPalette::Text, - ColorSettings::idealTextColor(QColor(r, g, b))); - } - } - - QStyledItemDelegate::paint(painter, opt, index); - } -}; - -class ModListViewMarkingScrollBar : public ViewMarkingScrollBar -{ - ModListView* m_view; - -public: - ModListViewMarkingScrollBar(ModListView* view) - : ViewMarkingScrollBar(view, ModList::ScrollMarkRole), m_view(view) - {} - - QColor color(const QModelIndex& index) const override - { - auto color = m_view->markerColor(index); - if (!color.isValid()) { - color = ViewMarkingScrollBar::color(index); - } - return color; - } -}; - -ModListView::ModListView(QWidget* parent) - : QTreeView(parent), - m_markers{{}, {}, {}, {}, {}, {}}, - m_scrollbar(new ModListViewMarkingScrollBar(this)) -{ - setVerticalScrollBar(m_scrollbar); - MOBase::setCustomizableColumns(this); - setAutoExpandDelay(750); - - setItemDelegate(new ModListStyledItemDelegate(this)); - - connect(this, &ModListView::doubleClicked, this, &ModListView::onDoubleClicked); - connect(this, &ModListView::customContextMenuRequested, this, - &ModListView::onCustomContextMenuRequested); - - // the timeout is pretty small because its main purpose is to avoid - // refreshing multiple times when calling expandAll() or collapseAll() - // which emit a lots of expanded/collapsed signals in a very small - // time window - m_refreshMarkersTimer.setInterval(50); - m_refreshMarkersTimer.setSingleShot(true); - connect(&m_refreshMarkersTimer, &QTimer::timeout, [=, this] { - refreshMarkersAndPlugins(); - }); - - installEventFilter(new CopyEventFilter(this, [=, this](auto& index) { - QVariant mIndex = index.data(ModList::IndexRole); - QString name = index.data(Qt::DisplayRole).toString(); - if (mIndex.isValid() && hasCollapsibleSeparators()) { - ModInfo::Ptr info = ModInfo::getByIndex(mIndex.toInt()); - if (info->isSeparator()) { - name = "[" + name + "]"; - } - } else if (model()->hasChildren(index)) { - name = "[" + name + "]"; - } - return name; - })); -} - -void ModListView::refresh() -{ - updateGroupByProxy(); -} - -void ModListView::onProfileChanged(Profile* oldProfile, Profile* newProfile) -{ - const auto perProfileSeparators = - m_core->settings().interface().collapsibleSeparatorsPerProfile(); - - // save expanded/collapsed state of separators - if (oldProfile && perProfileSeparators) { - auto& collapsed = m_collapsed[m_byPriorityProxy]; - oldProfile->storeSetting("UserInterface", "collapsed_separators", - QStringList(collapsed.begin(), collapsed.end())); - } - - m_sortProxy->setProfile(newProfile); - m_byPriorityProxy->setProfile(newProfile); - - if (newProfile && perProfileSeparators) { - auto collapsed = - newProfile->setting("UserInterface", "collapsed_separators", QStringList()) - .toStringList(); - m_collapsed[m_byPriorityProxy] = {collapsed.begin(), collapsed.end()}; - } -} - -bool ModListView::hasCollapsibleSeparators() const -{ - return groupByMode() == GroupByMode::SEPARATOR; -} - -int ModListView::sortColumn() const -{ - return m_sortProxy ? m_sortProxy->sortColumn() : -1; -} - -Qt::SortOrder ModListView::sortOrder() const -{ - return m_sortProxy ? m_sortProxy->sortOrder() : Qt::AscendingOrder; -} - -bool ModListView::isFilterActive() const -{ - return m_sortProxy && m_sortProxy->isFilterActive(); -} - -ModListView::GroupByMode ModListView::groupByMode() const -{ - if (m_sortProxy == nullptr) { - return GroupByMode::NONE; - } else if (m_sortProxy->sourceModel() == m_byPriorityProxy) { - return GroupByMode::SEPARATOR; - } else if (m_sortProxy->sourceModel() == m_byCategoryProxy) { - return GroupByMode::CATEGORY; - } else if (m_sortProxy->sourceModel() == m_byNexusIdProxy) { - return GroupByMode::NEXUS_ID; - } else { - return GroupByMode::NONE; - } -} - -ModListViewActions& ModListView::actions() const -{ - return *m_actions; -} - -std::optional ModListView::nextMod(unsigned int modIndex) const -{ - const QModelIndex start = indexModelToView(m_core->modList()->index(modIndex, 0)); - - auto index = start; - - for (;;) { - index = nextIndex(index); - - if (index == start || !index.isValid()) { - // wrapped around, give up - break; - } - - modIndex = index.data(ModList::IndexRole).toInt(); - - ModInfo::Ptr mod = ModInfo::getByIndex(modIndex); - - // skip overwrite, backups and separators - if (mod->isOverwrite() || mod->isBackup() || mod->isSeparator()) { - continue; - } - - return modIndex; - } - - return {}; -} - -std::optional ModListView::prevMod(unsigned int modIndex) const -{ - const QModelIndex start = indexModelToView(m_core->modList()->index(modIndex, 0)); - - auto index = start; - - for (;;) { - index = prevIndex(index); - - if (index == start || !index.isValid()) { - // wrapped around, give up - break; - } - - modIndex = index.data(ModList::IndexRole).toInt(); - - // skip overwrite, backups and separators - ModInfo::Ptr mod = ModInfo::getByIndex(modIndex); - if (mod->isOverwrite() || mod->isBackup() || mod->isSeparator()) { - continue; - } - - return modIndex; - } - - return {}; -} - -void ModListView::invalidateFilter() const -{ - m_sortProxy->invalidate(); -} - -void ModListView::setFilterCriteria( - const std::vector& criteria) const -{ - m_sortProxy->setCriteria(criteria); -} - -void ModListView::setFilterOptions(ModListSortProxy::FilterMode mode, - ModListSortProxy::SeparatorsMode sep) const -{ - m_sortProxy->setOptions(mode, sep); -} - -bool ModListView::isModVisible(unsigned int index) const -{ - return m_sortProxy->filterMatchesMod(ModInfo::getByIndex(index), - m_core->currentProfile()->modEnabled(index)); -} - -bool ModListView::isModVisible(ModInfo::Ptr mod) const -{ - return m_sortProxy->filterMatchesMod( - mod, m_core->currentProfile()->modEnabled(ModInfo::getIndex(mod->name()))); -} - -QModelIndex ModListView::indexModelToView(const QModelIndex& index) const -{ - return ::indexModelToView(index, this); -} - -QModelIndexList ModListView::indexModelToView(const QModelIndexList& index) const -{ - return ::indexModelToView(index, this); -} - -QModelIndex ModListView::indexViewToModel(const QModelIndex& index) const -{ - return ::indexViewToModel(index, m_core->modList()); -} - -QModelIndexList ModListView::indexViewToModel(const QModelIndexList& index) const -{ - return ::indexViewToModel(index, m_core->modList()); -} - -QModelIndex ModListView::nextIndex(const QModelIndex& index) -{ - auto* model = index.model(); - if (!model) { - return {}; - } - - if (model->rowCount(index) > 0) { - return model->index(0, index.column(), index); - } - - if (index.parent().isValid()) { - if (index.row() + 1 < model->rowCount(index.parent())) { - return index.model()->index(index.row() + 1, index.column(), index.parent()); - } else { - return index.model()->index((index.parent().row() + 1) % - model->rowCount(index.parent().parent()), - index.column(), index.parent().parent()); - ; - } - } else { - return index.model()->index((index.row() + 1) % model->rowCount(index.parent()), - index.column(), index.parent()); - } -} - -QModelIndex ModListView::prevIndex(const QModelIndex& index) -{ - if (index.row() == 0 && index.parent().isValid()) { - return index.parent(); - } - - auto* model = index.model(); - if (!model) { - return {}; - } - - auto prev = model->index((index.row() - 1) % model->rowCount(index.parent()), - index.column(), index.parent()); - - if (model->rowCount(prev) > 0) { - return model->index(model->rowCount(prev) - 1, index.column(), prev); - } - - return prev; -} - -std::pair ModListView::selected() const -{ - return {indexViewToModel(currentIndex()), - indexViewToModel(selectionModel()->selectedRows())}; -} - -void ModListView::setSelected(const QModelIndex& current, - const QModelIndexList& selected) -{ - setCurrentIndex(indexModelToView(current)); - for (auto idx : selected) { - selectionModel()->select(indexModelToView(idx), - QItemSelectionModel::Select | QItemSelectionModel::Rows); - } -} - -void ModListView::scrollToAndSelect(const QModelIndex& index) -{ - scrollToAndSelect(QModelIndexList{index}); -} - -void ModListView::scrollToAndSelect(const QModelIndexList& indexes, - const QModelIndex& current) -{ - // focus, scroll to and select - if (!current.isValid() && indexes.isEmpty()) { - return; - } - scrollTo(current.isValid() ? current : indexes.first()); - setCurrentIndex(current.isValid() ? current : indexes.first()); - QItemSelection selection; - for (auto& idx : indexes) { - selection.select(idx, idx); - } - selectionModel()->select(selection, - QItemSelectionModel::Select | QItemSelectionModel::Rows); - QTimer::singleShot(50, [=, this] { - setFocus(); - }); -} - -void ModListView::refreshExpandedItems() -{ - auto* model = m_sortProxy->sourceModel(); - for (auto i = 0; i < model->rowCount(); ++i) { - auto idx = model->index(i, 0); - if (!m_collapsed[model].contains(idx.data(Qt::DisplayRole).toString())) { - setExpanded(m_sortProxy->mapFromSource(idx), true); - } - } -} - -void ModListView::onModPrioritiesChanged(const QModelIndexList& indices) -{ - // expand separator whose priority has changed and parents - for (auto index : indices) { - auto idx = indexModelToView(index); - if (hasCollapsibleSeparators() && model()->hasChildren(idx)) { - setExpanded(idx, true); - } - if (idx.parent().isValid()) { - setExpanded(idx.parent(), true); - } - } - - setOverwriteMarkers(selectionModel()->selectedRows()); -} - -void ModListView::onModInstalled(const QString& modName) -{ - unsigned int index = ModInfo::getIndex(modName); - - if (index == UINT_MAX) { - return; - } - - QModelIndex qIndex = indexModelToView(m_core->modList()->index(index, 0)); - - if (hasCollapsibleSeparators() && qIndex.parent().isValid()) { - setExpanded(qIndex.parent(), true); - } - - scrollToAndSelect(qIndex); -} - -void ModListView::onModFilterActive(bool filterActive) -{ - ui.clearFilters->setVisible(filterActive); - if (filterActive) { - setStyleSheet("QTreeView { border: 2px ridge #f00; }"); - ui.counter->setStyleSheet("QLCDNumber { border: 2px ridge #f00; }"); - } else if (ui.groupBy->currentIndex() != GroupBy::NONE) { - setStyleSheet("QTreeView { border: 2px ridge #337733; }"); - ui.counter->setStyleSheet(""); - } else { - setStyleSheet(""); - ui.counter->setStyleSheet(""); - } -} - -ModListView::ModCounters ModListView::counters() const -{ - ModCounters c; - - auto hasFlag = [](std::vector flags, ModInfo::EFlag filter) { - return std::find(flags.begin(), flags.end(), filter) != flags.end(); - }; - - for (unsigned int index = 0; index < ModInfo::getNumMods(); ++index) { - auto info = ModInfo::getByIndex(index); - const auto flags = info->getFlags(); - - const bool enabled = m_core->currentProfile()->modEnabled(index); - const bool visible = m_sortProxy->filterMatchesMod(info, enabled); - - if (info->isBackup()) { - c.backup++; - if (visible) - c.visible.backup++; - } else if (info->isForeign()) { - c.foreign++; - if (visible) - c.visible.foreign++; - } else if (info->isSeparator()) { - c.separator++; - if (visible) - c.visible.separator++; - } else if (!info->isOverwrite()) { - c.regular++; - if (visible) - c.visible.regular++; - if (enabled) { - c.active++; - if (visible) - c.visible.active++; - } - } - } - - return c; -} - -void ModListView::updateModCount() -{ - const auto c = counters(); - - ui.counter->display(c.visible.active); - ui.counter->setToolTip(tr("" - "" - "" - "" - "" - "" - "
    TypeAllVisible
    Enabled mods: %1 / " - "%2%3 / %4
    Unmanaged/DLCs: %5%6
    Mod backups: %7%8
    Separators: %9%10
    ") - .arg(c.active) - .arg(c.regular) - .arg(c.visible.active) - .arg(c.visible.regular) - .arg(c.foreign) - .arg(c.visible.foreign) - .arg(c.backup) - .arg(c.visible.backup) - .arg(c.separator) - .arg(c.visible.separator)); -} - -void ModListView::refreshFilters() -{ - auto [current, sourceRows] = selected(); - - setCurrentIndex(QModelIndex()); - m_filters->refresh(); - - setSelected(current, sourceRows); -} - -void ModListView::onExternalFolderDropped(const QUrl& url, int priority) -{ - setWindowState(Qt::WindowActive); - - QFileInfo fileInfo(url.toLocalFile()); - - GuessedValue name; - name.setFilter(&fixDirectoryName); - name.update(fileInfo.fileName(), GUESS_PRESET); - - do { - bool ok; - name.update( - QInputDialog::getText(this, tr("Copy Folder..."), - tr("This will copy the content of %1 to a new mod.\n" - "Please enter the name:") - .arg(fileInfo.fileName()), - QLineEdit::Normal, name, &ok), - GUESS_USER); - if (!ok) { - return; - } - } while (name->isEmpty()); - - if (m_core->modList()->getMod(name) != nullptr) { - reportError(tr("A mod with this name already exists.")); - return; - } - - IModInterface* newMod = m_core->createMod(name); - if (!newMod) { - return; - } - - // TODO: this is currently a silent copy, which can take some time, but there is - // no clean method to do this in uibase - if (!copyDir(fileInfo.absoluteFilePath(), newMod->absolutePath(), true)) { - return; - } - - m_core->refresh(); - - const auto index = ModInfo::getIndex(name); - if (priority != -1) { - m_core->modList()->changeModPriority(index, priority); - } - - scrollToAndSelect(indexModelToView(m_core->modList()->index(index, 0))); -} - -bool ModListView::moveSelection(int key) -{ - auto rows = selectionModel()->selectedRows(); - const QPersistentModelIndex current(key == Qt::Key_Up ? rows.first() : rows.last()); - - int offset = key == Qt::Key_Up ? -1 : 1; - if (m_sortProxy->sortOrder() == Qt::DescendingOrder) { - offset = -offset; - } - - m_core->modList()->shiftModsPriority(indexViewToModel(rows), offset); - selectionModel()->setCurrentIndex(current, QItemSelectionModel::NoUpdate); - scrollTo(current); - - return true; -} - -bool ModListView::removeSelection() -{ - m_actions->removeMods(indexViewToModel(selectionModel()->selectedRows())); - return true; -} - -bool ModListView::toggleSelectionState() -{ - if (!selectionModel()->hasSelection()) { - return true; - } - return m_core->modList()->toggleState( - indexViewToModel(selectionModel()->selectedRows())); -} - -void ModListView::updateGroupByProxy() -{ - if (m_restoringHeaderState) { - return; - } - - int groupIndex = ui.groupBy->currentIndex(); - auto* previousModel = m_sortProxy->sourceModel(); - - QAbstractItemModel* nextModel = m_core->modList(); - if (groupIndex == GroupBy::CATEGORY) { - nextModel = m_byCategoryProxy; - } else if (groupIndex == GroupBy::NEXUS_ID) { - nextModel = m_byNexusIdProxy; - } else if (m_core->settings().interface().collapsibleSeparators( - m_sortProxy->sortOrder()) && - m_sortProxy->sortColumn() == ModList::COL_PRIORITY) { - m_byPriorityProxy->setSortOrder(m_sortProxy->sortOrder()); - nextModel = m_byPriorityProxy; - } - - if (nextModel != previousModel) { - - if (auto* proxy = dynamic_cast(nextModel)) { - proxy->setSourceModel(m_core->modList()); - } - m_sortProxy->setSourceModel(nextModel); - - // reset the source model of the old proxy because we do not want to - // react to signals - // - if (auto* proxy = qobject_cast(previousModel)) { - proxy->setSourceModel(nullptr); - } - - // expand items previously expanded - refreshExpandedItems(); - - if (hasCollapsibleSeparators()) { - ui.filterSeparators->setCurrentIndex(ModListSortProxy::SeparatorFilter); - ui.filterSeparators->setEnabled(false); - } else { - ui.filterSeparators->setEnabled(true); - } - } -} - -void ModListView::setup(OrganizerCore& core, CategoryFactory& factory, MainWindow* mw, - Ui::MainWindow* mwui) -{ - // attributes - m_core = &core; - m_filters.reset(new FilterList(mwui, core, factory)); - m_categories = &factory; - m_actions = - new ModListViewActions(core, *m_filters, factory, this, mwui->espList, mw); - ui = {mwui->groupCombo, - mwui->activeModsCounter, - mwui->modFilterEdit, - mwui->currentCategoryLabel, - mwui->clearFiltersButton, - mwui->filtersSeparators, - mwui->espList}; - - connect(m_core, &OrganizerCore::modInstalled, [=, this](auto&& name) { - onModInstalled(name); - }); - connect(m_core, &OrganizerCore::profileChanged, this, &ModListView::onProfileChanged); - connect(core.modList(), &ModList::modPrioritiesChanged, [=, this](auto&& indices) { - onModPrioritiesChanged(indices); - }); - connect(core.modList(), &ModList::clearOverwrite, [=, this] { - m_actions->clearOverwrite(); - }); - connect(core.modList(), &ModList::modStatesChanged, [=, this] { - updateModCount(); - setOverwriteMarkers(selectionModel()->selectedRows()); - }); - connect(core.modList(), &ModList::modelReset, [=, this] { - clearOverwriteMarkers(); - }); - - // proxy for various group by - m_byPriorityProxy = - new ModListByPriorityProxy(core.currentProfile().get(), core, this); - m_byCategoryProxy = new QtGroupingProxy(QModelIndex(), ModList::COL_CATEGORY, - ModList::GroupingRole, 0, ModList::AggrRole); - m_byNexusIdProxy = new QtGroupingProxy( - QModelIndex(), ModList::COL_MODID, ModList::GroupingRole, - QtGroupingProxy::FLAG_NOGROUPNAME | QtGroupingProxy::FLAG_NOSINGLE, - ModList::AggrRole); - - // we need to store the expanded/collapsed state of all items and restore them 1) when - // switching proxies, 2) when filtering and 3) when reseting the mod list. - connect(this, &QTreeView::expanded, [=, this](const QModelIndex& index) { - auto it = m_collapsed[m_sortProxy->sourceModel()].find( - index.data(Qt::DisplayRole).toString()); - if (it != m_collapsed[m_sortProxy->sourceModel()].end()) { - m_collapsed[m_sortProxy->sourceModel()].erase(it); - } - }); - connect(this, &QTreeView::collapsed, [=, this](const QModelIndex& index) { - m_collapsed[m_sortProxy->sourceModel()].insert( - index.data(Qt::DisplayRole).toString()); - }); - - // the top-level proxy - m_sortProxy = new ModListSortProxy(core.currentProfile().get(), &core); - setModel(m_sortProxy); - connect(m_sortProxy, &ModList::modelReset, [=, this] { - refreshExpandedItems(); - }); - - // update the proxy when changing the sort column/direction and the group - connect(m_sortProxy, &QAbstractItemModel::layoutAboutToBeChanged, - [this](auto&& parents, auto&& hint) { - if (hint == QAbstractItemModel::VerticalSortHint) { - updateGroupByProxy(); - } - }); - connect(ui.groupBy, QOverload::of(&QComboBox::currentIndexChanged), - [=, this](int index) { - updateGroupByProxy(); - onModFilterActive(m_sortProxy->isFilterActive()); - }); - sortByColumn(ModList::COL_PRIORITY, Qt::AscendingOrder); - - // inform the mod list about the type of item being dropped at the beginning of a drag - // and the position of the drop indicator at the end (only for by-priority) - connect(this, &ModListView::dragEntered, core.modList(), &ModList::onDragEnter); - connect(this, &ModListView::dropEntered, m_byPriorityProxy, - &ModListByPriorityProxy::onDropEnter); - - connect(m_sortProxy, &ModListSortProxy::filterInvalidated, this, - &ModListView::updateModCount); - - connect(header(), &QHeaderView::sortIndicatorChanged, [=, this](int, Qt::SortOrder) { - verticalScrollBar()->repaint(); - }); - connect(header(), &QHeaderView::sectionResized, - [=, this](int logicalIndex, int oldSize, int newSize) { - m_sortProxy->setColumnVisible(logicalIndex, newSize != 0); - }); - - setItemDelegateForColumn(ModList::COL_FLAGS, - new ModFlagIconDelegate(this, ModList::COL_FLAGS, 120)); - setItemDelegateForColumn( - ModList::COL_CONFLICTFLAGS, - new ModConflictIconDelegate(this, ModList::COL_CONFLICTFLAGS, 80)); - setItemDelegateForColumn(ModList::COL_CONTENT, - new ModContentIconDelegate(this, ModList::COL_CONTENT, 150)); - setItemDelegateForColumn(ModList::COL_VERSION, - new ModListVersionDelegate(this, core.settings())); - - m_restoringHeaderState = true; - const bool headerRestored = m_core->settings().geometry().restoreState(header()); - m_restoringHeaderState = false; - - if (headerRestored) { - // hack: force the resize-signal to be triggered because restoreState doesn't seem - // to do that - for (int column = 0; column <= ModList::COL_LASTCOLUMN; ++column) { - int sectionSize = header()->sectionSize(column); - header()->resizeSection(column, sectionSize + 1); - header()->resizeSection(column, sectionSize); - } - } else { - // hide these columns by default - header()->setSectionHidden(ModList::COL_CONTENT, true); - header()->setSectionHidden(ModList::COL_MODID, true); - header()->setSectionHidden(ModList::COL_UPLOADER, true); - header()->setSectionHidden(ModList::COL_GAME, true); - header()->setSectionHidden(ModList::COL_INSTALLTIME, true); - header()->setSectionHidden(ModList::COL_NOTES, true); - - // resize mod list to fit content - for (int i = 0; i < header()->count(); ++i) { - header()->setSectionResizeMode(i, QHeaderView::ResizeToContents); - } - - header()->setSectionResizeMode(ModList::COL_NAME, QHeaderView::Stretch); - } - - // prevent the name-column from being hidden - header()->setSectionHidden(ModList::COL_NAME, false); - - // we need QueuedConnection for the download/archive dropped otherwise the - // installation starts within the drop-event and it's not possible to drag&drop - // in the manual installer - connect( - m_core->modList(), &ModList::downloadArchiveDropped, this, - [=, this](int row, int priority) { - m_core->installDownload(row, priority); - }, - Qt::QueuedConnection); - connect( - m_core->modList(), &ModList::externalArchiveDropped, this, - [=, this](const QUrl& url, int priority) { - setWindowState(Qt::WindowActive); - m_core->installArchive(url.toLocalFile(), priority, false, nullptr); - }, - Qt::QueuedConnection); - connect(m_core->modList(), &ModList::externalFolderDropped, this, - &ModListView::onExternalFolderDropped); - - connect(selectionModel(), &QItemSelectionModel::selectionChanged, [=, this] { - m_refreshMarkersTimer.start(); - }); - connect(this, &QTreeView::collapsed, [=, this] { - m_refreshMarkersTimer.start(); - }); - connect(this, &QTreeView::expanded, [=, this] { - m_refreshMarkersTimer.start(); - }); - - // filters - connect(m_sortProxy, &ModListSortProxy::filterActive, this, - &ModListView::onModFilterActive); - connect(m_filters.get(), &FilterList::criteriaChanged, [=, this](auto&& v) { - onFiltersCriteria(v); - }); - connect(m_filters.get(), &FilterList::optionsChanged, [=, this](auto&& mode, auto&& sep) { - setFilterOptions(mode, sep); - }); - connect(ui.filter, &QLineEdit::textChanged, m_sortProxy, - &ModListSortProxy::updateFilter); - connect(ui.clearFilters, &QPushButton::clicked, [=, this]() { - ui.filter->clear(); - m_filters->clearSelection(); - }); - connect(m_sortProxy, &ModListSortProxy::filterInvalidated, [=, this]() { - if (hasCollapsibleSeparators()) { - refreshExpandedItems(); - } - }); -} - -void ModListView::restoreState(const Settings& s) -{ - // Restore groupBy BEFORE header state: changing the groupBy combo triggers - // updateGroupByProxy() which replaces the sort proxy's source model, resetting - // the header. By setting it first, the model is stable when header state - // (column visibility, widths, order) is restored. - s.widgets().restoreIndex(ui.groupBy); - - m_restoringHeaderState = true; - s.geometry().restoreState(header()); - m_restoringHeaderState = false; - - s.widgets().restoreTreeExpandState(this); - - m_filters->restoreState(s); -} - -void ModListView::saveState(Settings& s) const -{ - s.geometry().saveState(header()); - - s.widgets().saveIndex(ui.groupBy); - s.widgets().saveTreeExpandState(this); - - m_filters->saveState(s); -} - -QRect ModListView::visualRect(const QModelIndex& index) const -{ - // this shift the visualRect() from QTreeView to match the new actual - // zone after removing indentation (see the ModListStyledItemDelegate) - QRect rect = QTreeView::visualRect(index); - if (hasCollapsibleSeparators() && index.column() == 0 && index.isValid() && - index.parent().isValid()) { - rect.adjust(-indentation(), 0, 0, 0); - } - return rect; -} - -void ModListView::drawBranches(QPainter* painter, const QRect& rect, - const QModelIndex& index) const -{ - // the branches are the small indicator left to the row (there are none in the default - // style, and the VS dark style only has background for these) - // - // the branches are not shifted left with the visualRect() change and since MO2 uses - // stylesheet, it is not possible to shift those in the proxy style so we have to - // shift it here. - // - QRect r(rect); - if (hasCollapsibleSeparators() && index.parent().isValid()) { - r.adjust(-indentation(), 0, 0 - indentation(), 0); - } - QTreeView::drawBranches(painter, r, index); -} - -void ModListView::commitData(QWidget* editor) -{ - // maintain the selection when changing priority - if (currentIndex().column() == ModList::COL_PRIORITY) { - auto [current, selected] = this->selected(); - QTreeView::commitData(editor); - setSelected(current, selected); - } else { - QTreeView::commitData(editor); - } -} - -QModelIndexList ModListView::selectedIndexes() const -{ - // during drag&drop events, we fake the return value of selectedIndexes() - // to allow drag&drop of a parent into its children - // - // this is only "active" during the actual dragXXXEvent and dropEvent method, - // not during the whole drag&drop event - // - // selectedIndexes() is a protected method from QTreeView which is little - // used so this should not break anything - // - return m_inDragMoveEvent ? QModelIndexList() : QTreeView::selectedIndexes(); -} - -void ModListView::onCustomContextMenuRequested(const QPoint& pos) -{ - try { - QModelIndex contextIdx = indexViewToModel(indexAt(pos)); - - if (!contextIdx.isValid()) { - // no selection - ModListGlobalContextMenu(*m_core, this).exec(viewport()->mapToGlobal(pos)); - } else { - ModListContextMenu(contextIdx, *m_core, m_categories, this) - .exec(viewport()->mapToGlobal(pos)); - } - } catch (const std::exception& e) { - reportError(tr("Exception: ").arg(e.what())); - } catch (...) { - reportError(tr("Unknown exception")); - } -} - -void ModListView::onDoubleClicked(const QModelIndex& index) -{ - if (!index.isValid()) { - return; - } - - if (m_core->modList()->timeElapsedSinceLastChecked() <= - QApplication::doubleClickInterval()) { - // don't interpret double click if we only just checked a mod - return; - } - - bool indexOk = false; - int modIndex = index.data(ModList::IndexRole).toInt(&indexOk); - - if (!indexOk || modIndex < 0 || modIndex >= ModInfo::getNumMods()) { - return; - } - - ModInfo::Ptr modInfo = ModInfo::getByIndex(modIndex); - - const auto modifiers = QApplication::queryKeyboardModifiers(); - if (modifiers.testFlag(Qt::ControlModifier)) { - try { - shell::Explore(modInfo->absolutePath()); - } catch (const std::exception& e) { - reportError(e.what()); - } - } else if (modifiers.testFlag(Qt::ShiftModifier)) { - try { - actions().visitNexusOrWebPage({indexViewToModel(index)}); - } catch (const std::exception& e) { - reportError(e.what()); - } - } else if (hasCollapsibleSeparators() && modInfo->isSeparator()) { - setExpanded(index, !isExpanded(index)); - } else { - try { - auto tab = ModInfoTabIDs::None; - - switch (index.column()) { - case ModList::COL_NOTES: - tab = ModInfoTabIDs::Notes; - break; - case ModList::COL_VERSION: - tab = ModInfoTabIDs::Nexus; - break; - case ModList::COL_MODID: - tab = ModInfoTabIDs::Nexus; - break; - case ModList::COL_GAME: - tab = ModInfoTabIDs::Nexus; - break; - case ModList::COL_CATEGORY: - tab = ModInfoTabIDs::Categories; - break; - case ModList::COL_CONFLICTFLAGS: - tab = ModInfoTabIDs::Conflicts; - break; - } - - actions().displayModInformation(modIndex, tab); - } catch (const std::exception& e) { - reportError(e.what()); - } - } - - // workaround to cancel the editor that might have opened because of - // selection-click - closePersistentEditor(index); -} - -void ModListView::clearOverwriteMarkers() -{ - m_markers.overwrite.clear(); - m_markers.overwritten.clear(); - m_markers.archiveOverwrite.clear(); - m_markers.archiveOverwritten.clear(); - m_markers.archiveLooseOverwrite.clear(); - m_markers.archiveLooseOverwritten.clear(); -} - -void ModListView::setOverwriteMarkers(const QModelIndexList& indexes) -{ - const auto insert = [](auto& dest, const auto& from) { - dest.insert(from.begin(), from.end()); - }; - clearOverwriteMarkers(); - for (auto& idx : indexes) { - auto mIndex = idx.data(ModList::IndexRole); - if (mIndex.isValid()) { - auto info = ModInfo::getByIndex(mIndex.toInt()); - insert(m_markers.overwrite, info->getModOverwrite()); - insert(m_markers.overwritten, info->getModOverwritten()); - insert(m_markers.archiveOverwrite, info->getModArchiveOverwrite()); - insert(m_markers.archiveOverwritten, info->getModArchiveOverwritten()); - insert(m_markers.archiveLooseOverwrite, info->getModArchiveLooseOverwrite()); - insert(m_markers.archiveLooseOverwritten, info->getModArchiveLooseOverwritten()); - } - } - dataChanged(model()->index(0, 0), - model()->index(model()->rowCount() ? model()->rowCount() - 1 : 0, - model()->columnCount() ? model()->columnCount() - 1 : 0)); - verticalScrollBar()->repaint(); -} - -void ModListView::refreshMarkersAndPlugins() -{ - QModelIndexList indexes = selectionModel()->selectedRows(); - - if (m_core->settings().interface().collapsibleSeparatorsHighlightFrom()) { - for (auto& idx : selectionModel()->selectedRows()) { - if (hasCollapsibleSeparators() && model()->hasChildren(idx) && !isExpanded(idx)) { - for (int i = 0; i < model()->rowCount(idx); ++i) { - indexes.append(model()->index(i, idx.column(), idx)); - } - } - } - } - - setOverwriteMarkers(indexes); - - // highlight plugins - std::vector modIndices; - for (auto& idx : indexes) { - modIndices.push_back(idx.data(ModList::IndexRole).toInt()); - } - m_core->pluginList()->highlightPlugins(modIndices, *m_core->directoryStructure()); - ui.pluginList->verticalScrollBar()->repaint(); -} - -void ModListView::setHighlightedMods(const std::set& modNames) -{ - m_markers.highlight.clear(); - for (const auto& modName : modNames) { - const auto index = ModInfo::getIndex(modName); - if (index != UINT_MAX) { - m_markers.highlight.insert(index); - } - } - dataChanged(model()->index(0, 0), - model()->index(model()->rowCount() ? model()->rowCount() - 1 : 0, - model()->columnCount() ? model()->columnCount() - 1 : 0)); - verticalScrollBar()->repaint(); -} - -QColor ModListView::markerColor(const QModelIndex& index) const -{ - unsigned int modIndex = index.data(ModList::IndexRole).toInt(); - bool highlight = m_markers.highlight.contains(modIndex); - bool overwrite = m_markers.overwrite.contains(modIndex); - bool archiveOverwrite = - m_markers.archiveOverwrite.contains(modIndex); - bool archiveLooseOverwrite = m_markers.archiveLooseOverwrite.contains(modIndex); - bool overwritten = - m_markers.overwritten.contains(modIndex); - bool archiveOverwritten = - m_markers.archiveOverwritten.contains(modIndex); - bool archiveLooseOverwritten = m_markers.archiveLooseOverwritten.contains(modIndex); - - if (highlight) { - return Settings::instance().colors().modlistContainsFile(); - } else if (overwritten || archiveLooseOverwritten) { - return Settings::instance().colors().modlistOverwritingLoose(); - } else if (overwrite || archiveLooseOverwrite) { - return Settings::instance().colors().modlistOverwrittenLoose(); - } else if (archiveOverwritten) { - return Settings::instance().colors().modlistOverwritingArchive(); - } else if (archiveOverwrite) { - return Settings::instance().colors().modlistOverwrittenArchive(); - } - - // collapsed separator - auto rowIndex = index.sibling(index.row(), 0); - if (hasCollapsibleSeparators() && - m_core->settings().interface().collapsibleSeparatorsHighlightTo() && - model()->hasChildren(rowIndex) && !isExpanded(rowIndex)) { - - std::vector colors; - for (int i = 0; i < model()->rowCount(rowIndex); ++i) { - auto childColor = markerColor(model()->index(i, index.column(), rowIndex)); - if (childColor.isValid()) { - colors.push_back(childColor); - } - } - - if (colors.empty()) { - return {}; - } - - int r = 0, g = 0, b = 0, a = 0; - for (auto& color : colors) { - r += color.red(); - g += color.green(); - b += color.blue(); - a += color.alpha(); - } - - return {r / colors.size(), g / colors.size(), b / colors.size(), - a / colors.size()}; - } - - return {}; -} - -std::vector ModListView::modFlags(const QModelIndex& index, - bool* forceCompact) const -{ - ModInfo::Ptr info = ModInfo::getByIndex(index.data(ModList::IndexRole).toInt()); - - auto flags = info->getFlags(); - bool compact = false; - if (info->isSeparator() && hasCollapsibleSeparators() && - m_core->settings().interface().collapsibleSeparatorsIcons(ModList::COL_FLAGS) && - !isExpanded(index.sibling(index.row(), 0))) { - - // combine the child conflicts - std::set eFlags(flags.begin(), flags.end()); - for (int i = 0; i < model()->rowCount(index); ++i) { - auto cIndex = - model()->index(i, index.column(), index).data(ModList::IndexRole).toInt(); - auto cFlags = ModInfo::getByIndex(cIndex)->getFlags(); - eFlags.insert(cFlags.begin(), cFlags.end()); - } - flags = {eFlags.begin(), eFlags.end()}; - - // force compact because there can be a lots of flags here - compact = true; - } - - if (forceCompact) { - *forceCompact = true; - } - - return flags; -} - -std::vector ModListView::conflictFlags(const QModelIndex& index, - bool* forceCompact) const -{ - ModInfo::Ptr info = ModInfo::getByIndex(index.data(ModList::IndexRole).toInt()); - - auto flags = info->getConflictFlags(); - bool compact = false; - if (info->isSeparator() && hasCollapsibleSeparators() && - m_core->settings().interface().collapsibleSeparatorsIcons( - ModList::COL_CONFLICTFLAGS) && - !isExpanded(index.sibling(index.row(), 0))) { - - // combine the child conflicts - std::set eFlags(flags.begin(), flags.end()); - for (int i = 0; i < model()->rowCount(index); ++i) { - auto cIndex = - model()->index(i, index.column(), index).data(ModList::IndexRole).toInt(); - auto cFlags = ModInfo::getByIndex(cIndex)->getConflictFlags(); - eFlags.insert(cFlags.begin(), cFlags.end()); - } - flags = {eFlags.begin(), eFlags.end()}; - - // force compact because there can be a lots of flags here - compact = true; - } - - if (forceCompact) { - *forceCompact = true; - } - - return flags; -} - -std::set ModListView::contents(const QModelIndex& index, - bool* includeChildren) const -{ - auto modIndex = index.data(ModList::IndexRole); - if (!modIndex.isValid()) { - return {}; - } - ModInfo::Ptr info = ModInfo::getByIndex(index.data(ModList::IndexRole).toInt()); - auto contents = info->getContents(); - bool children = false; - - if (info->isSeparator() && hasCollapsibleSeparators() && - m_core->settings().interface().collapsibleSeparatorsIcons(ModList::COL_CONTENT) && - !isExpanded(index.sibling(index.row(), 0))) { - - // combine the child contents - std::set eContents(contents.begin(), contents.end()); - for (int i = 0; i < model()->rowCount(index); ++i) { - auto cIndex = - model()->index(i, index.column(), index).data(ModList::IndexRole).toInt(); - auto cContents = ModInfo::getByIndex(cIndex)->getContents(); - eContents.insert(cContents.begin(), cContents.end()); - } - contents = {eContents.begin(), eContents.end()}; - children = true; - } - - if (includeChildren) { - *includeChildren = children; - } - - return contents; -} - -QList ModListView::contentsIcons(const QModelIndex& index, - bool* forceCompact) const -{ - auto contents = this->contents(index, forceCompact); - QList result; - m_core->modDataContents().forEachContentInOrOut( - contents, - [&result](auto const& content) { - result.append(content.icon()); - }, - [&result](auto const&) { - result.append(QString()); - }); - return result; -} - -QString ModListView::contentsTooltip(const QModelIndex& index) const -{ - auto contents = this->contents(index, nullptr); - if (contents.empty()) { - return {}; - } - QString result(""); - m_core->modDataContents().forEachContentIn(contents, [&result](auto const& content) { - result.append(QString("" - "") - .arg(content.icon()) - .arg(content.name())); - }); - result.append("
    %2
    "); - return result; -} - -void ModListView::onFiltersCriteria( - const std::vector& criteria) -{ - setFilterCriteria(criteria); - - QString label = "?"; - - if (criteria.empty()) { - label = ""; - } else if (criteria.size() == 1) { - const auto& c = criteria[0]; - - if (c.type == ModListSortProxy::TypeContent) { - const auto* content = m_core->modDataContents().findById(c.id); - label = content ? content->name() : QString(); - } else { - label = m_categories->getCategoryNameByID(c.id); - } - - if (label.isEmpty()) { - log::error("category {}:{} not found", c.type, c.id); - } - } else { - label = tr(""); - } - - ui.currentCategory->setText(label); -} - -void ModListView::dragEnterEvent(QDragEnterEvent* event) -{ - // this event is used by the modlist to check if we are draggin - // to a mod (local files) or to a priority (mods, downloads, external - // files) - emit dragEntered(event->mimeData()); - QTreeView::dragEnterEvent(event); - - // there is no drop event for invalid data since canDropMimeData - // returns false, so we notify user on drag enter - ModListDropInfo dropInfo(event->mimeData(), *m_core); - - if (dropInfo.isValid() && !dropInfo.isLocalFileDrop() && - sortColumn() != ModList::COL_PRIORITY) { - log::warn("Drag&Drop is only supported when sorting by priority."); - } -} - -void ModListView::dragMoveEvent(QDragMoveEvent* event) -{ - // this replace the openTimer from QTreeView to prevent - // auto-collapse of items - if (autoExpandDelay() >= 0) { - m_openTimer.start(autoExpandDelay(), this); - } - - // see selectedIndexes() - m_inDragMoveEvent = true; - QAbstractItemView::dragMoveEvent(event); - m_inDragMoveEvent = false; -} - -void ModListView::dropEvent(QDropEvent* event) -{ - // from Qt source - QModelIndex index; - if (viewport()->rect().contains(event->position().toPoint())) { - index = indexAt(event->position().toPoint()); - if (!index.isValid() || !visualRect(index).contains(event->position().toPoint())) - index = QModelIndex(); - } - - // this event is used by the byPriorityProxy to know if allow - // dropping mod between a separator and its first mod (there - // is no way to deduce this except using dropIndicatorPosition()) - emit dropEntered(event->mimeData(), isExpanded(index), - static_cast(dropIndicatorPosition())); - - // see selectedIndexes() - m_inDragMoveEvent = true; - QTreeView::dropEvent(event); - m_inDragMoveEvent = false; -} - -void ModListView::timerEvent(QTimerEvent* event) -{ - // prevent auto-collapse, see dragMoveEvent() - if (event->timerId() == m_openTimer.timerId()) { - QPoint pos = viewport()->mapFromGlobal(QCursor::pos()); - if (state() == QAbstractItemView::DraggingState && - viewport()->rect().contains(pos)) { - QModelIndex index = indexAt(pos); - setExpanded(index, !m_core->settings().interface().autoCollapseOnHover() || - !isExpanded(index)); - } - m_openTimer.stop(); - } else { - QTreeView::timerEvent(event); - } -} - -void ModListView::mousePressEvent(QMouseEvent* event) -{ - // allow alt+click to select all mods inside a separator - // when using collapsible separators - // - // similar code is also present in mouseReleaseEvent to - // avoid missing events - - // disable edit if Alt is pressed - auto triggers = editTriggers(); - if (event->modifiers() & Qt::AltModifier) { - setEditTriggers(NoEditTriggers); - } - - // we call the parent class first so that we can use the actual - // selection state of the item after - QTreeView::mousePressEvent(event); - - // restore triggers - setEditTriggers(triggers); - - const auto index = indexAt(event->position().toPoint()); - - if (event->isAccepted() && hasCollapsibleSeparators() && index.isValid() && - model()->hasChildren(indexAt(event->position().toPoint())) && - (event->modifiers() & Qt::AltModifier)) { - - const auto flag = selectionModel()->isSelected(index) - ? QItemSelectionModel::Select - : QItemSelectionModel::Deselect; - const QItemSelection selection( - model()->index(0, index.column(), index), - model()->index(model()->rowCount(index) - 1, index.column(), index)); - selectionModel()->select(selection, flag | QItemSelectionModel::Rows); - } -} - -void ModListView::mouseReleaseEvent(QMouseEvent* event) -{ - // this is a duplicate of mousePressEvent because for some reason - // the selection is not always triggered in mousePressEvent and only - // doing it here create a small lag between the selection of the - // separator and the children - - // disable edit if Alt is pressed - auto triggers = editTriggers(); - if (event->modifiers() & Qt::AltModifier) { - setEditTriggers(NoEditTriggers); - } - - // we call the parent class first so that we can use the actual - // selection state of the item after - QTreeView::mouseReleaseEvent(event); - - const auto index = indexAt(event->position().toPoint()); - - // restore triggers - setEditTriggers(triggers); - - if (event->isAccepted() && hasCollapsibleSeparators() && index.isValid() && - model()->hasChildren(indexAt(event->position().toPoint())) && - (event->modifiers() & Qt::AltModifier)) { - - const auto flag = selectionModel()->isSelected(index) - ? QItemSelectionModel::Select - : QItemSelectionModel::Deselect; - const QItemSelection selection( - model()->index(0, index.column(), index), - model()->index(model()->rowCount(index) - 1, index.column(), index)); - selectionModel()->select(selection, flag | QItemSelectionModel::Rows); - } -} - -bool ModListView::event(QEvent* event) -{ - if (event->type() == QEvent::KeyPress && m_core->currentProfile() && - selectionModel()->hasSelection()) { - QKeyEvent* keyEvent = static_cast(event); - - auto index = selectionModel()->currentIndex(); - - if (keyEvent->modifiers() == Qt::ControlModifier) { - // ctrl+enter open explorer - if (keyEvent->key() == Qt::Key_Return || keyEvent->key() == Qt::Key_Enter) { - if (selectionModel()->selectedRows().count() == 1) { - m_actions->openExplorer({indexViewToModel(index)}); - return true; - } - } - // ctrl+up/down move selection - else if (sortColumn() == ModList::COL_PRIORITY && - (keyEvent->key() == Qt::Key_Up || keyEvent->key() == Qt::Key_Down)) { - return moveSelection(keyEvent->key()); - } - } else if (keyEvent->modifiers() == Qt::ShiftModifier) { - // shift+enter expand - if ((keyEvent->key() == Qt::Key_Return || keyEvent->key() == Qt::Key_Enter) && - selectionModel()->selectedRows().count() == 1) { - if (model()->hasChildren(index)) { - setExpanded(index, !isExpanded(index)); - } else if (index.parent().isValid()) { - setExpanded(index.parent(), false); - selectionModel()->select(index.parent(), QItemSelectionModel::SelectCurrent | - QItemSelectionModel::Rows); - setCurrentIndex(index.parent()); - } - } - } else { - if (keyEvent->key() == Qt::Key_Delete) { - return removeSelection(); - } else if (keyEvent->key() == Qt::Key_Space) { - return toggleSelectionState(); - } - } - return QTreeView::event(event); - } - return QTreeView::event(event); -} +#include "modlistview.h" +#include +#include +#include + +#include + +#include "filesystemutilities.h" +#include + +#include "ui_mainwindow.h" + +#include "copyeventfilter.h" +#include "filterlist.h" +#include "genericicondelegate.h" +#include "log.h" +#include "mainwindow.h" +#include "modconflicticondelegate.h" +#include "modcontenticondelegate.h" +#include "modelutils.h" +#include "modflagicondelegate.h" +#include "modlist.h" +#include "modlistbypriorityproxy.h" +#include "modlistcontextmenu.h" +#include "modlistdropinfo.h" +#include "modlistsortproxy.h" +#include "modlistversiondelegate.h" +#include "modlistviewactions.h" +#include "organizercore.h" + +using namespace MOBase; +using namespace MOShared; + +// delegate to remove indentation for mods when using collapsible +// separator +// +// the delegate works by removing the indentation of the child items +// before drawing, but unfortunately this normally breaks event +// handling (e.g. checkbox, edit, etc.), so we also need to override +// the visualRect() function from the mod list view. +// +class ModListStyledItemDelegate : public QStyledItemDelegate +{ + ModListView* m_view; + +public: + ModListStyledItemDelegate(ModListView* view) : QStyledItemDelegate(view), m_view(view) + {} + + void initStyleOption(QStyleOptionViewItem* option, + const QModelIndex& index) const override + { + // the parent version always overwrite the background brush, so + // we need to save it and restore it + auto backgroundColor = option->backgroundBrush.color(); + QStyledItemDelegate::initStyleOption(option, index); + + if (backgroundColor.isValid()) { + option->backgroundBrush = backgroundColor; + } + } + + void paint(QPainter* painter, const QStyleOptionViewItem& option, + const QModelIndex& index) const override + { + QStyleOptionViewItem opt(option); + + // remove items indentaiton when using collapsible separators + if (index.column() == 0 && m_view->hasCollapsibleSeparators()) { + if (!index.model()->hasChildren(index) && index.parent().isValid()) { + auto parentIndex = index.parent().data(ModList::IndexRole).toInt(); + if (ModInfo::getByIndex(parentIndex)->isSeparator()) { + opt.rect.adjust(-m_view->indentation(), 0, 0, 0); + } + } + } + + // compute required color from children, otherwise fallback to the + // color from the model, and draw the background here + auto color = m_view->markerColor(index); + if (!color.isValid()) { + color = index.data(Qt::BackgroundRole).value(); + } + opt.backgroundBrush = color; + + // we need to find the background color to compute the ideal text color + // but the mod list view uses alternate color so we need to find the + // right color + auto bg = opt.palette.base().color(); + if (opt.features & QStyleOptionViewItem::Alternate) { + bg = opt.palette.alternateBase().color(); + } + + // compute ideal foreground color for some rows + if (color.isValid()) { + if (((index.column() == ModList::COL_NAME || + index.column() == ModList::COL_PRIORITY) && + ModInfo::getByIndex(index.data(ModList::IndexRole).toInt()) + ->isSeparator()) || + index.column() == ModList::COL_NOTES) { + + // combine the color with the background and then find the "ideal" text color + const auto a = color.alpha() / 255.; + int r = (1 - a) * bg.red() + a * color.red(), + g = (1 - a) * bg.green() + a * color.green(), + b = (1 - a) * bg.blue() + a * color.blue(); + opt.palette.setBrush(QPalette::Text, + ColorSettings::idealTextColor(QColor(r, g, b))); + } + } + + QStyledItemDelegate::paint(painter, opt, index); + } +}; + +class ModListViewMarkingScrollBar : public ViewMarkingScrollBar +{ + ModListView* m_view; + +public: + ModListViewMarkingScrollBar(ModListView* view) + : ViewMarkingScrollBar(view, ModList::ScrollMarkRole), m_view(view) + {} + + QColor color(const QModelIndex& index) const override + { + auto color = m_view->markerColor(index); + if (!color.isValid()) { + color = ViewMarkingScrollBar::color(index); + } + return color; + } +}; + +ModListView::ModListView(QWidget* parent) + : QTreeView(parent), + m_markers{{}, {}, {}, {}, {}, {}}, + m_scrollbar(new ModListViewMarkingScrollBar(this)) +{ + setVerticalScrollBar(m_scrollbar); + MOBase::setCustomizableColumns(this); + setAutoExpandDelay(750); + + setItemDelegate(new ModListStyledItemDelegate(this)); + + connect(this, &ModListView::doubleClicked, this, &ModListView::onDoubleClicked); + connect(this, &ModListView::customContextMenuRequested, this, + &ModListView::onCustomContextMenuRequested); + + // the timeout is pretty small because its main purpose is to avoid + // refreshing multiple times when calling expandAll() or collapseAll() + // which emit a lots of expanded/collapsed signals in a very small + // time window + m_refreshMarkersTimer.setInterval(50); + m_refreshMarkersTimer.setSingleShot(true); + connect(&m_refreshMarkersTimer, &QTimer::timeout, [=, this] { + refreshMarkersAndPlugins(); + }); + + installEventFilter(new CopyEventFilter(this, [=, this](auto& index) { + QVariant mIndex = index.data(ModList::IndexRole); + QString name = index.data(Qt::DisplayRole).toString(); + if (mIndex.isValid() && hasCollapsibleSeparators()) { + ModInfo::Ptr info = ModInfo::getByIndex(mIndex.toInt()); + if (info->isSeparator()) { + name = "[" + name + "]"; + } + } else if (model()->hasChildren(index)) { + name = "[" + name + "]"; + } + return name; + })); +} + +void ModListView::refresh() +{ + updateGroupByProxy(); +} + +void ModListView::onProfileChanged(Profile* oldProfile, Profile* newProfile) +{ + const auto perProfileSeparators = + m_core->settings().interface().collapsibleSeparatorsPerProfile(); + + // save expanded/collapsed state of separators + if (oldProfile && perProfileSeparators) { + auto& collapsed = m_collapsed[m_byPriorityProxy]; + oldProfile->storeSetting("UserInterface", "collapsed_separators", + QStringList(collapsed.begin(), collapsed.end())); + } + + m_sortProxy->setProfile(newProfile); + m_byPriorityProxy->setProfile(newProfile); + + if (newProfile && perProfileSeparators) { + auto collapsed = + newProfile->setting("UserInterface", "collapsed_separators", QStringList()) + .toStringList(); + m_collapsed[m_byPriorityProxy] = {collapsed.begin(), collapsed.end()}; + } +} + +bool ModListView::hasCollapsibleSeparators() const +{ + return groupByMode() == GroupByMode::SEPARATOR; +} + +int ModListView::sortColumn() const +{ + return m_sortProxy ? m_sortProxy->sortColumn() : -1; +} + +Qt::SortOrder ModListView::sortOrder() const +{ + return m_sortProxy ? m_sortProxy->sortOrder() : Qt::AscendingOrder; +} + +bool ModListView::isFilterActive() const +{ + return m_sortProxy && m_sortProxy->isFilterActive(); +} + +ModListView::GroupByMode ModListView::groupByMode() const +{ + if (m_sortProxy == nullptr) { + return GroupByMode::NONE; + } else if (m_sortProxy->sourceModel() == m_byPriorityProxy) { + return GroupByMode::SEPARATOR; + } else if (m_sortProxy->sourceModel() == m_byCategoryProxy) { + return GroupByMode::CATEGORY; + } else if (m_sortProxy->sourceModel() == m_byNexusIdProxy) { + return GroupByMode::NEXUS_ID; + } else { + return GroupByMode::NONE; + } +} + +ModListViewActions& ModListView::actions() const +{ + return *m_actions; +} + +std::optional ModListView::nextMod(unsigned int modIndex) const +{ + const QModelIndex start = indexModelToView(m_core->modList()->index(modIndex, 0)); + + auto index = start; + + for (;;) { + index = nextIndex(index); + + if (index == start || !index.isValid()) { + // wrapped around, give up + break; + } + + modIndex = index.data(ModList::IndexRole).toInt(); + + ModInfo::Ptr mod = ModInfo::getByIndex(modIndex); + + // skip overwrite, backups and separators + if (mod->isOverwrite() || mod->isBackup() || mod->isSeparator()) { + continue; + } + + return modIndex; + } + + return {}; +} + +std::optional ModListView::prevMod(unsigned int modIndex) const +{ + const QModelIndex start = indexModelToView(m_core->modList()->index(modIndex, 0)); + + auto index = start; + + for (;;) { + index = prevIndex(index); + + if (index == start || !index.isValid()) { + // wrapped around, give up + break; + } + + modIndex = index.data(ModList::IndexRole).toInt(); + + // skip overwrite, backups and separators + ModInfo::Ptr mod = ModInfo::getByIndex(modIndex); + if (mod->isOverwrite() || mod->isBackup() || mod->isSeparator()) { + continue; + } + + return modIndex; + } + + return {}; +} + +void ModListView::invalidateFilter() const +{ + m_sortProxy->invalidate(); +} + +void ModListView::setFilterCriteria( + const std::vector& criteria) const +{ + m_sortProxy->setCriteria(criteria); +} + +void ModListView::setFilterOptions(ModListSortProxy::FilterMode mode, + ModListSortProxy::SeparatorsMode sep) const +{ + m_sortProxy->setOptions(mode, sep); +} + +bool ModListView::isModVisible(unsigned int index) const +{ + return m_sortProxy->filterMatchesMod(ModInfo::getByIndex(index), + m_core->currentProfile()->modEnabled(index)); +} + +bool ModListView::isModVisible(ModInfo::Ptr mod) const +{ + return m_sortProxy->filterMatchesMod( + mod, m_core->currentProfile()->modEnabled(ModInfo::getIndex(mod->name()))); +} + +QModelIndex ModListView::indexModelToView(const QModelIndex& index) const +{ + return ::indexModelToView(index, this); +} + +QModelIndexList ModListView::indexModelToView(const QModelIndexList& index) const +{ + return ::indexModelToView(index, this); +} + +QModelIndex ModListView::indexViewToModel(const QModelIndex& index) const +{ + return ::indexViewToModel(index, m_core->modList()); +} + +QModelIndexList ModListView::indexViewToModel(const QModelIndexList& index) const +{ + return ::indexViewToModel(index, m_core->modList()); +} + +QModelIndex ModListView::nextIndex(const QModelIndex& index) +{ + auto* model = index.model(); + if (!model) { + return {}; + } + + if (model->rowCount(index) > 0) { + return model->index(0, index.column(), index); + } + + if (index.parent().isValid()) { + if (index.row() + 1 < model->rowCount(index.parent())) { + return index.model()->index(index.row() + 1, index.column(), index.parent()); + } else { + return index.model()->index((index.parent().row() + 1) % + model->rowCount(index.parent().parent()), + index.column(), index.parent().parent()); + ; + } + } else { + return index.model()->index((index.row() + 1) % model->rowCount(index.parent()), + index.column(), index.parent()); + } +} + +QModelIndex ModListView::prevIndex(const QModelIndex& index) +{ + if (index.row() == 0 && index.parent().isValid()) { + return index.parent(); + } + + auto* model = index.model(); + if (!model) { + return {}; + } + + auto prev = model->index((index.row() - 1) % model->rowCount(index.parent()), + index.column(), index.parent()); + + if (model->rowCount(prev) > 0) { + return model->index(model->rowCount(prev) - 1, index.column(), prev); + } + + return prev; +} + +std::pair ModListView::selected() const +{ + return {indexViewToModel(currentIndex()), + indexViewToModel(selectionModel()->selectedRows())}; +} + +void ModListView::setSelected(const QModelIndex& current, + const QModelIndexList& selected) +{ + setCurrentIndex(indexModelToView(current)); + for (auto idx : selected) { + selectionModel()->select(indexModelToView(idx), + QItemSelectionModel::Select | QItemSelectionModel::Rows); + } +} + +void ModListView::scrollToAndSelect(const QModelIndex& index) +{ + scrollToAndSelect(QModelIndexList{index}); +} + +void ModListView::scrollToAndSelect(const QModelIndexList& indexes, + const QModelIndex& current) +{ + // focus, scroll to and select + if (!current.isValid() && indexes.isEmpty()) { + return; + } + scrollTo(current.isValid() ? current : indexes.first()); + setCurrentIndex(current.isValid() ? current : indexes.first()); + QItemSelection selection; + for (auto& idx : indexes) { + selection.select(idx, idx); + } + selectionModel()->select(selection, + QItemSelectionModel::Select | QItemSelectionModel::Rows); + QTimer::singleShot(50, [=, this] { + setFocus(); + }); +} + +void ModListView::refreshExpandedItems() +{ + auto* model = m_sortProxy->sourceModel(); + for (auto i = 0; i < model->rowCount(); ++i) { + auto idx = model->index(i, 0); + if (!m_collapsed[model].contains(idx.data(Qt::DisplayRole).toString())) { + setExpanded(m_sortProxy->mapFromSource(idx), true); + } + } +} + +void ModListView::onModPrioritiesChanged(const QModelIndexList& indices) +{ + // expand separator whose priority has changed and parents + for (auto index : indices) { + auto idx = indexModelToView(index); + if (hasCollapsibleSeparators() && model()->hasChildren(idx)) { + setExpanded(idx, true); + } + if (idx.parent().isValid()) { + setExpanded(idx.parent(), true); + } + } + + setOverwriteMarkers(selectionModel()->selectedRows()); +} + +void ModListView::onModInstalled(const QString& modName) +{ + unsigned int index = ModInfo::getIndex(modName); + + if (index == UINT_MAX) { + return; + } + + QModelIndex qIndex = indexModelToView(m_core->modList()->index(index, 0)); + + if (hasCollapsibleSeparators() && qIndex.parent().isValid()) { + setExpanded(qIndex.parent(), true); + } + + scrollToAndSelect(qIndex); +} + +void ModListView::onModFilterActive(bool filterActive) +{ + ui.clearFilters->setVisible(filterActive); + if (filterActive) { + setStyleSheet("QTreeView { border: 2px ridge #f00; }"); + ui.counter->setStyleSheet("QLCDNumber { border: 2px ridge #f00; }"); + } else if (ui.groupBy->currentIndex() != GroupBy::NONE) { + setStyleSheet("QTreeView { border: 2px ridge #337733; }"); + ui.counter->setStyleSheet(""); + } else { + setStyleSheet(""); + ui.counter->setStyleSheet(""); + } +} + +ModListView::ModCounters ModListView::counters() const +{ + ModCounters c; + + auto hasFlag = [](std::vector flags, ModInfo::EFlag filter) { + return std::find(flags.begin(), flags.end(), filter) != flags.end(); + }; + + for (unsigned int index = 0; index < ModInfo::getNumMods(); ++index) { + auto info = ModInfo::getByIndex(index); + const auto flags = info->getFlags(); + + const bool enabled = m_core->currentProfile()->modEnabled(index); + const bool visible = m_sortProxy->filterMatchesMod(info, enabled); + + if (info->isBackup()) { + c.backup++; + if (visible) + c.visible.backup++; + } else if (info->isForeign()) { + c.foreign++; + if (visible) + c.visible.foreign++; + } else if (info->isSeparator()) { + c.separator++; + if (visible) + c.visible.separator++; + } else if (!info->isOverwrite()) { + c.regular++; + if (visible) + c.visible.regular++; + if (enabled) { + c.active++; + if (visible) + c.visible.active++; + } + } + } + + return c; +} + +void ModListView::updateModCount() +{ + const auto c = counters(); + + ui.counter->display(c.visible.active); + ui.counter->setToolTip(tr("" + "" + "" + "" + "" + "" + "
    TypeAllVisible
    Enabled mods: %1 / " + "%2%3 / %4
    Unmanaged/DLCs: %5%6
    Mod backups: %7%8
    Separators: %9%10
    ") + .arg(c.active) + .arg(c.regular) + .arg(c.visible.active) + .arg(c.visible.regular) + .arg(c.foreign) + .arg(c.visible.foreign) + .arg(c.backup) + .arg(c.visible.backup) + .arg(c.separator) + .arg(c.visible.separator)); +} + +void ModListView::refreshFilters() +{ + auto [current, sourceRows] = selected(); + + setCurrentIndex(QModelIndex()); + m_filters->refresh(); + + setSelected(current, sourceRows); +} + +void ModListView::onExternalFolderDropped(const QUrl& url, int priority) +{ + setWindowState(Qt::WindowActive); + + QFileInfo fileInfo(url.toLocalFile()); + + GuessedValue name; + name.setFilter(&fixDirectoryName); + name.update(fileInfo.fileName(), GUESS_PRESET); + + do { + bool ok; + name.update( + QInputDialog::getText(this, tr("Copy Folder..."), + tr("This will copy the content of %1 to a new mod.\n" + "Please enter the name:") + .arg(fileInfo.fileName()), + QLineEdit::Normal, name, &ok), + GUESS_USER); + if (!ok) { + return; + } + } while (name->isEmpty()); + + if (m_core->modList()->getMod(name) != nullptr) { + reportError(tr("A mod with this name already exists.")); + return; + } + + IModInterface* newMod = m_core->createMod(name); + if (!newMod) { + return; + } + + // TODO: this is currently a silent copy, which can take some time, but there is + // no clean method to do this in uibase + if (!copyDir(fileInfo.absoluteFilePath(), newMod->absolutePath(), true)) { + return; + } + + m_core->refresh(); + + const auto index = ModInfo::getIndex(name); + if (priority != -1) { + m_core->modList()->changeModPriority(index, priority); + } + + scrollToAndSelect(indexModelToView(m_core->modList()->index(index, 0))); +} + +bool ModListView::moveSelection(int key) +{ + auto rows = selectionModel()->selectedRows(); + const QPersistentModelIndex current(key == Qt::Key_Up ? rows.first() : rows.last()); + + int offset = key == Qt::Key_Up ? -1 : 1; + if (m_sortProxy->sortOrder() == Qt::DescendingOrder) { + offset = -offset; + } + + m_core->modList()->shiftModsPriority(indexViewToModel(rows), offset); + selectionModel()->setCurrentIndex(current, QItemSelectionModel::NoUpdate); + scrollTo(current); + + return true; +} + +bool ModListView::removeSelection() +{ + m_actions->removeMods(indexViewToModel(selectionModel()->selectedRows())); + return true; +} + +bool ModListView::toggleSelectionState() +{ + if (!selectionModel()->hasSelection()) { + return true; + } + return m_core->modList()->toggleState( + indexViewToModel(selectionModel()->selectedRows())); +} + +void ModListView::updateGroupByProxy() +{ + if (m_restoringHeaderState) { + return; + } + + int groupIndex = ui.groupBy->currentIndex(); + auto* previousModel = m_sortProxy->sourceModel(); + + QAbstractItemModel* nextModel = m_core->modList(); + if (groupIndex == GroupBy::CATEGORY) { + nextModel = m_byCategoryProxy; + } else if (groupIndex == GroupBy::NEXUS_ID) { + nextModel = m_byNexusIdProxy; + } else if (m_core->settings().interface().collapsibleSeparators( + m_sortProxy->sortOrder()) && + m_sortProxy->sortColumn() == ModList::COL_PRIORITY) { + m_byPriorityProxy->setSortOrder(m_sortProxy->sortOrder()); + nextModel = m_byPriorityProxy; + } + + if (nextModel != previousModel) { + + if (auto* proxy = dynamic_cast(nextModel)) { + proxy->setSourceModel(m_core->modList()); + } + m_sortProxy->setSourceModel(nextModel); + + // reset the source model of the old proxy because we do not want to + // react to signals + // + if (auto* proxy = qobject_cast(previousModel)) { + proxy->setSourceModel(nullptr); + } + + // expand items previously expanded + refreshExpandedItems(); + + if (hasCollapsibleSeparators()) { + ui.filterSeparators->setCurrentIndex(ModListSortProxy::SeparatorFilter); + ui.filterSeparators->setEnabled(false); + } else { + ui.filterSeparators->setEnabled(true); + } + } +} + +void ModListView::setup(OrganizerCore& core, CategoryFactory& factory, MainWindow* mw, + Ui::MainWindow* mwui) +{ + // attributes + m_core = &core; + m_filters.reset(new FilterList(mwui, core, factory)); + m_categories = &factory; + m_actions = + new ModListViewActions(core, *m_filters, factory, this, mwui->espList, mw); + ui = {mwui->groupCombo, + mwui->activeModsCounter, + mwui->modFilterEdit, + mwui->currentCategoryLabel, + mwui->clearFiltersButton, + mwui->filtersSeparators, + mwui->espList}; + + connect(m_core, &OrganizerCore::modInstalled, [=, this](auto&& name) { + onModInstalled(name); + }); + connect(m_core, &OrganizerCore::profileChanged, this, &ModListView::onProfileChanged); + connect(core.modList(), &ModList::modPrioritiesChanged, [=, this](auto&& indices) { + onModPrioritiesChanged(indices); + }); + connect(core.modList(), &ModList::clearOverwrite, [=, this] { + m_actions->clearOverwrite(); + }); + connect(core.modList(), &ModList::modStatesChanged, [=, this] { + updateModCount(); + setOverwriteMarkers(selectionModel()->selectedRows()); + }); + connect(core.modList(), &ModList::modelReset, [=, this] { + clearOverwriteMarkers(); + }); + + // proxy for various group by + m_byPriorityProxy = + new ModListByPriorityProxy(core.currentProfile().get(), core, this); + m_byCategoryProxy = new QtGroupingProxy(QModelIndex(), ModList::COL_CATEGORY, + ModList::GroupingRole, 0, ModList::AggrRole); + m_byNexusIdProxy = new QtGroupingProxy( + QModelIndex(), ModList::COL_MODID, ModList::GroupingRole, + QtGroupingProxy::FLAG_NOGROUPNAME | QtGroupingProxy::FLAG_NOSINGLE, + ModList::AggrRole); + + // we need to store the expanded/collapsed state of all items and restore them 1) when + // switching proxies, 2) when filtering and 3) when reseting the mod list. + connect(this, &QTreeView::expanded, [=, this](const QModelIndex& index) { + auto it = m_collapsed[m_sortProxy->sourceModel()].find( + index.data(Qt::DisplayRole).toString()); + if (it != m_collapsed[m_sortProxy->sourceModel()].end()) { + m_collapsed[m_sortProxy->sourceModel()].erase(it); + } + }); + connect(this, &QTreeView::collapsed, [=, this](const QModelIndex& index) { + m_collapsed[m_sortProxy->sourceModel()].insert( + index.data(Qt::DisplayRole).toString()); + }); + + // the top-level proxy + m_sortProxy = new ModListSortProxy(core.currentProfile().get(), &core); + setModel(m_sortProxy); + connect(m_sortProxy, &ModList::modelReset, [=, this] { + refreshExpandedItems(); + }); + + // update the proxy when changing the sort column/direction and the group + connect(m_sortProxy, &QAbstractItemModel::layoutAboutToBeChanged, + [this](auto&& parents, auto&& hint) { + if (hint == QAbstractItemModel::VerticalSortHint) { + updateGroupByProxy(); + } + }); + connect(ui.groupBy, QOverload::of(&QComboBox::currentIndexChanged), + [=, this](int index) { + updateGroupByProxy(); + onModFilterActive(m_sortProxy->isFilterActive()); + }); + sortByColumn(ModList::COL_PRIORITY, Qt::AscendingOrder); + + // inform the mod list about the type of item being dropped at the beginning of a drag + // and the position of the drop indicator at the end (only for by-priority) + connect(this, &ModListView::dragEntered, core.modList(), &ModList::onDragEnter); + connect(this, &ModListView::dropEntered, m_byPriorityProxy, + &ModListByPriorityProxy::onDropEnter); + + connect(m_sortProxy, &ModListSortProxy::filterInvalidated, this, + &ModListView::updateModCount); + + connect(header(), &QHeaderView::sortIndicatorChanged, [=, this](int, Qt::SortOrder) { + verticalScrollBar()->repaint(); + }); + connect(header(), &QHeaderView::sectionResized, + [=, this](int logicalIndex, int oldSize, int newSize) { + m_sortProxy->setColumnVisible(logicalIndex, newSize != 0); + }); + + setItemDelegateForColumn(ModList::COL_FLAGS, + new ModFlagIconDelegate(this, ModList::COL_FLAGS, 120)); + setItemDelegateForColumn( + ModList::COL_CONFLICTFLAGS, + new ModConflictIconDelegate(this, ModList::COL_CONFLICTFLAGS, 80)); + setItemDelegateForColumn(ModList::COL_CONTENT, + new ModContentIconDelegate(this, ModList::COL_CONTENT, 150)); + setItemDelegateForColumn(ModList::COL_VERSION, + new ModListVersionDelegate(this, core.settings())); + + m_restoringHeaderState = true; + const bool headerRestored = m_core->settings().geometry().restoreState(header()); + m_restoringHeaderState = false; + + if (headerRestored) { + // hack: force the resize-signal to be triggered because restoreState doesn't seem + // to do that + for (int column = 0; column <= ModList::COL_LASTCOLUMN; ++column) { + int sectionSize = header()->sectionSize(column); + header()->resizeSection(column, sectionSize + 1); + header()->resizeSection(column, sectionSize); + } + } else { + // hide these columns by default + header()->setSectionHidden(ModList::COL_CONTENT, true); + header()->setSectionHidden(ModList::COL_MODID, true); + header()->setSectionHidden(ModList::COL_UPLOADER, true); + header()->setSectionHidden(ModList::COL_GAME, true); + header()->setSectionHidden(ModList::COL_INSTALLTIME, true); + header()->setSectionHidden(ModList::COL_NOTES, true); + + // resize mod list to fit content + for (int i = 0; i < header()->count(); ++i) { + header()->setSectionResizeMode(i, QHeaderView::ResizeToContents); + } + + header()->setSectionResizeMode(ModList::COL_NAME, QHeaderView::Stretch); + } + + // prevent the name-column from being hidden + header()->setSectionHidden(ModList::COL_NAME, false); + + // we need QueuedConnection for the download/archive dropped otherwise the + // installation starts within the drop-event and it's not possible to drag&drop + // in the manual installer + connect( + m_core->modList(), &ModList::downloadArchiveDropped, this, + [=, this](int row, int priority) { + m_core->installDownload(row, priority); + }, + Qt::QueuedConnection); + connect( + m_core->modList(), &ModList::externalArchiveDropped, this, + [=, this](const QUrl& url, int priority) { + setWindowState(Qt::WindowActive); + m_core->installArchive(url.toLocalFile(), priority, false, nullptr); + }, + Qt::QueuedConnection); + connect(m_core->modList(), &ModList::externalFolderDropped, this, + &ModListView::onExternalFolderDropped); + + connect(selectionModel(), &QItemSelectionModel::selectionChanged, [=, this] { + m_refreshMarkersTimer.start(); + }); + connect(this, &QTreeView::collapsed, [=, this] { + m_refreshMarkersTimer.start(); + }); + connect(this, &QTreeView::expanded, [=, this] { + m_refreshMarkersTimer.start(); + }); + + // filters + connect(m_sortProxy, &ModListSortProxy::filterActive, this, + &ModListView::onModFilterActive); + connect(m_filters.get(), &FilterList::criteriaChanged, [=, this](auto&& v) { + onFiltersCriteria(v); + }); + connect(m_filters.get(), &FilterList::optionsChanged, [=, this](auto&& mode, auto&& sep) { + setFilterOptions(mode, sep); + }); + connect(ui.filter, &QLineEdit::textChanged, m_sortProxy, + &ModListSortProxy::updateFilter); + connect(ui.clearFilters, &QPushButton::clicked, [=, this]() { + ui.filter->clear(); + m_filters->clearSelection(); + }); + connect(m_sortProxy, &ModListSortProxy::filterInvalidated, [=, this]() { + if (hasCollapsibleSeparators()) { + refreshExpandedItems(); + } + }); +} + +void ModListView::restoreState(const Settings& s) +{ + // Restore groupBy BEFORE header state: changing the groupBy combo triggers + // updateGroupByProxy() which replaces the sort proxy's source model, resetting + // the header. By setting it first, the model is stable when header state + // (column visibility, widths, order) is restored. + s.widgets().restoreIndex(ui.groupBy); + + m_restoringHeaderState = true; + s.geometry().restoreState(header()); + m_restoringHeaderState = false; + + s.widgets().restoreTreeExpandState(this); + + m_filters->restoreState(s); +} + +void ModListView::saveState(Settings& s) const +{ + s.geometry().saveState(header()); + + s.widgets().saveIndex(ui.groupBy); + s.widgets().saveTreeExpandState(this); + + m_filters->saveState(s); +} + +QRect ModListView::visualRect(const QModelIndex& index) const +{ + // this shift the visualRect() from QTreeView to match the new actual + // zone after removing indentation (see the ModListStyledItemDelegate) + QRect rect = QTreeView::visualRect(index); + if (hasCollapsibleSeparators() && index.column() == 0 && index.isValid() && + index.parent().isValid()) { + rect.adjust(-indentation(), 0, 0, 0); + } + return rect; +} + +void ModListView::drawBranches(QPainter* painter, const QRect& rect, + const QModelIndex& index) const +{ + // the branches are the small indicator left to the row (there are none in the default + // style, and the VS dark style only has background for these) + // + // the branches are not shifted left with the visualRect() change and since MO2 uses + // stylesheet, it is not possible to shift those in the proxy style so we have to + // shift it here. + // + QRect r(rect); + if (hasCollapsibleSeparators() && index.parent().isValid()) { + r.adjust(-indentation(), 0, 0 - indentation(), 0); + } + QTreeView::drawBranches(painter, r, index); +} + +void ModListView::commitData(QWidget* editor) +{ + // maintain the selection when changing priority + if (currentIndex().column() == ModList::COL_PRIORITY) { + auto [current, selected] = this->selected(); + QTreeView::commitData(editor); + setSelected(current, selected); + } else { + QTreeView::commitData(editor); + } +} + +QModelIndexList ModListView::selectedIndexes() const +{ + // during drag&drop events, we fake the return value of selectedIndexes() + // to allow drag&drop of a parent into its children + // + // this is only "active" during the actual dragXXXEvent and dropEvent method, + // not during the whole drag&drop event + // + // selectedIndexes() is a protected method from QTreeView which is little + // used so this should not break anything + // + return m_inDragMoveEvent ? QModelIndexList() : QTreeView::selectedIndexes(); +} + +void ModListView::onCustomContextMenuRequested(const QPoint& pos) +{ + try { + QModelIndex contextIdx = indexViewToModel(indexAt(pos)); + + if (!contextIdx.isValid()) { + // no selection + ModListGlobalContextMenu(*m_core, this).exec(viewport()->mapToGlobal(pos)); + } else { + ModListContextMenu(contextIdx, *m_core, m_categories, this) + .exec(viewport()->mapToGlobal(pos)); + } + } catch (const std::exception& e) { + reportError(tr("Exception: ").arg(e.what())); + } catch (...) { + reportError(tr("Unknown exception")); + } +} + +void ModListView::onDoubleClicked(const QModelIndex& index) +{ + if (!index.isValid()) { + return; + } + + if (m_core->modList()->timeElapsedSinceLastChecked() <= + QApplication::doubleClickInterval()) { + // don't interpret double click if we only just checked a mod + return; + } + + bool indexOk = false; + int modIndex = index.data(ModList::IndexRole).toInt(&indexOk); + + if (!indexOk || modIndex < 0 || modIndex >= ModInfo::getNumMods()) { + return; + } + + ModInfo::Ptr modInfo = ModInfo::getByIndex(modIndex); + + const auto modifiers = QApplication::queryKeyboardModifiers(); + if (modifiers.testFlag(Qt::ControlModifier)) { + try { + shell::Explore(modInfo->absolutePath()); + } catch (const std::exception& e) { + reportError(e.what()); + } + } else if (modifiers.testFlag(Qt::ShiftModifier)) { + try { + actions().visitNexusOrWebPage({indexViewToModel(index)}); + } catch (const std::exception& e) { + reportError(e.what()); + } + } else if (hasCollapsibleSeparators() && modInfo->isSeparator()) { + setExpanded(index, !isExpanded(index)); + } else { + try { + auto tab = ModInfoTabIDs::None; + + switch (index.column()) { + case ModList::COL_NOTES: + tab = ModInfoTabIDs::Notes; + break; + case ModList::COL_VERSION: + tab = ModInfoTabIDs::Nexus; + break; + case ModList::COL_MODID: + tab = ModInfoTabIDs::Nexus; + break; + case ModList::COL_GAME: + tab = ModInfoTabIDs::Nexus; + break; + case ModList::COL_CATEGORY: + tab = ModInfoTabIDs::Categories; + break; + case ModList::COL_CONFLICTFLAGS: + tab = ModInfoTabIDs::Conflicts; + break; + } + + actions().displayModInformation(modIndex, tab); + } catch (const std::exception& e) { + reportError(e.what()); + } + } + + // workaround to cancel the editor that might have opened because of + // selection-click + closePersistentEditor(index); +} + +void ModListView::clearOverwriteMarkers() +{ + m_markers.overwrite.clear(); + m_markers.overwritten.clear(); + m_markers.archiveOverwrite.clear(); + m_markers.archiveOverwritten.clear(); + m_markers.archiveLooseOverwrite.clear(); + m_markers.archiveLooseOverwritten.clear(); +} + +void ModListView::setOverwriteMarkers(const QModelIndexList& indexes) +{ + const auto insert = [](auto& dest, const auto& from) { + dest.insert(from.begin(), from.end()); + }; + clearOverwriteMarkers(); + for (auto& idx : indexes) { + auto mIndex = idx.data(ModList::IndexRole); + if (mIndex.isValid()) { + auto info = ModInfo::getByIndex(mIndex.toInt()); + insert(m_markers.overwrite, info->getModOverwrite()); + insert(m_markers.overwritten, info->getModOverwritten()); + insert(m_markers.archiveOverwrite, info->getModArchiveOverwrite()); + insert(m_markers.archiveOverwritten, info->getModArchiveOverwritten()); + insert(m_markers.archiveLooseOverwrite, info->getModArchiveLooseOverwrite()); + insert(m_markers.archiveLooseOverwritten, info->getModArchiveLooseOverwritten()); + } + } + dataChanged(model()->index(0, 0), + model()->index(model()->rowCount() ? model()->rowCount() - 1 : 0, + model()->columnCount() ? model()->columnCount() - 1 : 0)); + verticalScrollBar()->repaint(); +} + +void ModListView::refreshMarkersAndPlugins() +{ + QModelIndexList indexes = selectionModel()->selectedRows(); + + if (m_core->settings().interface().collapsibleSeparatorsHighlightFrom()) { + for (auto& idx : selectionModel()->selectedRows()) { + if (hasCollapsibleSeparators() && model()->hasChildren(idx) && !isExpanded(idx)) { + for (int i = 0; i < model()->rowCount(idx); ++i) { + indexes.append(model()->index(i, idx.column(), idx)); + } + } + } + } + + setOverwriteMarkers(indexes); + + // highlight plugins + std::vector modIndices; + for (auto& idx : indexes) { + modIndices.push_back(idx.data(ModList::IndexRole).toInt()); + } + m_core->pluginList()->highlightPlugins(modIndices, *m_core->directoryStructure()); + ui.pluginList->verticalScrollBar()->repaint(); +} + +void ModListView::setHighlightedMods(const std::set& modNames) +{ + m_markers.highlight.clear(); + for (const auto& modName : modNames) { + const auto index = ModInfo::getIndex(modName); + if (index != UINT_MAX) { + m_markers.highlight.insert(index); + } + } + dataChanged(model()->index(0, 0), + model()->index(model()->rowCount() ? model()->rowCount() - 1 : 0, + model()->columnCount() ? model()->columnCount() - 1 : 0)); + verticalScrollBar()->repaint(); +} + +QColor ModListView::markerColor(const QModelIndex& index) const +{ + unsigned int modIndex = index.data(ModList::IndexRole).toInt(); + bool highlight = m_markers.highlight.contains(modIndex); + bool overwrite = m_markers.overwrite.contains(modIndex); + bool archiveOverwrite = + m_markers.archiveOverwrite.contains(modIndex); + bool archiveLooseOverwrite = m_markers.archiveLooseOverwrite.contains(modIndex); + bool overwritten = + m_markers.overwritten.contains(modIndex); + bool archiveOverwritten = + m_markers.archiveOverwritten.contains(modIndex); + bool archiveLooseOverwritten = m_markers.archiveLooseOverwritten.contains(modIndex); + + if (highlight) { + return Settings::instance().colors().modlistContainsFile(); + } else if (overwritten || archiveLooseOverwritten) { + return Settings::instance().colors().modlistOverwritingLoose(); + } else if (overwrite || archiveLooseOverwrite) { + return Settings::instance().colors().modlistOverwrittenLoose(); + } else if (archiveOverwritten) { + return Settings::instance().colors().modlistOverwritingArchive(); + } else if (archiveOverwrite) { + return Settings::instance().colors().modlistOverwrittenArchive(); + } + + // collapsed separator + auto rowIndex = index.sibling(index.row(), 0); + if (hasCollapsibleSeparators() && + m_core->settings().interface().collapsibleSeparatorsHighlightTo() && + model()->hasChildren(rowIndex) && !isExpanded(rowIndex)) { + + std::vector colors; + for (int i = 0; i < model()->rowCount(rowIndex); ++i) { + auto childColor = markerColor(model()->index(i, index.column(), rowIndex)); + if (childColor.isValid()) { + colors.push_back(childColor); + } + } + + if (colors.empty()) { + return {}; + } + + int r = 0, g = 0, b = 0, a = 0; + for (auto& color : colors) { + r += color.red(); + g += color.green(); + b += color.blue(); + a += color.alpha(); + } + + const auto count = static_cast(colors.size()); + return {r / count, g / count, b / count, a / count}; + } + + return {}; +} + +std::vector ModListView::modFlags(const QModelIndex& index, + bool* forceCompact) const +{ + ModInfo::Ptr info = ModInfo::getByIndex(index.data(ModList::IndexRole).toInt()); + + auto flags = info->getFlags(); + bool compact = false; + if (info->isSeparator() && hasCollapsibleSeparators() && + m_core->settings().interface().collapsibleSeparatorsIcons(ModList::COL_FLAGS) && + !isExpanded(index.sibling(index.row(), 0))) { + + // combine the child conflicts + std::set eFlags(flags.begin(), flags.end()); + for (int i = 0; i < model()->rowCount(index); ++i) { + auto cIndex = + model()->index(i, index.column(), index).data(ModList::IndexRole).toInt(); + auto cFlags = ModInfo::getByIndex(cIndex)->getFlags(); + eFlags.insert(cFlags.begin(), cFlags.end()); + } + flags = {eFlags.begin(), eFlags.end()}; + + // force compact because there can be a lots of flags here + compact = true; + } + + if (forceCompact) { + *forceCompact = compact; + } + + return flags; +} + +std::vector ModListView::conflictFlags(const QModelIndex& index, + bool* forceCompact) const +{ + ModInfo::Ptr info = ModInfo::getByIndex(index.data(ModList::IndexRole).toInt()); + + auto flags = info->getConflictFlags(); + bool compact = false; + if (info->isSeparator() && hasCollapsibleSeparators() && + m_core->settings().interface().collapsibleSeparatorsIcons( + ModList::COL_CONFLICTFLAGS) && + !isExpanded(index.sibling(index.row(), 0))) { + + // combine the child conflicts + std::set eFlags(flags.begin(), flags.end()); + for (int i = 0; i < model()->rowCount(index); ++i) { + auto cIndex = + model()->index(i, index.column(), index).data(ModList::IndexRole).toInt(); + auto cFlags = ModInfo::getByIndex(cIndex)->getConflictFlags(); + eFlags.insert(cFlags.begin(), cFlags.end()); + } + flags = {eFlags.begin(), eFlags.end()}; + + // force compact because there can be a lots of flags here + compact = true; + } + + if (forceCompact) { + *forceCompact = compact; + } + + return flags; +} + +std::set ModListView::contents(const QModelIndex& index, + bool* includeChildren) const +{ + auto modIndex = index.data(ModList::IndexRole); + if (!modIndex.isValid()) { + return {}; + } + ModInfo::Ptr info = ModInfo::getByIndex(index.data(ModList::IndexRole).toInt()); + auto contents = info->getContents(); + bool children = false; + + if (info->isSeparator() && hasCollapsibleSeparators() && + m_core->settings().interface().collapsibleSeparatorsIcons(ModList::COL_CONTENT) && + !isExpanded(index.sibling(index.row(), 0))) { + + // combine the child contents + std::set eContents(contents.begin(), contents.end()); + for (int i = 0; i < model()->rowCount(index); ++i) { + auto cIndex = + model()->index(i, index.column(), index).data(ModList::IndexRole).toInt(); + auto cContents = ModInfo::getByIndex(cIndex)->getContents(); + eContents.insert(cContents.begin(), cContents.end()); + } + contents = {eContents.begin(), eContents.end()}; + children = true; + } + + if (includeChildren) { + *includeChildren = children; + } + + return contents; +} + +QList ModListView::contentsIcons(const QModelIndex& index, + bool* forceCompact) const +{ + auto contents = this->contents(index, forceCompact); + QList result; + m_core->modDataContents().forEachContentInOrOut( + contents, + [&result](auto const& content) { + result.append(content.icon()); + }, + [&result](auto const&) { + result.append(QString()); + }); + return result; +} + +QString ModListView::contentsTooltip(const QModelIndex& index) const +{ + auto contents = this->contents(index, nullptr); + if (contents.empty()) { + return {}; + } + QString result(""); + m_core->modDataContents().forEachContentIn(contents, [&result](auto const& content) { + result.append(QString("" + "") + .arg(content.icon()) + .arg(content.name())); + }); + result.append("
    %2
    "); + return result; +} + +void ModListView::onFiltersCriteria( + const std::vector& criteria) +{ + setFilterCriteria(criteria); + + QString label = "?"; + + if (criteria.empty()) { + label = ""; + } else if (criteria.size() == 1) { + const auto& c = criteria[0]; + + if (c.type == ModListSortProxy::TypeContent) { + const auto* content = m_core->modDataContents().findById(c.id); + label = content ? content->name() : QString(); + } else { + label = m_categories->getCategoryNameByID(c.id); + } + + if (label.isEmpty()) { + log::error("category {}:{} not found", c.type, c.id); + } + } else { + label = tr(""); + } + + ui.currentCategory->setText(label); +} + +void ModListView::dragEnterEvent(QDragEnterEvent* event) +{ + // this event is used by the modlist to check if we are draggin + // to a mod (local files) or to a priority (mods, downloads, external + // files) + emit dragEntered(event->mimeData()); + QTreeView::dragEnterEvent(event); + + // there is no drop event for invalid data since canDropMimeData + // returns false, so we notify user on drag enter + ModListDropInfo dropInfo(event->mimeData(), *m_core); + + if (dropInfo.isValid() && !dropInfo.isLocalFileDrop() && + sortColumn() != ModList::COL_PRIORITY) { + log::warn("Drag&Drop is only supported when sorting by priority."); + } +} + +void ModListView::dragMoveEvent(QDragMoveEvent* event) +{ + // this replace the openTimer from QTreeView to prevent + // auto-collapse of items + if (autoExpandDelay() >= 0) { + m_openTimer.start(autoExpandDelay(), this); + } + + // see selectedIndexes() + m_inDragMoveEvent = true; + QAbstractItemView::dragMoveEvent(event); + m_inDragMoveEvent = false; +} + +void ModListView::dropEvent(QDropEvent* event) +{ + // from Qt source + QModelIndex index; + if (viewport()->rect().contains(event->position().toPoint())) { + index = indexAt(event->position().toPoint()); + if (!index.isValid() || !visualRect(index).contains(event->position().toPoint())) + index = QModelIndex(); + } + + // this event is used by the byPriorityProxy to know if allow + // dropping mod between a separator and its first mod (there + // is no way to deduce this except using dropIndicatorPosition()) + emit dropEntered(event->mimeData(), isExpanded(index), + static_cast(dropIndicatorPosition())); + + // see selectedIndexes() + m_inDragMoveEvent = true; + QTreeView::dropEvent(event); + m_inDragMoveEvent = false; +} + +void ModListView::timerEvent(QTimerEvent* event) +{ + // prevent auto-collapse, see dragMoveEvent() + if (event->timerId() == m_openTimer.timerId()) { + QPoint pos = viewport()->mapFromGlobal(QCursor::pos()); + if (state() == QAbstractItemView::DraggingState && + viewport()->rect().contains(pos)) { + QModelIndex index = indexAt(pos); + setExpanded(index, !m_core->settings().interface().autoCollapseOnHover() || + !isExpanded(index)); + } + m_openTimer.stop(); + } else { + QTreeView::timerEvent(event); + } +} + +void ModListView::mousePressEvent(QMouseEvent* event) +{ + // allow alt+click to select all mods inside a separator + // when using collapsible separators + // + // similar code is also present in mouseReleaseEvent to + // avoid missing events + + // disable edit if Alt is pressed + auto triggers = editTriggers(); + if (event->modifiers() & Qt::AltModifier) { + setEditTriggers(NoEditTriggers); + } + + // we call the parent class first so that we can use the actual + // selection state of the item after + QTreeView::mousePressEvent(event); + + // restore triggers + setEditTriggers(triggers); + + const auto index = indexAt(event->position().toPoint()); + + if (event->isAccepted() && hasCollapsibleSeparators() && index.isValid() && + model()->hasChildren(indexAt(event->position().toPoint())) && + (event->modifiers() & Qt::AltModifier)) { + + const auto flag = selectionModel()->isSelected(index) + ? QItemSelectionModel::Select + : QItemSelectionModel::Deselect; + const QItemSelection selection( + model()->index(0, index.column(), index), + model()->index(model()->rowCount(index) - 1, index.column(), index)); + selectionModel()->select(selection, flag | QItemSelectionModel::Rows); + } +} + +void ModListView::mouseReleaseEvent(QMouseEvent* event) +{ + // this is a duplicate of mousePressEvent because for some reason + // the selection is not always triggered in mousePressEvent and only + // doing it here create a small lag between the selection of the + // separator and the children + + // disable edit if Alt is pressed + auto triggers = editTriggers(); + if (event->modifiers() & Qt::AltModifier) { + setEditTriggers(NoEditTriggers); + } + + // we call the parent class first so that we can use the actual + // selection state of the item after + QTreeView::mouseReleaseEvent(event); + + const auto index = indexAt(event->position().toPoint()); + + // restore triggers + setEditTriggers(triggers); + + if (event->isAccepted() && hasCollapsibleSeparators() && index.isValid() && + model()->hasChildren(indexAt(event->position().toPoint())) && + (event->modifiers() & Qt::AltModifier)) { + + const auto flag = selectionModel()->isSelected(index) + ? QItemSelectionModel::Select + : QItemSelectionModel::Deselect; + const QItemSelection selection( + model()->index(0, index.column(), index), + model()->index(model()->rowCount(index) - 1, index.column(), index)); + selectionModel()->select(selection, flag | QItemSelectionModel::Rows); + } +} + +bool ModListView::event(QEvent* event) +{ + if (event->type() == QEvent::KeyPress && m_core->currentProfile() && + selectionModel()->hasSelection()) { + QKeyEvent* keyEvent = static_cast(event); + + auto index = selectionModel()->currentIndex(); + + if (keyEvent->modifiers() == Qt::ControlModifier) { + // ctrl+enter open explorer + if (keyEvent->key() == Qt::Key_Return || keyEvent->key() == Qt::Key_Enter) { + if (selectionModel()->selectedRows().count() == 1) { + m_actions->openExplorer({indexViewToModel(index)}); + return true; + } + } + // ctrl+up/down move selection + else if (sortColumn() == ModList::COL_PRIORITY && + (keyEvent->key() == Qt::Key_Up || keyEvent->key() == Qt::Key_Down)) { + return moveSelection(keyEvent->key()); + } + } else if (keyEvent->modifiers() == Qt::ShiftModifier) { + // shift+enter expand + if ((keyEvent->key() == Qt::Key_Return || keyEvent->key() == Qt::Key_Enter) && + selectionModel()->selectedRows().count() == 1) { + if (model()->hasChildren(index)) { + setExpanded(index, !isExpanded(index)); + } else if (index.parent().isValid()) { + setExpanded(index.parent(), false); + selectionModel()->select(index.parent(), QItemSelectionModel::SelectCurrent | + QItemSelectionModel::Rows); + setCurrentIndex(index.parent()); + } + } + } else { + if (keyEvent->key() == Qt::Key_Delete) { + return removeSelection(); + } else if (keyEvent->key() == Qt::Key_Space) { + return toggleSelectionState(); + } + } + return QTreeView::event(event); + } + return QTreeView::event(event); +} diff --git a/src/src/modlistviewactions.cpp b/src/src/modlistviewactions.cpp index 4627cc7..d33e2a7 100644 --- a/src/src/modlistviewactions.cpp +++ b/src/src/modlistviewactions.cpp @@ -1,1519 +1,1508 @@ -#include "modlistviewactions.h" - -#include -#include -#include -#include - -#include "filesystemutilities.h" -#include -#include - -#include "categories.h" -#include "csvbuilder.h" -#include "directoryrefresher.h" -#include "downloadmanager.h" -#include "filedialogmemory.h" -#include "filterlist.h" -#include "listdialog.h" -#include "messagedialog.h" -#include "modelutils.h" -#include "modinfodialog.h" -#include "modlist.h" -#include "modlistview.h" -#include "nexusinterface.h" -#include "nxmaccessmanager.h" -#include "organizercore.h" -#include "overwriteinfodialog.h" -#include "pluginlistview.h" -#include "savetextasdialog.h" -#include "shared/directoryentry.h" -#include "shared/fileregister.h" -#include "shared/filesorigin.h" - -using namespace MOBase; -using namespace MOShared; - -ModListViewActions::ModListViewActions(OrganizerCore& core, FilterList& filters, - CategoryFactory& categoryFactory, - ModListView* view, PluginListView* pluginView, - QObject* nxmReceiver) - : QObject(view), m_core(core), m_filters(filters), m_categories(categoryFactory), - m_view(view), m_pluginView(pluginView), m_parent(view->topLevelWidget()), - m_receiver(nxmReceiver) -{} - -int ModListViewActions::findInstallPriority(const QModelIndex& index) const -{ - int newPriority = -1; - if (index.isValid() && index.data(ModList::IndexRole).isValid() && - m_view->sortColumn() == ModList::COL_PRIORITY) { - auto mIndex = index.data(ModList::IndexRole).toInt(); - auto info = ModInfo::getByIndex(mIndex); - newPriority = m_core.currentProfile()->getModPriority(mIndex); - if (info->isSeparator()) { - - auto isSeparator = [](const auto& p) { - return ModInfo::getByIndex(p.second)->isSeparator(); - }; - - auto& ibp = m_core.currentProfile()->getAllIndexesByPriority(); - - // start right after/before the current priority and look for the next - // separator - if (m_view->sortOrder() == Qt::AscendingOrder) { - auto it = std::find_if(ibp.find(newPriority + 1), ibp.end(), isSeparator); - if (it != ibp.end()) { - newPriority = it->first; - } else { - newPriority = -1; - } - } else { - auto it = std::find_if(std::reverse_iterator{ibp.find(newPriority - 1)}, - ibp.rend(), isSeparator); - if (it != ibp.rend()) { - newPriority = it->first + 1; - } else { - // create "before" priority 0, i.e. at the end in descending priority. - newPriority = 0; - } - } - } - } - - return newPriority; -} - -void ModListViewActions::installMod(const QString& archivePath, - const QModelIndex& index) const -{ - try { - QString path = archivePath; - if (path.isEmpty()) { - QStringList extensions = m_core.installationManager()->getSupportedExtensions(); - for (auto iter = extensions.begin(); iter != extensions.end(); ++iter) { - *iter = "*." + *iter; - } - - path = FileDialogMemory::getOpenFileName( - "installMod", m_parent, tr("Choose Mod"), QString(), - tr("Mod Archive").append(QString(" (%1)").arg(extensions.join(" ")))); - } - - if (path.isEmpty()) { - return; - } else { - m_core.installMod(path, findInstallPriority(index), false, nullptr, QString()); - } - } catch (const std::exception& e) { - reportError(e.what()); - } -} - -void ModListViewActions::createEmptyMod(const QModelIndex& index) const -{ - GuessedValue name; - name.setFilter(&fixDirectoryName); - - while (name->isEmpty()) { - bool ok; - name.update(QInputDialog::getText(m_parent, tr("Create Mod..."), - tr("This will create an empty mod.\n" - "Please enter a name:"), - QLineEdit::Normal, "", &ok), - GUESS_USER); - if (!ok) { - return; - } - } - - if (m_core.modList()->getMod(name) != nullptr) { - reportError(tr("A mod with this name already exists")); - return; - } - - if (m_core.createMod(name) == nullptr) { - return; - } - - // find the priority before refresh() otherwise the index might not be valid - const int newPriority = findInstallPriority(index); - m_core.refresh(); - - const auto mIndex = ModInfo::getIndex(name); - if (newPriority >= 0) { - m_core.modList()->changeModPriority(mIndex, newPriority); - } - - m_view->scrollToAndSelect( - m_view->indexModelToView(m_core.modList()->index(mIndex, 0))); -} - -void ModListViewActions::createSeparator(const QModelIndex& index) const -{ - GuessedValue name; - name.setFilter(&fixDirectoryName); - while (name->isEmpty()) { - bool ok; - name.update(QInputDialog::getText(m_parent, tr("Create Separator..."), - tr("This will create a new separator.\n" - "Please enter a name:"), - QLineEdit::Normal, "", &ok), - GUESS_USER); - if (!ok) { - return; - } - } - if (m_core.modList()->getMod(name) != nullptr) { - reportError(tr("A separator with this name already exists")); - return; - } - name->append("_separator"); - if (m_core.modList()->getMod(name) != nullptr) { - return; - } - - int newPriority = -1; - if (index.isValid() && m_view->sortColumn() == ModList::COL_PRIORITY) { - newPriority = - m_core.currentProfile()->getModPriority(index.data(ModList::IndexRole).toInt()); - - // descending order, we need to fix the priority - if (m_view->sortOrder() == Qt::DescendingOrder) { - newPriority++; - } - } - - if (m_core.createMod(name) == nullptr) { - return; - } - - m_core.refresh(); - - const auto mIndex = ModInfo::getIndex(name); - if (newPriority >= 0) { - m_core.modList()->changeModPriority(mIndex, newPriority); - } - - if (auto c = m_core.settings().colors().previousSeparatorColor()) { - ModInfo::getByIndex(mIndex)->setColor(*c); - } - - m_view->scrollToAndSelect( - m_view->indexModelToView(m_core.modList()->index(mIndex, 0))); -} - -void ModListViewActions::setAllMatchingModsEnabled(bool enabled) const -{ - // number of mods to enable / disable - const auto counters = m_view->counters(); - const auto count = enabled ? counters.visible.regular - counters.visible.active - : counters.visible.active; - - // retrieve visible mods from the model view - const auto allIndex = m_view->indexViewToModel(flatIndex(m_view->model())); - const QString message = - enabled ? tr("Really enable %1 mod(s)?") : tr("Really disable %1 mod(s)?"); - if (QMessageBox::question(m_parent, tr("Confirm"), message.arg(count), - QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { - m_core.modList()->setActive(allIndex, enabled); - } -} - -void ModListViewActions::checkModsForUpdates() const -{ - bool checkingModsForUpdate = false; - if (NexusInterface::instance().getAccessManager()->validated()) { - checkingModsForUpdate = - ModInfo::checkAllForUpdate(&m_core.pluginContainer(), m_receiver); - NexusInterface::instance().requestEndorsementInfo(m_receiver, QVariant(), - QString()); - NexusInterface::instance().requestTrackingInfo(m_receiver, QVariant(), QString()); - } else { - QString apiKey; - if (GlobalSettings::nexusApiKey(apiKey)) { - m_core.doAfterLogin([=, this]() { - checkModsForUpdates(); - }); - NexusInterface::instance().getAccessManager()->apiCheck(apiKey); - } else { - log::warn("{}", tr("You are not currently authenticated with Nexus. Please do so " - "under Settings -> Nexus.")); - } - } - - bool updatesAvailable = false; - for (const auto& mod : m_core.modList()->allMods()) { - ModInfo::Ptr const modInfo = ModInfo::getByName(mod); - if (modInfo->updateAvailable()) { - updatesAvailable = true; - break; - } - } - - if (updatesAvailable || checkingModsForUpdate) { - m_view->setFilterCriteria( - {{ModListSortProxy::TypeSpecial, CategoryFactory::UpdateAvailable, false}}); - - m_filters.setSelection( - {{ModListSortProxy::TypeSpecial, CategoryFactory::UpdateAvailable, false}}); - } -} - -void ModListViewActions::assignCategories() const -{ - if (!GlobalSettings::hideAssignCategoriesQuestion()) { - QMessageBox warning; - warning.setWindowTitle(tr("Are you sure?")); - warning.setText( - tr("This action will remove any existing categories on any mod with a valid " - "Nexus category mapping. Are you certain you want to proceed?")); - warning.setStandardButtons(QMessageBox::Yes | QMessageBox::Cancel); - QCheckBox dontShow(tr("&Don't show this again")); - warning.setCheckBox(&dontShow); - auto result = warning.exec(); - if (dontShow.isChecked()) - GlobalSettings::setHideAssignCategoriesQuestion(true); - if (result == QMessageBox::Cancel) - return; - } - for (const auto& mod : m_core.modList()->allMods()) { - ModInfo::Ptr const modInfo = ModInfo::getByName(mod); - if (modInfo->isSeparator()) - continue; - int nexusCategory = modInfo->getNexusCategory(); - if (!nexusCategory) { - QSettings const downloadMeta(m_core.downloadsPath() + "/" + - modInfo->installationFile() + ".meta", - QSettings::IniFormat); - if (downloadMeta.contains("category")) { - nexusCategory = downloadMeta.value("category", 0).toInt(); - } - } - int const newCategory = CategoryFactory::instance().resolveNexusID(nexusCategory); - if (newCategory != 0) { - for (const auto& category : modInfo->categories()) { - modInfo->removeCategory(category); - } - } - modInfo->setCategory(CategoryFactory::instance().getCategoryID(newCategory), true); - } -} - -void ModListViewActions::checkModsForUpdates( - std::multimap const& IDs) const -{ - if (m_core.settings().network().offlineMode()) { - return; - } - - if (NexusInterface::instance().getAccessManager()->validated()) { - ModInfo::manualUpdateCheck(m_receiver, IDs); - } else { - QString apiKey; - if (GlobalSettings::nexusApiKey(apiKey)) { - m_core.doAfterLogin([=, this]() { - checkModsForUpdates(IDs); - }); - NexusInterface::instance().getAccessManager()->apiCheck(apiKey); - } else - log::warn("{}", tr("You are not currently authenticated with Nexus. Please do so " - "under Settings -> Nexus.")); - } -} - -void ModListViewActions::checkModsForUpdates(const QModelIndexList& indices) const -{ - std::multimap ids; - for (auto& idx : indices) { - ModInfo::Ptr const info = ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt()); - ids.insert(std::make_pair(info->gameName(), info->nexusId())); - } - checkModsForUpdates(ids); -} - -void ModListViewActions::exportModListCSV() const -{ - QDialog selection(m_parent); - QGridLayout* grid = new QGridLayout; - selection.setWindowTitle(tr("Export to csv")); - - QLabel* csvDescription = new QLabel(); - csvDescription->setText( - tr("CSV (Comma Separated Values) is a format that can be imported in programs " - "like Excel to create a spreadsheet.\nYou can also use online editors and " - "converters instead.")); - grid->addWidget(csvDescription); - - QGroupBox* groupBoxRows = new QGroupBox(tr("Select what mods you want export:")); - QRadioButton* all = new QRadioButton(tr("All installed mods")); - QRadioButton* active = - new QRadioButton(tr("Only active (checked) mods from your current profile")); - QRadioButton* visible = - new QRadioButton(tr("All currently visible mods in the mod list")); - - QVBoxLayout* vbox = new QVBoxLayout; - vbox->addWidget(all); - vbox->addWidget(active); - vbox->addWidget(visible); - vbox->addStretch(1); - groupBoxRows->setLayout(vbox); - - grid->addWidget(groupBoxRows); - - QButtonGroup* buttonGroupRows = new QButtonGroup(); - buttonGroupRows->addButton(all, 0); - buttonGroupRows->addButton(active, 1); - buttonGroupRows->addButton(visible, 2); - buttonGroupRows->button(0)->setChecked(true); - - QGroupBox* groupBoxColumns = new QGroupBox(tr("Choose what Columns to export:")); - groupBoxColumns->setFlat(true); - - QCheckBox* mod_Priority = new QCheckBox(tr("Mod_Priority")); - mod_Priority->setChecked(true); - QCheckBox* mod_Name = new QCheckBox(tr("Mod_Name")); - mod_Name->setChecked(true); - QCheckBox* mod_Note = new QCheckBox(tr("Notes_column")); - QCheckBox* mod_Status = new QCheckBox(tr("Mod_Status")); - mod_Status->setChecked(true); - QCheckBox* primary_Category = new QCheckBox(tr("Primary_Category")); - QCheckBox* mod_Author = new QCheckBox(tr("Mod_Author")); - QCheckBox* mod_Uploader = new QCheckBox(tr("Mod_Uploader")); - QCheckBox* nexus_ID = new QCheckBox(tr("Nexus_ID")); - QCheckBox* mod_Nexus_URL = new QCheckBox(tr("Mod_Nexus_URL")); - QCheckBox* mod_Uploader_URL = new QCheckBox(tr("Mod_Uploader_URL")); - QCheckBox* mod_Version = new QCheckBox(tr("Mod_Version")); - QCheckBox* install_Date = new QCheckBox(tr("Install_Date")); - QCheckBox* download_File_Name = new QCheckBox(tr("Download_File_Name")); - - QVBoxLayout* vbox1 = new QVBoxLayout; - vbox1->addWidget(mod_Priority); - vbox1->addWidget(mod_Name); - vbox1->addWidget(mod_Status); - vbox1->addWidget(mod_Note); - vbox1->addWidget(primary_Category); - vbox1->addWidget(mod_Author); - vbox1->addWidget(mod_Uploader); - vbox1->addWidget(nexus_ID); - vbox1->addWidget(mod_Nexus_URL); - vbox1->addWidget(mod_Uploader_URL); - vbox1->addWidget(mod_Version); - vbox1->addWidget(install_Date); - vbox1->addWidget(download_File_Name); - groupBoxColumns->setLayout(vbox1); - - grid->addWidget(groupBoxColumns); - - QPushButton* ok = new QPushButton("Ok"); - QPushButton* cancel = new QPushButton("Cancel"); - QDialogButtonBox* buttons = - new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); - - connect(buttons, SIGNAL(accepted()), &selection, SLOT(accept())); - connect(buttons, SIGNAL(rejected()), &selection, SLOT(reject())); - - grid->addWidget(buttons); - - selection.setLayout(grid); - - if (selection.exec() == QDialog::Accepted) { - - unsigned int const numMods = ModInfo::getNumMods(); - int const selectedRowID = buttonGroupRows->checkedId(); - - try { - QBuffer buffer; - buffer.open(QIODevice::ReadWrite); - CSVBuilder builder(&buffer); - builder.setEscapeMode(CSVBuilder::TYPE_STRING, CSVBuilder::QUOTE_ALWAYS); - std::vector> fields; - if (mod_Priority->isChecked()) - fields.push_back( - std::make_pair(QString("#Mod_Priority"), CSVBuilder::TYPE_STRING)); - if (mod_Status->isChecked()) - fields.push_back( - std::make_pair(QString("#Mod_Status"), CSVBuilder::TYPE_STRING)); - if (mod_Name->isChecked()) - fields.push_back(std::make_pair(QString("#Mod_Name"), CSVBuilder::TYPE_STRING)); - if (mod_Note->isChecked()) - fields.push_back(std::make_pair(QString("#Note"), CSVBuilder::TYPE_STRING)); - if (primary_Category->isChecked()) - fields.push_back( - std::make_pair(QString("#Primary_Category"), CSVBuilder::TYPE_STRING)); - if (mod_Author->isChecked()) - fields.push_back( - std::make_pair(QString("#Mod_Author"), CSVBuilder::TYPE_STRING)); - if (mod_Uploader->isChecked()) - fields.push_back( - std::make_pair(QString("#Mod_Uploader"), CSVBuilder::TYPE_STRING)); - if (nexus_ID->isChecked()) - fields.push_back( - std::make_pair(QString("#Nexus_ID"), CSVBuilder::TYPE_INTEGER)); - if (mod_Nexus_URL->isChecked()) - fields.push_back( - std::make_pair(QString("#Mod_Nexus_URL"), CSVBuilder::TYPE_STRING)); - if (mod_Uploader_URL->isChecked()) - fields.push_back( - std::make_pair(QString("#Mod_Uploader_URL"), CSVBuilder::TYPE_STRING)); - if (mod_Version->isChecked()) - fields.push_back( - std::make_pair(QString("#Mod_Version"), CSVBuilder::TYPE_STRING)); - if (install_Date->isChecked()) - fields.push_back( - std::make_pair(QString("#Install_Date"), CSVBuilder::TYPE_STRING)); - if (download_File_Name->isChecked()) - fields.push_back( - std::make_pair(QString("#Download_File_Name"), CSVBuilder::TYPE_STRING)); - - builder.setFields(fields); - - builder.writeHeader(); - - auto indexesByPriority = m_core.currentProfile()->getAllIndexesByPriority(); - for (auto& iter : indexesByPriority) { - ModInfo::Ptr const info = ModInfo::getByIndex(iter.second); - bool const enabled = m_core.currentProfile()->modEnabled(iter.second); - if ((selectedRowID == 1) && !enabled) { - continue; - } else if ((selectedRowID == 2) && !m_view->isModVisible(iter.second)) { - continue; - } - std::vector flags = info->getFlags(); - if ((std::find(flags.begin(), flags.end(), ModInfo::FLAG_OVERWRITE) == - flags.end()) && - (std::find(flags.begin(), flags.end(), ModInfo::FLAG_BACKUP) == - flags.end())) { - if (mod_Priority->isChecked()) - builder.setRowField("#Mod_Priority", - QString("%1").arg(iter.first, 4, 10, QChar('0'))); - if (mod_Status->isChecked()) - builder.setRowField("#Mod_Status", (enabled) ? "+" : "-"); - if (mod_Name->isChecked()) - builder.setRowField("#Mod_Name", info->name()); - if (mod_Note->isChecked()) - builder.setRowField("#Note", - QString("%1").arg(info->comments().remove(','))); - if (primary_Category->isChecked()) - builder.setRowField( - "#Primary_Category", - (m_categories.categoryExists(info->primaryCategory())) - ? m_categories.getCategoryNameByID(info->primaryCategory()) - : ""); - if (mod_Author->isChecked()) - builder.setRowField("#Mod_Author", info->author()); - if (mod_Uploader->isChecked()) - builder.setRowField("#Mod_Uploader", info->uploader()); - if (nexus_ID->isChecked()) - builder.setRowField("#Nexus_ID", info->nexusId()); - if (mod_Nexus_URL->isChecked()) - builder.setRowField("#Mod_Nexus_URL", - (info->nexusId() > 0) - ? NexusInterface::instance().getModURL( - info->nexusId(), info->gameName()) - : ""); - if (mod_Uploader_URL->isChecked()) - builder.setRowField("#Mod_Uploader_URL", info->uploaderUrl()); - if (mod_Version->isChecked()) - builder.setRowField("#Mod_Version", info->version().canonicalString()); - if (install_Date->isChecked()) - builder.setRowField("#Install_Date", - info->creationTime().toString("yyyy/MM/dd HH:mm:ss")); - if (download_File_Name->isChecked()) - builder.setRowField("#Download_File_Name", info->installationFile()); - - builder.writeRow(); - } - } - - SaveTextAsDialog saveDialog(m_parent); - saveDialog.setText(buffer.data()); - saveDialog.exec(); - } catch (const std::exception& e) { - reportError(tr("export failed: %1").arg(e.what())); - } - } -} - -void ModListViewActions::displayModInformation(const QString& modName, - ModInfoTabIDs tab) const -{ - unsigned int const index = ModInfo::getIndex(modName); - if (index == UINT_MAX) { - log::error("failed to resolve mod name {}", modName); - return; - } - - ModInfo::Ptr const modInfo = ModInfo::getByIndex(index); - displayModInformation(modInfo, index, tab); -} - -void ModListViewActions::displayModInformation(unsigned int index, - ModInfoTabIDs tab) const -{ - ModInfo::Ptr const modInfo = ModInfo::getByIndex(index); - displayModInformation(modInfo, index, tab); -} - -void ModListViewActions::displayModInformation(ModInfo::Ptr modInfo, - unsigned int modIndex, - ModInfoTabIDs tab) const -{ - if (!m_core.modList()->modInfoAboutToChange(modInfo)) { - log::debug("a different mod information dialog is open. If this is incorrect, " - "please restart MO"); - return; - } - std::vector flags = modInfo->getFlags(); - if (std::find(flags.begin(), flags.end(), ModInfo::FLAG_OVERWRITE) != flags.end()) { - QDialog* dialog = m_parent->findChild("__overwriteDialog"); - try { - if (dialog == nullptr) { - dialog = new OverwriteInfoDialog(modInfo, m_core, m_parent); - dialog->setObjectName("__overwriteDialog"); - } else { - qobject_cast(dialog)->setModInfo(modInfo); - } - - dialog->show(); - dialog->raise(); - dialog->activateWindow(); - connect(dialog, &QDialog::finished, [=, this]() { - m_core.modList()->modInfoChanged(modInfo); - dialog->deleteLater(); - m_core.refreshDirectoryStructure(); - }); - } catch (const std::exception& e) { - reportError(tr("Failed to display overwrite dialog: %1").arg(e.what())); - } - } else { - modInfo->saveMeta(); - - ModInfoDialog dialog(m_core, m_core.pluginContainer(), modInfo, m_view, m_parent); - connect(&dialog, &ModInfoDialog::originModified, this, - &ModListViewActions::originModified); - connect(&dialog, &ModInfoDialog::modChanged, [=, this](unsigned int index) { - auto idx = m_view->indexModelToView(m_core.modList()->index(index, 0)); - m_view->selectionModel()->select(idx, QItemSelectionModel::ClearAndSelect | - QItemSelectionModel::Rows); - m_view->scrollTo(idx); - }); - - // Open the tab first if we want to use the standard indexes of the tabs. - if (tab != ModInfoTabIDs::None) { - dialog.selectTab(tab); - } - - dialog.exec(); - - modInfo->saveMeta(); - m_core.modList()->modInfoChanged(modInfo); - emit modInfoDisplayed(); - } - - if (m_core.currentProfile()->modEnabled(modIndex) && !modInfo->isForeign()) { - FilesOrigin& origin = - m_core.directoryStructure()->getOriginByName(ToWString(modInfo->name())); - origin.enable(false); - - if (m_core.directoryStructure()->originExists(ToWString(modInfo->name()))) { - FilesOrigin& origin = - m_core.directoryStructure()->getOriginByName(ToWString(modInfo->name())); - origin.enable(false); - QString path = modInfo->absolutePath(); - QString const modDataDir = m_core.managedGame()->modDataDirectory(); - path = modDataDir.isEmpty() ? path : path + "/" + modDataDir; - m_core.directoryRefresher()->addModToStructure( - m_core.directoryStructure(), modInfo->name(), - m_core.currentProfile()->getModPriority(modIndex), path, - modInfo->stealFiles(), modInfo->archives()); - DirectoryRefresher::cleanStructure(m_core.directoryStructure()); - m_core.directoryStructure()->getFileRegister()->sortOrigins(); - m_core.refreshLists(); - } - } -} - -void ModListViewActions::sendModsToTop(const QModelIndexList& indexes) const -{ - m_core.modList()->changeModsPriority(indexes, Profile::MinimumPriority); -} - -void ModListViewActions::sendModsToBottom(const QModelIndexList& indexes) const -{ - m_core.modList()->changeModsPriority(indexes, Profile::MaximumPriority); -} - -void ModListViewActions::sendModsToPriority(const QModelIndexList& indexes) const -{ - bool ok; - int const priority = QInputDialog::getInt(m_parent, tr("Set Priority"), - tr("Set the priority of the selected mods"), 0, 0, - std::numeric_limits::max(), 1, &ok); - if (!ok) - return; - - m_core.modList()->changeModsPriority(indexes, priority); -} - -void ModListViewActions::sendModsToSeparator(const QModelIndexList& indexes) const -{ - QStringList separators; - const auto& ibp = m_core.currentProfile()->getAllIndexesByPriority(); - for (const auto& [priority, index] : ibp) { - if (index < ModInfo::getNumMods()) { - ModInfo::Ptr const modInfo = ModInfo::getByIndex(index); - if (modInfo->isSeparator()) { - separators << modInfo->name().chopped( - 10); // chops the "_separator" away from the name - } - } - } - - // in descending order, reverse the separator - if (m_view->sortOrder() == Qt::DescendingOrder) { - std::reverse(separators.begin(), separators.end()); - } - - ListDialog dialog(m_parent); - dialog.setWindowTitle("Select a separator..."); - dialog.setChoices(separators); - - if (dialog.exec() != QDialog::Accepted) { - return; - } - - const QString result = dialog.getChoice(); - if (result.isEmpty()) { - return; - } - - const auto sepPriority = - m_core.currentProfile()->getModPriority(ModInfo::getIndex(result + "_separator")); - - auto isSeparator = [](const auto& p) { - return ModInfo::getByIndex(p.second)->isSeparator(); - }; - - // start right after/before the current priority and look for the next - // separator - int priority = -1; - if (m_view->sortOrder() == Qt::AscendingOrder) { - auto it = std::find_if(ibp.find(sepPriority + 1), ibp.end(), isSeparator); - if (it != ibp.end()) { - priority = it->first; - } else { - priority = Profile::MaximumPriority; - } - } else { - auto it = std::find_if(--std::reverse_iterator{ibp.find(sepPriority - 1)}, - ibp.rend(), isSeparator); - if (it != ibp.rend()) { - priority = it->first + 1; - } else { - // create "before" priority 0, i.e. at the end in descending priority. - priority = Profile::MinimumPriority; - } - } - - // when the priority of a single mod is incremented, we need to shift the - // target priority, otherwise we will miss the target by one - if (indexes.size() == 1 && - indexes[0].data(ModList::PriorityRole).toInt() < sepPriority) { - priority--; - } - - m_core.modList()->changeModsPriority(indexes, priority); -} - -void ModListViewActions::sendModsToFirstConflict(const QModelIndexList& indexes) const -{ - std::set conflicts; - - for (auto& idx : indexes) { - if (!idx.data(ModList::IndexRole).isValid()) { - continue; - } - auto info = ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt()); - conflicts.insert(info->getModOverwrite().begin(), info->getModOverwrite().end()); - } - - std::set priorities; - std::transform(conflicts.begin(), conflicts.end(), - std::inserter(priorities, priorities.end()), [=, this](auto index) { - return m_core.currentProfile()->getModPriority(index); - }); - - if (!priorities.empty()) { - m_core.modList()->changeModsPriority(indexes, *priorities.begin()); - } -} - -void ModListViewActions::sendModsToLastConflict(const QModelIndexList& indexes) const -{ - std::set conflicts; - - for (auto& idx : indexes) { - if (!idx.data(ModList::IndexRole).isValid()) { - continue; - } - auto info = ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt()); - conflicts.insert(info->getModOverwritten().begin(), - info->getModOverwritten().end()); - } - - std::set priorities; - std::transform(conflicts.begin(), conflicts.end(), - std::inserter(priorities, priorities.end()), [=, this](auto index) { - return m_core.currentProfile()->getModPriority(index); - }); - - if (!priorities.empty()) { - m_core.modList()->changeModsPriority(indexes, *priorities.rbegin()); - } -} - -void ModListViewActions::renameMod(const QModelIndex& index) const -{ - try { - m_view->edit(m_view->indexModelToView(index)); - } catch (const std::exception& e) { - reportError(tr("failed to rename mod: %1").arg(e.what())); - } -} - -void ModListViewActions::removeMods(const QModelIndexList& indices) const -{ - const int max_items = 20; - - try { - if (indices.size() > 1) { - QString mods; - QStringList modNames; - - int i = 0; - for (auto& idx : indices) { - QString const name = idx.data().toString(); - if (!ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt())->isRegular()) { - continue; - } - - // adds an item for the mod name until `i` reaches `max_items`, which - // adds one "..." item; subsequent mods are not shown on the list but - // are still added to `modNames` below so they can be removed correctly - - if (i < max_items) { - mods += "
  • " + name + "
  • "; - } else if (i == max_items) { - mods += "
  • ...
  • "; - } - - modNames.append( - ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt())->name()); - ++i; - } - if (QMessageBox::question( - m_parent, tr("Confirm"), - tr("Remove the following mods?
      %1
    ").arg(mods), - QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { - // use mod names instead of indexes because those become invalid during the - // removal - DownloadManager::startDisableDirWatcher(); - for (const QString& name : modNames) { - m_core.modList()->removeRowForce(ModInfo::getIndex(name), QModelIndex()); - } - DownloadManager::endDisableDirWatcher(); - } - } else if (!indices.isEmpty()) { - m_core.modList()->removeRow(indices[0].data(ModList::IndexRole).toInt(), - QModelIndex()); - } - m_view->updateModCount(); - m_pluginView->updatePluginCount(); - } catch (const std::exception& e) { - reportError(tr("failed to remove mod: %1").arg(e.what())); - } -} - -void ModListViewActions::ignoreMissingData(const QModelIndexList& indices) const -{ - for (auto& idx : indices) { - int const row_idx = idx.data(ModList::IndexRole).toInt(); - ModInfo::Ptr const info = ModInfo::getByIndex(row_idx); - info->markValidated(true); - m_core.modList()->notifyChange(row_idx); - } -} - -void ModListViewActions::setIgnoreUpdate(const QModelIndexList& indices, - bool ignore) const -{ - for (auto& idx : indices) { - int const modIdx = idx.data(ModList::IndexRole).toInt(); - ModInfo::Ptr const info = ModInfo::getByIndex(modIdx); - info->ignoreUpdate(ignore); - m_core.modList()->notifyChange(modIdx); - } -} - -void ModListViewActions::changeVersioningScheme(const QModelIndex& index) const -{ - if (QMessageBox::question( - m_parent, tr("Continue?"), - tr("The versioning scheme decides which version is considered newer than " - "another.\n" - "This function will guess the versioning scheme under the assumption that " - "the installed version is outdated."), - QMessageBox::Yes | QMessageBox::Cancel) == QMessageBox::Yes) { - - ModInfo::Ptr const info = ModInfo::getByIndex(index.data(ModList::IndexRole).toInt()); - - bool success = false; - - static VersionInfo::VersionScheme const schemes[] = { - VersionInfo::SCHEME_REGULAR, VersionInfo::SCHEME_DECIMALMARK, - VersionInfo::SCHEME_NUMBERSANDLETTERS}; - - for (int i = 0; - i < sizeof(schemes) / sizeof(VersionInfo::VersionScheme) && !success; ++i) { - VersionInfo const verOld(info->version().canonicalString(), schemes[i]); - VersionInfo const verNew(info->newestVersion().canonicalString(), schemes[i]); - if (verOld < verNew) { - info->setVersion(verOld); - info->setNewestVersion(verNew); - success = true; - } - } - if (!success) { - QMessageBox::information( - m_parent, tr("Sorry"), - tr("I don't know a versioning scheme where %1 is newer than %2.") - .arg(info->newestVersion().canonicalString()) - .arg(info->version().canonicalString()), - QMessageBox::Ok); - } - } -} - -void ModListViewActions::markConverted(const QModelIndexList& indices) const -{ - for (auto& idx : indices) { - int const modIdx = idx.data(ModList::IndexRole).toInt(); - ModInfo::Ptr const info = ModInfo::getByIndex(modIdx); - info->markConverted(true); - m_core.modList()->notifyChange(modIdx); - } -} - -void ModListViewActions::visitOnNexus(const QModelIndexList& indices) const -{ - if (indices.size() > 10) { - if (!askOpenLinksConfirmation(indices.size(), tr("Nexus Links"))) { - return; - } - } - - for (auto& idx : indices) { - ModInfo::Ptr const info = ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt()); - int const modID = info->nexusId(); - QString const gameName = info->gameName(); - if (modID > 0) { - shell::Open(QUrl(NexusInterface::instance().getModURL(modID, gameName))); - } else { - log::error("mod '{}' has no nexus id", info->name()); - } - } -} - -void ModListViewActions::visitWebPage(const QModelIndexList& indices) const -{ - if (indices.size() > 10) { - if (!askOpenLinksConfirmation(indices.size(), tr("Web Pages"))) { - return; - } - } - - for (auto& idx : indices) { - ModInfo::Ptr const info = ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt()); - - const auto url = info->parseCustomURL(); - if (url.isValid()) { - shell::Open(url); - } - } -} - -void ModListViewActions::visitNexusOrWebPage(const QModelIndexList& indices) const -{ - if (indices.size() > 10) { - if (!askOpenLinksConfirmation(indices.size(), tr("Web Pages"))) { - return; - } - } - - for (auto& idx : indices) { - ModInfo::Ptr const info = ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt()); - if (!info) { - log::error("mod {} not found", idx.data(ModList::IndexRole).toInt()); - continue; - } - - int const modID = info->nexusId(); - QString const gameName = info->gameName(); - const auto url = info->parseCustomURL(); - - if (modID > 0) { - shell::Open(QUrl(NexusInterface::instance().getModURL(modID, gameName))); - } else if (url.isValid()) { - shell::Open(url); - } else { - log::error("mod '{}' has no valid link", info->name()); - } - } -} - -void ModListViewActions::visitUploaderProfile(const QModelIndexList& indices) const -{ - if (indices.size() > 10) { - if (!askOpenLinksConfirmation(indices.size(), tr("Uploader Profiles"))) { - return; - } - } - - for (auto& idx : indices) { - ModInfo::Ptr const info = ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt()); - const auto uploaderUrl = info->uploaderUrl(); - - if (!uploaderUrl.isEmpty()) { - shell::Open(QUrl(uploaderUrl)); - } else { - log::error("mod '{}' has no uploader url", info->name()); - } - } -} - -bool ModListViewActions::askOpenLinksConfirmation(std::size_t numberOfLinks, - const QString& nameOfLinks) const -{ - return QMessageBox::question(m_parent, tr("Opening %1").arg(nameOfLinks), - tr("You are trying to open %1 %2. Are you sure " - "you want to do this?") - .arg(numberOfLinks) - .arg(nameOfLinks), - QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes; -} - -void ModListViewActions::reinstallMod(const QModelIndex& index) const -{ - ModInfo::Ptr const modInfo = ModInfo::getByIndex(index.data(ModList::IndexRole).toInt()); - QString const installationFile = modInfo->installationFile(); - if (installationFile.length() != 0) { - QString fullInstallationFile; - QFileInfo const fileInfo(installationFile); - if (fileInfo.isAbsolute()) { - if (fileInfo.exists()) { - fullInstallationFile = installationFile; - } else { - fullInstallationFile = - m_core.downloadManager()->getOutputDirectory() + "/" + fileInfo.fileName(); - } - } else { - fullInstallationFile = - m_core.downloadManager()->getOutputDirectory() + "/" + installationFile; - } - if (QFile::exists(fullInstallationFile)) { - m_core.installMod(fullInstallationFile, -1, true, modInfo, modInfo->name()); - } else { - QMessageBox::information(m_parent, tr("Failed"), - tr("Installation file no longer exists")); - } - } else { - QMessageBox::information( - m_parent, tr("Failed"), - tr("Mods installed with old versions of MO can't be reinstalled in this way.")); - } -} - -void ModListViewActions::createBackup(const QModelIndex& index) const -{ - ModInfo::Ptr const modInfo = ModInfo::getByIndex(index.data(ModList::IndexRole).toInt()); - QString const backupDirectory = - m_core.installationManager()->generateBackupName(modInfo->absolutePath()); - if (!copyDir(modInfo->absolutePath(), backupDirectory, false)) { - QMessageBox::information(m_parent, tr("Failed"), tr("Failed to create backup.")); - } - m_core.refresh(); - m_view->updateModCount(); -} - -void ModListViewActions::restoreHiddenFiles(const QModelIndexList& indices) const -{ - const int max_items = 20; - - QFlags flags = FileRenamer::UNHIDE; - flags |= FileRenamer::MULTIPLE; - - FileRenamer renamer(m_parent, flags); - - FileRenamer::RenameResults result = FileRenamer::RESULT_OK; - - // multi selection - if (indices.size() > 1) { - - QStringList modNames; - for (auto& idx : indices) { - - ModInfo::Ptr const modInfo = ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt()); - const auto flags = modInfo->getFlags(); - - if (!modInfo->isRegular() || - std::find(flags.begin(), flags.end(), ModInfo::FLAG_HIDDEN_FILES) == - flags.end()) { - continue; - } - - modNames.append(idx.data(Qt::DisplayRole).toString()); - } - - QString mods = "
  • " + modNames.mid(0, max_items).join("
  • ") + "
  • "; - if (modNames.size() > max_items) { - mods += "
  • ...
  • "; - } - - if (QMessageBox::question( - m_parent, tr("Confirm"), - tr("Restore all hidden files in the following mods?
      %1
    ") - .arg(mods), - QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { - - for (auto& idx : indices) { - - ModInfo::Ptr const modInfo = - ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt()); - - const auto flags = modInfo->getFlags(); - if (std::find(flags.begin(), flags.end(), ModInfo::FLAG_HIDDEN_FILES) != - flags.end()) { - const QString modDir = modInfo->absolutePath(); - - auto partialResult = restoreHiddenFilesRecursive(renamer, modDir); - - if (partialResult == FileRenamer::RESULT_CANCEL) { - result = FileRenamer::RESULT_CANCEL; - break; - } - emit originModified((m_core.directoryStructure()->getOriginByName( - ToWString(modInfo->internalName()))) - .getID()); - } - } - } - } else if (!indices.isEmpty()) { - // single selection - ModInfo::Ptr const modInfo = - ModInfo::getByIndex(indices[0].data(ModList::IndexRole).toInt()); - const QString modDir = modInfo->absolutePath(); - - if (QMessageBox::question( - m_parent, tr("Are you sure?"), - tr("About to restore all hidden files in:\n") + modInfo->name(), - QMessageBox::Ok | QMessageBox::Cancel) == QMessageBox::Ok) { - - result = restoreHiddenFilesRecursive(renamer, modDir); - - emit originModified((m_core.directoryStructure()->getOriginByName( - ToWString(modInfo->internalName()))) - .getID()); - } - } - - if (result == FileRenamer::RESULT_CANCEL) { - log::debug("Restoring hidden files operation cancelled"); - } else { - log::debug("Finished restoring hidden files"); - } -} - -void ModListViewActions::setTracked(const QModelIndexList& indices, bool tracked) const -{ - m_core.loggedInAction(m_parent, [=, this] { - for (auto& idx : indices) { - ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt())->track(tracked); - } - }); -} - -void ModListViewActions::setEndorsed(const QModelIndexList& indices, - bool endorsed) const -{ - m_core.loggedInAction(m_parent, [=, this] { - if (indices.size() > 1) { - MessageDialog::showMessage( - tr("Endorsing multiple mods will take a while. Please wait..."), m_parent); - } - - for (auto& idx : indices) { - ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt())->endorse(endorsed); - } - }); -} - -void ModListViewActions::willNotEndorsed(const QModelIndexList& indices) -{ - for (auto& idx : indices) { - ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt())->setNeverEndorse(); - } -} - -void ModListViewActions::remapCategory(const QModelIndexList& indices) const -{ - for (auto& idx : indices) { - ModInfo::Ptr const modInfo = ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt()); - if (modInfo->isSeparator()) - continue; - - int categoryID = modInfo->getNexusCategory(); - if (!categoryID) { - QSettings const downloadMeta(m_core.downloadsPath() + "/" + - modInfo->installationFile() + ".meta", - QSettings::IniFormat); - if (downloadMeta.contains("category")) { - categoryID = downloadMeta.value("category", 0).toInt(); - } - } - unsigned int const categoryIndex = CategoryFactory::instance().resolveNexusID(categoryID); - if (categoryIndex != 0) - modInfo->setPrimaryCategory( - CategoryFactory::instance().getCategoryID(categoryIndex)); - } -} - -void ModListViewActions::setColor(const QModelIndexList& indices, - const QModelIndex& refIndex) const -{ - auto& settings = m_core.settings(); - ModInfo::Ptr const modInfo = ModInfo::getByIndex(refIndex.data(ModList::IndexRole).toInt()); - - QColorDialog dialog(m_parent); - dialog.setOption(QColorDialog::ShowAlphaChannel); - - QColor currentColor = modInfo->color(); - if (currentColor.isValid()) { - dialog.setCurrentColor(currentColor); - } else if (auto c = settings.colors().previousSeparatorColor()) { - dialog.setCurrentColor(*c); - } - - if (!dialog.exec()) - return; - - currentColor = dialog.currentColor(); - if (!currentColor.isValid()) - return; - - settings.colors().setPreviousSeparatorColor(currentColor); - - for (auto& idx : indices) { - ModInfo::Ptr const info = ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt()); - info->setColor(currentColor); - } -} - -void ModListViewActions::resetColor(const QModelIndexList& indices) const -{ - for (auto& idx : indices) { - ModInfo::Ptr const info = ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt()); - info->setColor(QColor()); - } - m_core.settings().colors().removePreviousSeparatorColor(); -} - -void ModListViewActions::setCategories( - ModInfo::Ptr mod, const std::vector>& categories) -{ - for (auto& [id, enabled] : categories) { - mod->setCategory(id, enabled); - } -} - -void ModListViewActions::setCategoriesIf( - ModInfo::Ptr mod, ModInfo::Ptr ref, - const std::vector>& categories) -{ - for (auto& [id, enabled] : categories) { - if (ref->categorySet(id) != enabled) { - mod->setCategory(id, enabled); - } - } -} - -void ModListViewActions::setCategories( - const QModelIndexList& selected, const QModelIndex& ref, - const std::vector>& categories) const -{ - ModInfo::Ptr const refMod = ModInfo::getByIndex(ref.data(ModList::IndexRole).toInt()); - if (selected.size() > 1) { - for (auto& idx : selected) { - if (idx.row() != ref.row()) { - setCategoriesIf(ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt()), - refMod, categories); - } - } - setCategories(refMod, categories); - } else if (!selected.isEmpty()) { - // for single mod selections, just do a replace - setCategories(refMod, categories); - } - - for (auto& idx : selected) { - m_core.modList()->notifyChange(idx.data(ModList::IndexRole).toInt()); - } - - // reset the selection manually - still needed - auto viewIndices = m_view->indexModelToView(selected); - for (auto& idx : viewIndices) { - m_view->selectionModel()->select(idx, QItemSelectionModel::Select | - QItemSelectionModel::Rows); - } -} - -void ModListViewActions::setPrimaryCategory(const QModelIndexList& selected, - int category, bool force) -{ - for (auto& idx : selected) { - ModInfo::Ptr const info = ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt()); - if (force || info->categorySet(category)) { - info->setCategory(category, true); - info->setPrimaryCategory(category); - } - } - - // reset the selection manually - still needed - auto viewIndices = m_view->indexModelToView(selected); - for (auto& idx : viewIndices) { - m_view->selectionModel()->select(idx, QItemSelectionModel::Select | - QItemSelectionModel::Rows); - } -} - -void ModListViewActions::openExplorer(const QModelIndexList& index) -{ - for (auto& idx : index) { - ModInfo::Ptr const info = ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt()); - if (!info->isForeign()) { - shell::Explore(info->absolutePath()); - } - } -} - -void ModListViewActions::restoreBackup(const QModelIndex& index) const -{ - QRegularExpression const backupRegEx("(.*)_backup[0-9]*$"); - ModInfo::Ptr const modInfo = ModInfo::getByIndex(index.data(ModList::IndexRole).toInt()); - auto match = backupRegEx.match(modInfo->name()); - if (match.hasMatch()) { - QString const regName = match.captured(1); - QDir modDir(QDir::fromNativeSeparators(m_core.settings().paths().mods())); - if (!modDir.exists(regName) || - (QMessageBox::question( - m_parent, tr("Overwrite?"), - tr("This will replace the existing mod \"%1\". Continue?").arg(regName), - QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes)) { - if (modDir.exists(regName) && - !shellDelete(QStringList(modDir.absoluteFilePath(regName)))) { - reportError(tr("failed to remove mod \"%1\"").arg(regName)); - } else { - QString const destinationPath = - QDir::fromNativeSeparators(m_core.settings().paths().mods()) + "/" + - regName; - if (!modDir.rename(modInfo->absolutePath(), destinationPath)) { - reportError(tr(R"(failed to rename "%1" to "%2")") - .arg(modInfo->absolutePath()) - .arg(destinationPath)); - } - m_core.refresh(); - m_view->updateModCount(); - } - } - } -} - -void ModListViewActions::moveOverwriteContentsTo(const QString& absolutePath) const -{ - ModInfo::Ptr const overwriteInfo = ModInfo::getOverwrite(); - const QString overwritePath = overwriteInfo->absolutePath(); - const QDir overwriteDir(overwritePath); - const QDir destDir(absolutePath); - - // Recursively move every file from overwrite into the destination mod, - // preserving the directory structure. - bool successful = true; - int movedCount = 0; - - QDirIterator iter(overwritePath, QDir::Files | QDir::NoDotAndDotDot, - QDirIterator::Subdirectories); - while (iter.hasNext()) { - iter.next(); - const QString relPath = overwriteDir.relativeFilePath(iter.filePath()); - const QString destFile = destDir.filePath(relPath); - - // Ensure destination subdirectory exists. - QDir().mkpath(QFileInfo(destFile).absolutePath()); - - // Remove existing file at destination (overwrite semantics). - if (QFile::exists(destFile)) { - QFile::remove(destFile); - } - - if (!QFile::rename(iter.filePath(), destFile)) { - // Fallback: copy + delete (cross-filesystem). - if (!QFile::copy(iter.filePath(), destFile) || !QFile::remove(iter.filePath())) { - log::error("Failed to move {} -> {}", relPath, destFile); - successful = false; - break; - } - } - ++movedCount; - } - - // Clean up empty directories left behind in overwrite. - // Sort by path length descending so leaf dirs are removed before parents. - if (movedCount > 0) { - QDirIterator dirIter(overwritePath, QDir::Dirs | QDir::NoDotAndDotDot, - QDirIterator::Subdirectories); - QStringList dirs; - while (dirIter.hasNext()) { - dirs.append(dirIter.next()); - } - std::sort(dirs.begin(), dirs.end(), [](const QString& a, const QString& b) { - return a.length() > b.length(); - }); - for (const auto& dir : dirs) { - QDir().rmdir(dir); // Only removes if empty. - } - } - - if (successful && movedCount > 0) { - // Track all files now in the target mod so future VFS writes go - // back to this mod instead of creating new copies in Overwrite. - QDirIterator trackIter(absolutePath, QDir::Files | QDir::NoDotAndDotDot, - QDirIterator::Subdirectories); - while (trackIter.hasNext()) { - trackIter.next(); - QString const relPath = destDir.relativeFilePath(trackIter.filePath()); - m_core.trackOverwriteMove(relPath, absolutePath); - } - MessageDialog::showMessage(tr("Move successful."), m_parent); - } else if (!successful) { - log::error("Move operation failed"); - } - - m_core.refresh(); -} - -void ModListViewActions::createModFromOverwrite() const -{ - GuessedValue name; - name.setFilter(&fixDirectoryName); - - while (name->isEmpty()) { - bool ok; - name.update( - QInputDialog::getText( - m_parent, tr("Create Mod..."), - tr("This will move all files from overwrite into a new, regular mod.\n" - "Please enter a name:"), - QLineEdit::Normal, "", &ok), - GUESS_USER); - if (!ok) { - return; - } - } - - if (m_core.modList()->getMod(name) != nullptr) { - reportError(tr("A mod with this name already exists")); - return; - } - - const IModInterface* newMod = m_core.createMod(name); - if (newMod == nullptr) { - return; - } - - moveOverwriteContentsTo(newMod->absolutePath()); -} - -void ModListViewActions::moveOverwriteContentToExistingMod() const -{ - QStringList mods; - auto indexesByPriority = m_core.currentProfile()->getAllIndexesByPriority(); - for (auto& iter : indexesByPriority) { - if ((iter.second != UINT_MAX)) { - ModInfo::Ptr const modInfo = ModInfo::getByIndex(iter.second); - if (!modInfo->isSeparator() && !modInfo->isForeign() && !modInfo->isOverwrite()) { - mods << modInfo->name(); - } - } - } - - ListDialog dialog(m_parent); - dialog.setWindowTitle("Select a mod..."); - dialog.setChoices(mods); - - if (dialog.exec() == QDialog::Accepted) { - QString result = dialog.getChoice(); - if (!result.isEmpty()) { - - QString modAbsolutePath; - - for (const auto& mod : m_core.modList()->allModsByProfilePriority()) { - if (result.compare(mod) == 0) { - ModInfo::Ptr const modInfo = ModInfo::getByIndex(ModInfo::getIndex(mod)); - modAbsolutePath = modInfo->absolutePath(); - break; - } - } - - if (modAbsolutePath.isNull()) { - log::warn("Mod {} has not been found, for some reason", result); - return; - } - - moveOverwriteContentsTo(modAbsolutePath); - } - } -} - -void ModListViewActions::clearOverwrite() const -{ - ModInfo::Ptr const modInfo = ModInfo::getOverwrite(); - if (modInfo) { - QDir const overwriteDir(modInfo->absolutePath()); - if (QMessageBox::question( - m_parent, tr("Are you sure?"), - tr("About to recursively delete:\n") + overwriteDir.absolutePath(), - QMessageBox::Ok | QMessageBox::Cancel) == QMessageBox::Ok) { - QStringList delList; - for (const auto& f : overwriteDir.entryInfoList(QDir::AllDirs | QDir::Files | - QDir::NoDotAndDotDot)) { - if (f.isDir() && m_core.managedGame()->getModMappings().keys().contains( - f.fileName(), Qt::CaseInsensitive)) { - for (const auto& sf : - QDir(f.absoluteFilePath()) - .entryInfoList(QDir::AllDirs | QDir::Files | QDir::NoDotAndDotDot)) { - delList.push_back(sf.absoluteFilePath()); - } - } else { - delList.push_back(f.absoluteFilePath()); - } - } - if (shellDelete(delList, true)) { - emit overwriteCleared(); - m_core.refresh(); - } else { - const auto e = GetLastError(); - log::error("Delete operation failed: {}", formatSystemMessage(e)); - } - } - } -} +#include "modlistviewactions.h" + +#include +#include +#include +#include + +#include "filesystemutilities.h" +#include +#include + +#include "categories.h" +#include "csvbuilder.h" +#include "directoryrefresher.h" +#include "downloadmanager.h" +#include "filedialogmemory.h" +#include "filterlist.h" +#include "listdialog.h" +#include "messagedialog.h" +#include "modelutils.h" +#include "modinfodialog.h" +#include "modlist.h" +#include "modlistview.h" +#include "nexusinterface.h" +#include "nxmaccessmanager.h" +#include "organizercore.h" +#include "overwriteinfodialog.h" +#include "pluginlistview.h" +#include "savetextasdialog.h" +#include "shared/directoryentry.h" +#include "shared/fileregister.h" +#include "shared/filesorigin.h" + +using namespace MOBase; +using namespace MOShared; + +ModListViewActions::ModListViewActions(OrganizerCore& core, FilterList& filters, + CategoryFactory& categoryFactory, + ModListView* view, PluginListView* pluginView, + QObject* nxmReceiver) + : QObject(view), m_core(core), m_filters(filters), m_categories(categoryFactory), + m_view(view), m_pluginView(pluginView), m_parent(view->topLevelWidget()), + m_receiver(nxmReceiver) +{} + +int ModListViewActions::findInstallPriority(const QModelIndex& index) const +{ + int newPriority = -1; + if (index.isValid() && index.data(ModList::IndexRole).isValid() && + m_view->sortColumn() == ModList::COL_PRIORITY) { + auto mIndex = index.data(ModList::IndexRole).toInt(); + auto info = ModInfo::getByIndex(mIndex); + newPriority = m_core.currentProfile()->getModPriority(mIndex); + if (info->isSeparator()) { + + auto isSeparator = [](const auto& p) { + return ModInfo::getByIndex(p.second)->isSeparator(); + }; + + const auto& ibp = m_core.currentProfile()->getAllIndexesByPriority(); + + // start right after/before the current priority and look for the next + // separator + if (m_view->sortOrder() == Qt::AscendingOrder) { + auto it = std::find_if(ibp.find(newPriority + 1), ibp.end(), isSeparator); + if (it != ibp.end()) { + newPriority = it->first; + } else { + newPriority = -1; + } + } else { + auto it = std::find_if(std::reverse_iterator{ibp.find(newPriority - 1)}, + ibp.rend(), isSeparator); + if (it != ibp.rend()) { + newPriority = it->first + 1; + } else { + // create "before" priority 0, i.e. at the end in descending priority. + newPriority = 0; + } + } + } + } + + return newPriority; +} + +void ModListViewActions::installMod(const QString& archivePath, + const QModelIndex& index) const +{ + try { + QString path = archivePath; + if (path.isEmpty()) { + QStringList extensions = m_core.installationManager()->getSupportedExtensions(); + for (auto iter = extensions.begin(); iter != extensions.end(); ++iter) { + *iter = "*." + *iter; + } + + path = FileDialogMemory::getOpenFileName( + "installMod", m_parent, tr("Choose Mod"), QString(), + tr("Mod Archive").append(QString(" (%1)").arg(extensions.join(" ")))); + } + + if (path.isEmpty()) { + return; + } else { + m_core.installMod(path, findInstallPriority(index), false, nullptr, QString()); + } + } catch (const std::exception& e) { + reportError(e.what()); + } +} + +void ModListViewActions::createEmptyMod(const QModelIndex& index) const +{ + GuessedValue name; + name.setFilter(&fixDirectoryName); + + while (name->isEmpty()) { + bool ok; + name.update(QInputDialog::getText(m_parent, tr("Create Mod..."), + tr("This will create an empty mod.\n" + "Please enter a name:"), + QLineEdit::Normal, "", &ok), + GUESS_USER); + if (!ok) { + return; + } + } + + if (m_core.modList()->getMod(name) != nullptr) { + reportError(tr("A mod with this name already exists")); + return; + } + + if (m_core.createMod(name) == nullptr) { + return; + } + + // find the priority before refresh() otherwise the index might not be valid + const int newPriority = findInstallPriority(index); + m_core.refresh(); + + const auto mIndex = ModInfo::getIndex(name); + if (newPriority >= 0) { + m_core.modList()->changeModPriority(mIndex, newPriority); + } + + m_view->scrollToAndSelect( + m_view->indexModelToView(m_core.modList()->index(mIndex, 0))); +} + +void ModListViewActions::createSeparator(const QModelIndex& index) const +{ + GuessedValue name; + name.setFilter(&fixDirectoryName); + while (name->isEmpty()) { + bool ok; + name.update(QInputDialog::getText(m_parent, tr("Create Separator..."), + tr("This will create a new separator.\n" + "Please enter a name:"), + QLineEdit::Normal, "", &ok), + GUESS_USER); + if (!ok) { + return; + } + } + if (m_core.modList()->getMod(name) != nullptr) { + reportError(tr("A separator with this name already exists")); + return; + } + name->append("_separator"); + if (m_core.modList()->getMod(name) != nullptr) { + return; + } + + int newPriority = -1; + if (index.isValid() && m_view->sortColumn() == ModList::COL_PRIORITY) { + newPriority = + m_core.currentProfile()->getModPriority(index.data(ModList::IndexRole).toInt()); + + // descending order, we need to fix the priority + if (m_view->sortOrder() == Qt::DescendingOrder) { + newPriority++; + } + } + + if (m_core.createMod(name) == nullptr) { + return; + } + + m_core.refresh(); + + const auto mIndex = ModInfo::getIndex(name); + if (newPriority >= 0) { + m_core.modList()->changeModPriority(mIndex, newPriority); + } + + if (auto c = m_core.settings().colors().previousSeparatorColor()) { + ModInfo::getByIndex(mIndex)->setColor(*c); + } + + m_view->scrollToAndSelect( + m_view->indexModelToView(m_core.modList()->index(mIndex, 0))); +} + +void ModListViewActions::setAllMatchingModsEnabled(bool enabled) const +{ + // number of mods to enable / disable + const auto counters = m_view->counters(); + const auto count = enabled ? counters.visible.regular - counters.visible.active + : counters.visible.active; + + // retrieve visible mods from the model view + const auto allIndex = m_view->indexViewToModel(flatIndex(m_view->model())); + const QString message = + enabled ? tr("Really enable %1 mod(s)?") : tr("Really disable %1 mod(s)?"); + if (QMessageBox::question(m_parent, tr("Confirm"), message.arg(count), + QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { + m_core.modList()->setActive(allIndex, enabled); + } +} + +void ModListViewActions::checkModsForUpdates() const +{ + bool checkingModsForUpdate = false; + if (NexusInterface::instance().getAccessManager()->validated()) { + checkingModsForUpdate = + ModInfo::checkAllForUpdate(&m_core.pluginContainer(), m_receiver); + NexusInterface::instance().requestEndorsementInfo(m_receiver, QVariant(), + QString()); + NexusInterface::instance().requestTrackingInfo(m_receiver, QVariant(), QString()); + } else { + QString apiKey; + if (GlobalSettings::nexusApiKey(apiKey)) { + m_core.doAfterLogin([=, this]() { + checkModsForUpdates(); + }); + NexusInterface::instance().getAccessManager()->apiCheck(apiKey); + } else { + log::warn("{}", tr("You are not currently authenticated with Nexus. Please do so " + "under Settings -> Nexus.")); + } + } + + bool updatesAvailable = false; + for (const auto& mod : m_core.modList()->allMods()) { + ModInfo::Ptr const modInfo = ModInfo::getByName(mod); + if (modInfo->updateAvailable()) { + updatesAvailable = true; + break; + } + } + + if (updatesAvailable || checkingModsForUpdate) { + m_view->setFilterCriteria( + {{.type=ModListSortProxy::TypeSpecial, .id=CategoryFactory::UpdateAvailable, .inverse=false}}); + + m_filters.setSelection( + {{.type=ModListSortProxy::TypeSpecial, .id=CategoryFactory::UpdateAvailable, .inverse=false}}); + } +} + +void ModListViewActions::assignCategories() const +{ + if (!GlobalSettings::hideAssignCategoriesQuestion()) { + QMessageBox warning; + warning.setWindowTitle(tr("Are you sure?")); + warning.setText( + tr("This action will remove any existing categories on any mod with a valid " + "Nexus category mapping. Are you certain you want to proceed?")); + warning.setStandardButtons(QMessageBox::Yes | QMessageBox::Cancel); + QCheckBox dontShow(tr("&Don't show this again")); + warning.setCheckBox(&dontShow); + auto result = warning.exec(); + if (dontShow.isChecked()) + GlobalSettings::setHideAssignCategoriesQuestion(true); + if (result == QMessageBox::Cancel) + return; + } + for (const auto& mod : m_core.modList()->allMods()) { + ModInfo::Ptr const modInfo = ModInfo::getByName(mod); + if (modInfo->isSeparator()) + continue; + int nexusCategory = modInfo->getNexusCategory(); + if (!nexusCategory) { + QSettings const downloadMeta(m_core.downloadsPath() + "/" + + modInfo->installationFile() + ".meta", + QSettings::IniFormat); + if (downloadMeta.contains("category")) { + nexusCategory = downloadMeta.value("category", 0).toInt(); + } + } + int const newCategory = CategoryFactory::instance().resolveNexusID(nexusCategory); + if (newCategory != 0) { + for (const auto& category : modInfo->categories()) { + modInfo->removeCategory(category); + } + } + modInfo->setCategory(CategoryFactory::instance().getCategoryID(newCategory), true); + } +} + +void ModListViewActions::checkModsForUpdates( + std::multimap const& IDs) const +{ + if (m_core.settings().network().offlineMode()) { + return; + } + + if (NexusInterface::instance().getAccessManager()->validated()) { + ModInfo::manualUpdateCheck(m_receiver, IDs); + } else { + QString apiKey; + if (GlobalSettings::nexusApiKey(apiKey)) { + m_core.doAfterLogin([=, this]() { + checkModsForUpdates(IDs); + }); + NexusInterface::instance().getAccessManager()->apiCheck(apiKey); + } else + log::warn("{}", tr("You are not currently authenticated with Nexus. Please do so " + "under Settings -> Nexus.")); + } +} + +void ModListViewActions::checkModsForUpdates(const QModelIndexList& indices) const +{ + std::multimap ids; + for (const auto& idx : indices) { + ModInfo::Ptr const info = ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt()); + ids.insert(std::make_pair(info->gameName(), info->nexusId())); + } + checkModsForUpdates(ids); +} + +void ModListViewActions::exportModListCSV() const +{ + QDialog selection(m_parent); + QGridLayout* grid = new QGridLayout; + selection.setWindowTitle(tr("Export to csv")); + + QLabel* csvDescription = new QLabel(); + csvDescription->setText( + tr("CSV (Comma Separated Values) is a format that can be imported in programs " + "like Excel to create a spreadsheet.\nYou can also use online editors and " + "converters instead.")); + grid->addWidget(csvDescription); + + QGroupBox* groupBoxRows = new QGroupBox(tr("Select what mods you want export:")); + QRadioButton* all = new QRadioButton(tr("All installed mods")); + QRadioButton* active = + new QRadioButton(tr("Only active (checked) mods from your current profile")); + QRadioButton* visible = + new QRadioButton(tr("All currently visible mods in the mod list")); + + QVBoxLayout* vbox = new QVBoxLayout; + vbox->addWidget(all); + vbox->addWidget(active); + vbox->addWidget(visible); + vbox->addStretch(1); + groupBoxRows->setLayout(vbox); + + grid->addWidget(groupBoxRows); + + QButtonGroup* buttonGroupRows = new QButtonGroup(); + buttonGroupRows->addButton(all, 0); + buttonGroupRows->addButton(active, 1); + buttonGroupRows->addButton(visible, 2); + buttonGroupRows->button(0)->setChecked(true); + + QGroupBox* groupBoxColumns = new QGroupBox(tr("Choose what Columns to export:")); + groupBoxColumns->setFlat(true); + + QCheckBox* mod_Priority = new QCheckBox(tr("Mod_Priority")); + mod_Priority->setChecked(true); + QCheckBox* mod_Name = new QCheckBox(tr("Mod_Name")); + mod_Name->setChecked(true); + QCheckBox* mod_Note = new QCheckBox(tr("Notes_column")); + QCheckBox* mod_Status = new QCheckBox(tr("Mod_Status")); + mod_Status->setChecked(true); + QCheckBox* primary_Category = new QCheckBox(tr("Primary_Category")); + QCheckBox* mod_Author = new QCheckBox(tr("Mod_Author")); + QCheckBox* mod_Uploader = new QCheckBox(tr("Mod_Uploader")); + QCheckBox* nexus_ID = new QCheckBox(tr("Nexus_ID")); + QCheckBox* mod_Nexus_URL = new QCheckBox(tr("Mod_Nexus_URL")); + QCheckBox* mod_Uploader_URL = new QCheckBox(tr("Mod_Uploader_URL")); + QCheckBox* mod_Version = new QCheckBox(tr("Mod_Version")); + QCheckBox* install_Date = new QCheckBox(tr("Install_Date")); + QCheckBox* download_File_Name = new QCheckBox(tr("Download_File_Name")); + + QVBoxLayout* vbox1 = new QVBoxLayout; + vbox1->addWidget(mod_Priority); + vbox1->addWidget(mod_Name); + vbox1->addWidget(mod_Status); + vbox1->addWidget(mod_Note); + vbox1->addWidget(primary_Category); + vbox1->addWidget(mod_Author); + vbox1->addWidget(mod_Uploader); + vbox1->addWidget(nexus_ID); + vbox1->addWidget(mod_Nexus_URL); + vbox1->addWidget(mod_Uploader_URL); + vbox1->addWidget(mod_Version); + vbox1->addWidget(install_Date); + vbox1->addWidget(download_File_Name); + groupBoxColumns->setLayout(vbox1); + + grid->addWidget(groupBoxColumns); + + QPushButton* ok = new QPushButton("Ok"); + QPushButton* cancel = new QPushButton("Cancel"); + QDialogButtonBox* buttons = + new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + + connect(buttons, SIGNAL(accepted()), &selection, SLOT(accept())); + connect(buttons, SIGNAL(rejected()), &selection, SLOT(reject())); + + grid->addWidget(buttons); + + selection.setLayout(grid); + + if (selection.exec() == QDialog::Accepted) { + + unsigned int const numMods = ModInfo::getNumMods(); + int const selectedRowID = buttonGroupRows->checkedId(); + + try { + QBuffer buffer; + buffer.open(QIODevice::ReadWrite); + CSVBuilder builder(&buffer); + builder.setEscapeMode(CSVBuilder::TYPE_STRING, CSVBuilder::QUOTE_ALWAYS); + std::vector> fields; + if (mod_Priority->isChecked()) + fields.emplace_back(QString("#Mod_Priority"), CSVBuilder::TYPE_STRING); + if (mod_Status->isChecked()) + fields.emplace_back(QString("#Mod_Status"), CSVBuilder::TYPE_STRING); + if (mod_Name->isChecked()) + fields.emplace_back(QString("#Mod_Name"), CSVBuilder::TYPE_STRING); + if (mod_Note->isChecked()) + fields.emplace_back(QString("#Note"), CSVBuilder::TYPE_STRING); + if (primary_Category->isChecked()) + fields.emplace_back(QString("#Primary_Category"), CSVBuilder::TYPE_STRING); + if (mod_Author->isChecked()) + fields.emplace_back(QString("#Mod_Author"), CSVBuilder::TYPE_STRING); + if (mod_Uploader->isChecked()) + fields.emplace_back(QString("#Mod_Uploader"), CSVBuilder::TYPE_STRING); + if (nexus_ID->isChecked()) + fields.emplace_back(QString("#Nexus_ID"), CSVBuilder::TYPE_INTEGER); + if (mod_Nexus_URL->isChecked()) + fields.emplace_back(QString("#Mod_Nexus_URL"), CSVBuilder::TYPE_STRING); + if (mod_Uploader_URL->isChecked()) + fields.emplace_back(QString("#Mod_Uploader_URL"), CSVBuilder::TYPE_STRING); + if (mod_Version->isChecked()) + fields.emplace_back(QString("#Mod_Version"), CSVBuilder::TYPE_STRING); + if (install_Date->isChecked()) + fields.emplace_back(QString("#Install_Date"), CSVBuilder::TYPE_STRING); + if (download_File_Name->isChecked()) + fields.emplace_back(QString("#Download_File_Name"), CSVBuilder::TYPE_STRING); + + builder.setFields(fields); + + builder.writeHeader(); + + auto indexesByPriority = m_core.currentProfile()->getAllIndexesByPriority(); + for (auto& iter : indexesByPriority) { + ModInfo::Ptr const info = ModInfo::getByIndex(iter.second); + bool const enabled = m_core.currentProfile()->modEnabled(iter.second); + if ((selectedRowID == 1) && !enabled) { + continue; + } else if ((selectedRowID == 2) && !m_view->isModVisible(iter.second)) { + continue; + } + std::vector flags = info->getFlags(); + if ((std::find(flags.begin(), flags.end(), ModInfo::FLAG_OVERWRITE) == + flags.end()) && + (std::find(flags.begin(), flags.end(), ModInfo::FLAG_BACKUP) == + flags.end())) { + if (mod_Priority->isChecked()) + builder.setRowField("#Mod_Priority", + QString("%1").arg(iter.first, 4, 10, QChar('0'))); + if (mod_Status->isChecked()) + builder.setRowField("#Mod_Status", (enabled) ? "+" : "-"); + if (mod_Name->isChecked()) + builder.setRowField("#Mod_Name", info->name()); + if (mod_Note->isChecked()) + builder.setRowField("#Note", + QString("%1").arg(info->comments().remove(','))); + if (primary_Category->isChecked()) + builder.setRowField( + "#Primary_Category", + (m_categories.categoryExists(info->primaryCategory())) + ? m_categories.getCategoryNameByID(info->primaryCategory()) + : ""); + if (mod_Author->isChecked()) + builder.setRowField("#Mod_Author", info->author()); + if (mod_Uploader->isChecked()) + builder.setRowField("#Mod_Uploader", info->uploader()); + if (nexus_ID->isChecked()) + builder.setRowField("#Nexus_ID", info->nexusId()); + if (mod_Nexus_URL->isChecked()) + builder.setRowField("#Mod_Nexus_URL", + (info->nexusId() > 0) + ? NexusInterface::instance().getModURL( + info->nexusId(), info->gameName()) + : ""); + if (mod_Uploader_URL->isChecked()) + builder.setRowField("#Mod_Uploader_URL", info->uploaderUrl()); + if (mod_Version->isChecked()) + builder.setRowField("#Mod_Version", info->version().canonicalString()); + if (install_Date->isChecked()) + builder.setRowField("#Install_Date", + info->creationTime().toString("yyyy/MM/dd HH:mm:ss")); + if (download_File_Name->isChecked()) + builder.setRowField("#Download_File_Name", info->installationFile()); + + builder.writeRow(); + } + } + + SaveTextAsDialog saveDialog(m_parent); + saveDialog.setText(buffer.data()); + saveDialog.exec(); + } catch (const std::exception& e) { + reportError(tr("export failed: %1").arg(e.what())); + } + } +} + +void ModListViewActions::displayModInformation(const QString& modName, + ModInfoTabIDs tab) const +{ + unsigned int const index = ModInfo::getIndex(modName); + if (index == UINT_MAX) { + log::error("failed to resolve mod name {}", modName); + return; + } + + ModInfo::Ptr const modInfo = ModInfo::getByIndex(index); + displayModInformation(modInfo, index, tab); +} + +void ModListViewActions::displayModInformation(unsigned int index, + ModInfoTabIDs tab) const +{ + ModInfo::Ptr const modInfo = ModInfo::getByIndex(index); + displayModInformation(modInfo, index, tab); +} + +void ModListViewActions::displayModInformation(ModInfo::Ptr modInfo, + unsigned int modIndex, + ModInfoTabIDs tab) const +{ + if (!m_core.modList()->modInfoAboutToChange(modInfo)) { + log::debug("a different mod information dialog is open. If this is incorrect, " + "please restart MO"); + return; + } + std::vector flags = modInfo->getFlags(); + if (std::find(flags.begin(), flags.end(), ModInfo::FLAG_OVERWRITE) != flags.end()) { + QDialog* dialog = m_parent->findChild("__overwriteDialog"); + try { + if (dialog == nullptr) { + dialog = new OverwriteInfoDialog(modInfo, m_core, m_parent); + dialog->setObjectName("__overwriteDialog"); + } else { + qobject_cast(dialog)->setModInfo(modInfo); + } + + dialog->show(); + dialog->raise(); + dialog->activateWindow(); + connect(dialog, &QDialog::finished, [=, this]() { + m_core.modList()->modInfoChanged(modInfo); + dialog->deleteLater(); + m_core.refreshDirectoryStructure(); + }); + } catch (const std::exception& e) { + reportError(tr("Failed to display overwrite dialog: %1").arg(e.what())); + } + } else { + modInfo->saveMeta(); + + ModInfoDialog dialog(m_core, m_core.pluginContainer(), modInfo, m_view, m_parent); + connect(&dialog, &ModInfoDialog::originModified, this, + &ModListViewActions::originModified); + connect(&dialog, &ModInfoDialog::modChanged, [=, this](unsigned int index) { + auto idx = m_view->indexModelToView(m_core.modList()->index(index, 0)); + m_view->selectionModel()->select(idx, QItemSelectionModel::ClearAndSelect | + QItemSelectionModel::Rows); + m_view->scrollTo(idx); + }); + + // Open the tab first if we want to use the standard indexes of the tabs. + if (tab != ModInfoTabIDs::None) { + dialog.selectTab(tab); + } + + dialog.exec(); + + modInfo->saveMeta(); + m_core.modList()->modInfoChanged(modInfo); + emit modInfoDisplayed(); + } + + if (m_core.currentProfile()->modEnabled(modIndex) && !modInfo->isForeign()) { + FilesOrigin& origin = + m_core.directoryStructure()->getOriginByName(ToWString(modInfo->name())); + origin.enable(false); + + if (m_core.directoryStructure()->originExists(ToWString(modInfo->name()))) { + FilesOrigin& origin = + m_core.directoryStructure()->getOriginByName(ToWString(modInfo->name())); + origin.enable(false); + QString path = modInfo->absolutePath(); + QString const modDataDir = m_core.managedGame()->modDataDirectory(); + path = modDataDir.isEmpty() ? path : path + "/" + modDataDir; + m_core.directoryRefresher()->addModToStructure( + m_core.directoryStructure(), modInfo->name(), + m_core.currentProfile()->getModPriority(modIndex), path, + modInfo->stealFiles(), modInfo->archives()); + DirectoryRefresher::cleanStructure(m_core.directoryStructure()); + m_core.directoryStructure()->getFileRegister()->sortOrigins(); + m_core.refreshLists(); + } + } +} + +void ModListViewActions::sendModsToTop(const QModelIndexList& indexes) const +{ + m_core.modList()->changeModsPriority(indexes, Profile::MinimumPriority); +} + +void ModListViewActions::sendModsToBottom(const QModelIndexList& indexes) const +{ + m_core.modList()->changeModsPriority(indexes, Profile::MaximumPriority); +} + +void ModListViewActions::sendModsToPriority(const QModelIndexList& indexes) const +{ + bool ok; + int const priority = QInputDialog::getInt(m_parent, tr("Set Priority"), + tr("Set the priority of the selected mods"), 0, 0, + std::numeric_limits::max(), 1, &ok); + if (!ok) + return; + + m_core.modList()->changeModsPriority(indexes, priority); +} + +void ModListViewActions::sendModsToSeparator(const QModelIndexList& indexes) const +{ + QStringList separators; + const auto& ibp = m_core.currentProfile()->getAllIndexesByPriority(); + for (const auto& [priority, index] : ibp) { + if (index < ModInfo::getNumMods()) { + ModInfo::Ptr const modInfo = ModInfo::getByIndex(index); + if (modInfo->isSeparator()) { + separators << modInfo->name().chopped( + 10); // chops the "_separator" away from the name + } + } + } + + // in descending order, reverse the separator + if (m_view->sortOrder() == Qt::DescendingOrder) { + std::reverse(separators.begin(), separators.end()); + } + + ListDialog dialog(m_parent); + dialog.setWindowTitle("Select a separator..."); + dialog.setChoices(separators); + + if (dialog.exec() != QDialog::Accepted) { + return; + } + + const QString result = dialog.getChoice(); + if (result.isEmpty()) { + return; + } + + const auto sepPriority = + m_core.currentProfile()->getModPriority(ModInfo::getIndex(result + "_separator")); + + auto isSeparator = [](const auto& p) { + return ModInfo::getByIndex(p.second)->isSeparator(); + }; + + // start right after/before the current priority and look for the next + // separator + int priority = -1; + if (m_view->sortOrder() == Qt::AscendingOrder) { + auto it = std::find_if(ibp.find(sepPriority + 1), ibp.end(), isSeparator); + if (it != ibp.end()) { + priority = it->first; + } else { + priority = Profile::MaximumPriority; + } + } else { + auto it = std::find_if(--std::reverse_iterator{ibp.find(sepPriority - 1)}, + ibp.rend(), isSeparator); + if (it != ibp.rend()) { + priority = it->first + 1; + } else { + // create "before" priority 0, i.e. at the end in descending priority. + priority = Profile::MinimumPriority; + } + } + + // when the priority of a single mod is incremented, we need to shift the + // target priority, otherwise we will miss the target by one + if (indexes.size() == 1 && + indexes[0].data(ModList::PriorityRole).toInt() < sepPriority) { + priority--; + } + + m_core.modList()->changeModsPriority(indexes, priority); +} + +void ModListViewActions::sendModsToFirstConflict(const QModelIndexList& indexes) const +{ + std::set conflicts; + + for (const auto& idx : indexes) { + if (!idx.data(ModList::IndexRole).isValid()) { + continue; + } + auto info = ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt()); + conflicts.insert(info->getModOverwrite().begin(), info->getModOverwrite().end()); + } + + std::set priorities; + std::transform(conflicts.begin(), conflicts.end(), + std::inserter(priorities, priorities.end()), [=, this](auto index) { + return m_core.currentProfile()->getModPriority(index); + }); + + if (!priorities.empty()) { + m_core.modList()->changeModsPriority(indexes, *priorities.begin()); + } +} + +void ModListViewActions::sendModsToLastConflict(const QModelIndexList& indexes) const +{ + std::set conflicts; + + for (const auto& idx : indexes) { + if (!idx.data(ModList::IndexRole).isValid()) { + continue; + } + auto info = ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt()); + conflicts.insert(info->getModOverwritten().begin(), + info->getModOverwritten().end()); + } + + std::set priorities; + std::transform(conflicts.begin(), conflicts.end(), + std::inserter(priorities, priorities.end()), [=, this](auto index) { + return m_core.currentProfile()->getModPriority(index); + }); + + if (!priorities.empty()) { + m_core.modList()->changeModsPriority(indexes, *priorities.rbegin()); + } +} + +void ModListViewActions::renameMod(const QModelIndex& index) const +{ + try { + m_view->edit(m_view->indexModelToView(index)); + } catch (const std::exception& e) { + reportError(tr("failed to rename mod: %1").arg(e.what())); + } +} + +void ModListViewActions::removeMods(const QModelIndexList& indices) const +{ + const int max_items = 20; + + try { + if (indices.size() > 1) { + QString mods; + QStringList modNames; + + int i = 0; + for (const auto& idx : indices) { + QString const name = idx.data().toString(); + if (!ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt())->isRegular()) { + continue; + } + + // adds an item for the mod name until `i` reaches `max_items`, which + // adds one "..." item; subsequent mods are not shown on the list but + // are still added to `modNames` below so they can be removed correctly + + if (i < max_items) { + mods += "
  • " + name + "
  • "; + } else if (i == max_items) { + mods += "
  • ...
  • "; + } + + modNames.append( + ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt())->name()); + ++i; + } + if (QMessageBox::question( + m_parent, tr("Confirm"), + tr("Remove the following mods?
      %1
    ").arg(mods), + QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { + // use mod names instead of indexes because those become invalid during the + // removal + DownloadManager::startDisableDirWatcher(); + for (const QString& name : modNames) { + m_core.modList()->removeRowForce(ModInfo::getIndex(name), QModelIndex()); + } + DownloadManager::endDisableDirWatcher(); + } + } else if (!indices.isEmpty()) { + m_core.modList()->removeRow(indices[0].data(ModList::IndexRole).toInt(), + QModelIndex()); + } + m_view->updateModCount(); + m_pluginView->updatePluginCount(); + } catch (const std::exception& e) { + reportError(tr("failed to remove mod: %1").arg(e.what())); + } +} + +void ModListViewActions::ignoreMissingData(const QModelIndexList& indices) const +{ + for (const auto& idx : indices) { + int const row_idx = idx.data(ModList::IndexRole).toInt(); + ModInfo::Ptr const info = ModInfo::getByIndex(row_idx); + info->markValidated(true); + m_core.modList()->notifyChange(row_idx); + } +} + +void ModListViewActions::setIgnoreUpdate(const QModelIndexList& indices, + bool ignore) const +{ + for (const auto& idx : indices) { + int const modIdx = idx.data(ModList::IndexRole).toInt(); + ModInfo::Ptr const info = ModInfo::getByIndex(modIdx); + info->ignoreUpdate(ignore); + m_core.modList()->notifyChange(modIdx); + } +} + +void ModListViewActions::changeVersioningScheme(const QModelIndex& index) const +{ + if (QMessageBox::question( + m_parent, tr("Continue?"), + tr("The versioning scheme decides which version is considered newer than " + "another.\n" + "This function will guess the versioning scheme under the assumption that " + "the installed version is outdated."), + QMessageBox::Yes | QMessageBox::Cancel) == QMessageBox::Yes) { + + ModInfo::Ptr const info = ModInfo::getByIndex(index.data(ModList::IndexRole).toInt()); + + bool success = false; + + static VersionInfo::VersionScheme const schemes[] = { + VersionInfo::SCHEME_REGULAR, VersionInfo::SCHEME_DECIMALMARK, + VersionInfo::SCHEME_NUMBERSANDLETTERS}; + + for (int i = 0; + i < sizeof(schemes) / sizeof(VersionInfo::VersionScheme) && !success; ++i) { + VersionInfo const verOld(info->version().canonicalString(), schemes[i]); + VersionInfo const verNew(info->newestVersion().canonicalString(), schemes[i]); + if (verOld < verNew) { + info->setVersion(verOld); + info->setNewestVersion(verNew); + success = true; + } + } + if (!success) { + QMessageBox::information( + m_parent, tr("Sorry"), + tr("I don't know a versioning scheme where %1 is newer than %2.") + .arg(info->newestVersion().canonicalString()) + .arg(info->version().canonicalString()), + QMessageBox::Ok); + } + } +} + +void ModListViewActions::markConverted(const QModelIndexList& indices) const +{ + for (const auto& idx : indices) { + int const modIdx = idx.data(ModList::IndexRole).toInt(); + ModInfo::Ptr const info = ModInfo::getByIndex(modIdx); + info->markConverted(true); + m_core.modList()->notifyChange(modIdx); + } +} + +void ModListViewActions::visitOnNexus(const QModelIndexList& indices) const +{ + if (indices.size() > 10) { + if (!askOpenLinksConfirmation(indices.size(), tr("Nexus Links"))) { + return; + } + } + + for (const auto& idx : indices) { + ModInfo::Ptr const info = ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt()); + int const modID = info->nexusId(); + QString const gameName = info->gameName(); + if (modID > 0) { + shell::Open(QUrl(NexusInterface::instance().getModURL(modID, gameName))); + } else { + log::error("mod '{}' has no nexus id", info->name()); + } + } +} + +void ModListViewActions::visitWebPage(const QModelIndexList& indices) const +{ + if (indices.size() > 10) { + if (!askOpenLinksConfirmation(indices.size(), tr("Web Pages"))) { + return; + } + } + + for (const auto& idx : indices) { + ModInfo::Ptr const info = ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt()); + + const auto url = info->parseCustomURL(); + if (url.isValid()) { + shell::Open(url); + } + } +} + +void ModListViewActions::visitNexusOrWebPage(const QModelIndexList& indices) const +{ + if (indices.size() > 10) { + if (!askOpenLinksConfirmation(indices.size(), tr("Web Pages"))) { + return; + } + } + + for (const auto& idx : indices) { + ModInfo::Ptr const info = ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt()); + if (!info) { + log::error("mod {} not found", idx.data(ModList::IndexRole).toInt()); + continue; + } + + int const modID = info->nexusId(); + QString const gameName = info->gameName(); + const auto url = info->parseCustomURL(); + + if (modID > 0) { + shell::Open(QUrl(NexusInterface::instance().getModURL(modID, gameName))); + } else if (url.isValid()) { + shell::Open(url); + } else { + log::error("mod '{}' has no valid link", info->name()); + } + } +} + +void ModListViewActions::visitUploaderProfile(const QModelIndexList& indices) const +{ + if (indices.size() > 10) { + if (!askOpenLinksConfirmation(indices.size(), tr("Uploader Profiles"))) { + return; + } + } + + for (const auto& idx : indices) { + ModInfo::Ptr const info = ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt()); + const auto uploaderUrl = info->uploaderUrl(); + + if (!uploaderUrl.isEmpty()) { + shell::Open(QUrl(uploaderUrl)); + } else { + log::error("mod '{}' has no uploader url", info->name()); + } + } +} + +bool ModListViewActions::askOpenLinksConfirmation(std::size_t numberOfLinks, + const QString& nameOfLinks) const +{ + return QMessageBox::question(m_parent, tr("Opening %1").arg(nameOfLinks), + tr("You are trying to open %1 %2. Are you sure " + "you want to do this?") + .arg(numberOfLinks) + .arg(nameOfLinks), + QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes; +} + +void ModListViewActions::reinstallMod(const QModelIndex& index) const +{ + ModInfo::Ptr const modInfo = ModInfo::getByIndex(index.data(ModList::IndexRole).toInt()); + QString const installationFile = modInfo->installationFile(); + if (installationFile.length() != 0) { + QString fullInstallationFile; + QFileInfo const fileInfo(installationFile); + if (fileInfo.isAbsolute()) { + if (fileInfo.exists()) { + fullInstallationFile = installationFile; + } else { + fullInstallationFile = + m_core.downloadManager()->getOutputDirectory() + "/" + fileInfo.fileName(); + } + } else { + fullInstallationFile = + m_core.downloadManager()->getOutputDirectory() + "/" + installationFile; + } + if (QFile::exists(fullInstallationFile)) { + m_core.installMod(fullInstallationFile, -1, true, modInfo, modInfo->name()); + } else { + QMessageBox::information(m_parent, tr("Failed"), + tr("Installation file no longer exists")); + } + } else { + QMessageBox::information( + m_parent, tr("Failed"), + tr("Mods installed with old versions of MO can't be reinstalled in this way.")); + } +} + +void ModListViewActions::createBackup(const QModelIndex& index) const +{ + ModInfo::Ptr const modInfo = ModInfo::getByIndex(index.data(ModList::IndexRole).toInt()); + QString const backupDirectory = + m_core.installationManager()->generateBackupName(modInfo->absolutePath()); + if (!copyDir(modInfo->absolutePath(), backupDirectory, false)) { + QMessageBox::information(m_parent, tr("Failed"), tr("Failed to create backup.")); + } + m_core.refresh(); + m_view->updateModCount(); +} + +void ModListViewActions::restoreHiddenFiles(const QModelIndexList& indices) const +{ + const int max_items = 20; + + QFlags flags = FileRenamer::UNHIDE; + flags |= FileRenamer::MULTIPLE; + + FileRenamer renamer(m_parent, flags); + + FileRenamer::RenameResults result = FileRenamer::RESULT_OK; + + // multi selection + if (indices.size() > 1) { + + QStringList modNames; + for (const auto& idx : indices) { + + ModInfo::Ptr const modInfo = ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt()); + const auto flags = modInfo->getFlags(); + + if (!modInfo->isRegular() || + std::find(flags.begin(), flags.end(), ModInfo::FLAG_HIDDEN_FILES) == + flags.end()) { + continue; + } + + modNames.append(idx.data(Qt::DisplayRole).toString()); + } + + QString mods = "
  • " + modNames.mid(0, max_items).join("
  • ") + "
  • "; + if (modNames.size() > max_items) { + mods += "
  • ...
  • "; + } + + if (QMessageBox::question( + m_parent, tr("Confirm"), + tr("Restore all hidden files in the following mods?
      %1
    ") + .arg(mods), + QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { + + for (const auto& idx : indices) { + + ModInfo::Ptr const modInfo = + ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt()); + + const auto flags = modInfo->getFlags(); + if (std::find(flags.begin(), flags.end(), ModInfo::FLAG_HIDDEN_FILES) != + flags.end()) { + const QString modDir = modInfo->absolutePath(); + + auto partialResult = restoreHiddenFilesRecursive(renamer, modDir); + + if (partialResult == FileRenamer::RESULT_CANCEL) { + result = FileRenamer::RESULT_CANCEL; + break; + } + emit originModified((m_core.directoryStructure()->getOriginByName( + ToWString(modInfo->internalName()))) + .getID()); + } + } + } + } else if (!indices.isEmpty()) { + // single selection + ModInfo::Ptr const modInfo = + ModInfo::getByIndex(indices[0].data(ModList::IndexRole).toInt()); + const QString modDir = modInfo->absolutePath(); + + if (QMessageBox::question( + m_parent, tr("Are you sure?"), + tr("About to restore all hidden files in:\n") + modInfo->name(), + QMessageBox::Ok | QMessageBox::Cancel) == QMessageBox::Ok) { + + result = restoreHiddenFilesRecursive(renamer, modDir); + + emit originModified((m_core.directoryStructure()->getOriginByName( + ToWString(modInfo->internalName()))) + .getID()); + } + } + + if (result == FileRenamer::RESULT_CANCEL) { + log::debug("Restoring hidden files operation cancelled"); + } else { + log::debug("Finished restoring hidden files"); + } +} + +void ModListViewActions::setTracked(const QModelIndexList& indices, bool tracked) const +{ + m_core.loggedInAction(m_parent, [=, this] { + for (const auto& idx : indices) { + ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt())->track(tracked); + } + }); +} + +void ModListViewActions::setEndorsed(const QModelIndexList& indices, + bool endorsed) const +{ + m_core.loggedInAction(m_parent, [=, this] { + if (indices.size() > 1) { + MessageDialog::showMessage( + tr("Endorsing multiple mods will take a while. Please wait..."), m_parent); + } + + for (const auto& idx : indices) { + ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt())->endorse(endorsed); + } + }); +} + +void ModListViewActions::willNotEndorsed(const QModelIndexList& indices) +{ + for (const auto& idx : indices) { + ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt())->setNeverEndorse(); + } +} + +void ModListViewActions::remapCategory(const QModelIndexList& indices) const +{ + for (const auto& idx : indices) { + ModInfo::Ptr const modInfo = ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt()); + if (modInfo->isSeparator()) + continue; + + int categoryID = modInfo->getNexusCategory(); + if (!categoryID) { + QSettings const downloadMeta(m_core.downloadsPath() + "/" + + modInfo->installationFile() + ".meta", + QSettings::IniFormat); + if (downloadMeta.contains("category")) { + categoryID = downloadMeta.value("category", 0).toInt(); + } + } + unsigned int const categoryIndex = CategoryFactory::instance().resolveNexusID(categoryID); + if (categoryIndex != 0) + modInfo->setPrimaryCategory( + CategoryFactory::instance().getCategoryID(categoryIndex)); + } +} + +void ModListViewActions::setColor(const QModelIndexList& indices, + const QModelIndex& refIndex) const +{ + auto& settings = m_core.settings(); + ModInfo::Ptr const modInfo = ModInfo::getByIndex(refIndex.data(ModList::IndexRole).toInt()); + + QColorDialog dialog(m_parent); + dialog.setOption(QColorDialog::ShowAlphaChannel); + + QColor currentColor = modInfo->color(); + if (currentColor.isValid()) { + dialog.setCurrentColor(currentColor); + } else if (auto c = settings.colors().previousSeparatorColor()) { + dialog.setCurrentColor(*c); + } + + if (!dialog.exec()) + return; + + currentColor = dialog.currentColor(); + if (!currentColor.isValid()) + return; + + settings.colors().setPreviousSeparatorColor(currentColor); + + for (const auto& idx : indices) { + ModInfo::Ptr const info = ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt()); + info->setColor(currentColor); + } +} + +void ModListViewActions::resetColor(const QModelIndexList& indices) const +{ + for (const auto& idx : indices) { + ModInfo::Ptr const info = ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt()); + info->setColor(QColor()); + } + m_core.settings().colors().removePreviousSeparatorColor(); +} + +void ModListViewActions::setCategories( + ModInfo::Ptr mod, const std::vector>& categories) +{ + for (const auto& [id, enabled] : categories) { + mod->setCategory(id, enabled); + } +} + +void ModListViewActions::setCategoriesIf( + ModInfo::Ptr mod, ModInfo::Ptr ref, + const std::vector>& categories) +{ + for (const auto& [id, enabled] : categories) { + if (ref->categorySet(id) != enabled) { + mod->setCategory(id, enabled); + } + } +} + +void ModListViewActions::setCategories( + const QModelIndexList& selected, const QModelIndex& ref, + const std::vector>& categories) const +{ + ModInfo::Ptr const refMod = ModInfo::getByIndex(ref.data(ModList::IndexRole).toInt()); + if (selected.size() > 1) { + for (const auto& idx : selected) { + if (idx.row() != ref.row()) { + setCategoriesIf(ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt()), + refMod, categories); + } + } + setCategories(refMod, categories); + } else if (!selected.isEmpty()) { + // for single mod selections, just do a replace + setCategories(refMod, categories); + } + + for (const auto& idx : selected) { + m_core.modList()->notifyChange(idx.data(ModList::IndexRole).toInt()); + } + + // reset the selection manually - still needed + auto viewIndices = m_view->indexModelToView(selected); + for (auto& idx : viewIndices) { + m_view->selectionModel()->select(idx, QItemSelectionModel::Select | + QItemSelectionModel::Rows); + } +} + +void ModListViewActions::setPrimaryCategory(const QModelIndexList& selected, + int category, bool force) +{ + for (const auto& idx : selected) { + ModInfo::Ptr const info = ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt()); + if (force || info->categorySet(category)) { + info->setCategory(category, true); + info->setPrimaryCategory(category); + } + } + + // reset the selection manually - still needed + auto viewIndices = m_view->indexModelToView(selected); + for (auto& idx : viewIndices) { + m_view->selectionModel()->select(idx, QItemSelectionModel::Select | + QItemSelectionModel::Rows); + } +} + +void ModListViewActions::openExplorer(const QModelIndexList& index) +{ + for (const auto& idx : index) { + ModInfo::Ptr const info = ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt()); + if (!info->isForeign()) { + shell::Explore(info->absolutePath()); + } + } +} + +void ModListViewActions::restoreBackup(const QModelIndex& index) const +{ + QRegularExpression const backupRegEx("(.*)_backup[0-9]*$"); + ModInfo::Ptr const modInfo = ModInfo::getByIndex(index.data(ModList::IndexRole).toInt()); + auto match = backupRegEx.match(modInfo->name()); + if (match.hasMatch()) { + QString const regName = match.captured(1); + QDir modDir(QDir::fromNativeSeparators(m_core.settings().paths().mods())); + if (!modDir.exists(regName) || + (QMessageBox::question( + m_parent, tr("Overwrite?"), + tr("This will replace the existing mod \"%1\". Continue?").arg(regName), + QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes)) { + if (modDir.exists(regName) && + !shellDelete(QStringList(modDir.absoluteFilePath(regName)))) { + reportError(tr("failed to remove mod \"%1\"").arg(regName)); + } else { + QString const destinationPath = + QDir::fromNativeSeparators(m_core.settings().paths().mods()) + "/" + + regName; + if (!modDir.rename(modInfo->absolutePath(), destinationPath)) { + reportError(tr(R"(failed to rename "%1" to "%2")") + .arg(modInfo->absolutePath()) + .arg(destinationPath)); + } + m_core.refresh(); + m_view->updateModCount(); + } + } + } +} + +void ModListViewActions::moveOverwriteContentsTo(const QString& absolutePath) const +{ + ModInfo::Ptr const overwriteInfo = ModInfo::getOverwrite(); + const QString overwritePath = overwriteInfo->absolutePath(); + const QDir overwriteDir(overwritePath); + const QDir destDir(absolutePath); + + // Recursively move every file from overwrite into the destination mod, + // preserving the directory structure. + bool successful = true; + int movedCount = 0; + + QDirIterator iter(overwritePath, QDir::Files | QDir::NoDotAndDotDot, + QDirIterator::Subdirectories); + while (iter.hasNext()) { + iter.next(); + const QString relPath = overwriteDir.relativeFilePath(iter.filePath()); + const QString destFile = destDir.filePath(relPath); + + // Ensure destination subdirectory exists. + QDir().mkpath(QFileInfo(destFile).absolutePath()); + + // Remove existing file at destination (overwrite semantics). + if (QFile::exists(destFile)) { + QFile::remove(destFile); + } + + if (!QFile::rename(iter.filePath(), destFile)) { + // Fallback: copy + delete (cross-filesystem). + if (!QFile::copy(iter.filePath(), destFile) || !QFile::remove(iter.filePath())) { + log::error("Failed to move {} -> {}", relPath, destFile); + successful = false; + break; + } + } + ++movedCount; + } + + // Clean up empty directories left behind in overwrite. + // Sort by path length descending so leaf dirs are removed before parents. + if (movedCount > 0) { + QDirIterator dirIter(overwritePath, QDir::Dirs | QDir::NoDotAndDotDot, + QDirIterator::Subdirectories); + QStringList dirs; + while (dirIter.hasNext()) { + dirs.append(dirIter.next()); + } + std::sort(dirs.begin(), dirs.end(), [](const QString& a, const QString& b) { + return a.length() > b.length(); + }); + for (const auto& dir : dirs) { + QDir().rmdir(dir); // Only removes if empty. + } + } + + if (successful && movedCount > 0) { + // Track all files now in the target mod so future VFS writes go + // back to this mod instead of creating new copies in Overwrite. + QDirIterator trackIter(absolutePath, QDir::Files | QDir::NoDotAndDotDot, + QDirIterator::Subdirectories); + while (trackIter.hasNext()) { + trackIter.next(); + QString const relPath = destDir.relativeFilePath(trackIter.filePath()); + m_core.trackOverwriteMove(relPath, absolutePath); + } + MessageDialog::showMessage(tr("Move successful."), m_parent); + } else if (!successful) { + log::error("Move operation failed"); + } + + m_core.refresh(); +} + +void ModListViewActions::createModFromOverwrite() const +{ + GuessedValue name; + name.setFilter(&fixDirectoryName); + + while (name->isEmpty()) { + bool ok; + name.update( + QInputDialog::getText( + m_parent, tr("Create Mod..."), + tr("This will move all files from overwrite into a new, regular mod.\n" + "Please enter a name:"), + QLineEdit::Normal, "", &ok), + GUESS_USER); + if (!ok) { + return; + } + } + + if (m_core.modList()->getMod(name) != nullptr) { + reportError(tr("A mod with this name already exists")); + return; + } + + const IModInterface* newMod = m_core.createMod(name); + if (newMod == nullptr) { + return; + } + + moveOverwriteContentsTo(newMod->absolutePath()); +} + +void ModListViewActions::moveOverwriteContentToExistingMod() const +{ + QStringList mods; + auto indexesByPriority = m_core.currentProfile()->getAllIndexesByPriority(); + for (auto& iter : indexesByPriority) { + if ((iter.second != UINT_MAX)) { + ModInfo::Ptr const modInfo = ModInfo::getByIndex(iter.second); + if (!modInfo->isSeparator() && !modInfo->isForeign() && !modInfo->isOverwrite()) { + mods << modInfo->name(); + } + } + } + + ListDialog dialog(m_parent); + dialog.setWindowTitle("Select a mod..."); + dialog.setChoices(mods); + + if (dialog.exec() == QDialog::Accepted) { + QString result = dialog.getChoice(); + if (!result.isEmpty()) { + + QString modAbsolutePath; + + for (const auto& mod : m_core.modList()->allModsByProfilePriority()) { + if (result.compare(mod) == 0) { + ModInfo::Ptr const modInfo = ModInfo::getByIndex(ModInfo::getIndex(mod)); + modAbsolutePath = modInfo->absolutePath(); + break; + } + } + + if (modAbsolutePath.isNull()) { + log::warn("Mod {} has not been found, for some reason", result); + return; + } + + moveOverwriteContentsTo(modAbsolutePath); + } + } +} + +void ModListViewActions::clearOverwrite() const +{ + ModInfo::Ptr const modInfo = ModInfo::getOverwrite(); + if (modInfo) { + QDir const overwriteDir(modInfo->absolutePath()); + if (QMessageBox::question( + m_parent, tr("Are you sure?"), + tr("About to recursively delete:\n") + overwriteDir.absolutePath(), + QMessageBox::Ok | QMessageBox::Cancel) == QMessageBox::Ok) { + QStringList delList; + for (const auto& f : overwriteDir.entryInfoList(QDir::AllDirs | QDir::Files | + QDir::NoDotAndDotDot)) { + if (f.isDir() && m_core.managedGame()->getModMappings().keys().contains( + f.fileName(), Qt::CaseInsensitive)) { + for (const auto& sf : + QDir(f.absoluteFilePath()) + .entryInfoList(QDir::AllDirs | QDir::Files | QDir::NoDotAndDotDot)) { + delList.push_back(sf.absoluteFilePath()); + } + } else { + delList.push_back(f.absoluteFilePath()); + } + } + if (shellDelete(delList, true)) { + emit overwriteCleared(); + m_core.refresh(); + } else { + const auto e = GetLastError(); + log::error("Delete operation failed: {}", formatSystemMessage(e)); + } + } + } +} diff --git a/src/src/nexusinterface.cpp b/src/src/nexusinterface.cpp index 09240e9..20399d5 100644 --- a/src/src/nexusinterface.cpp +++ b/src/src/nexusinterface.cpp @@ -1,1327 +1,1326 @@ -/* -Copyright (C) 2012 Sebastian Herbord. All rights reserved. - -This file is part of Mod Organizer. - -Mod Organizer is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -Mod Organizer is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with Mod Organizer. If not, see . -*/ - -#include "nexusinterface.h" - -#include "bbcode.h" -#include "iplugingame.h" -#include "nxmaccessmanager.h" -#include "selectiondialog.h" -#include "settings.h" -#include "shared/util.h" -#include -#include -#include - -#include -#include -#include -#include - -#include - -using namespace MOBase; -using namespace MOShared; - -void throttledWarning(const APIUserAccount& user) -{ - log::error("You have fewer than {} requests remaining ({}). Only downloads and " - "login validation are being allowed.", - APIUserAccount::ThrottleThreshold, user.remainingRequests()); -} - -NexusBridge::NexusBridge(PluginContainer* pluginContainer, const QString& subModule) - : m_Interface(&NexusInterface::instance()), m_SubModule(subModule) -{} - -void NexusBridge::requestDescription(QString gameName, int modID, QVariant userData) -{ - m_RequestIDs.insert( - m_Interface->requestDescription(gameName, modID, this, userData, m_SubModule)); -} - -void NexusBridge::requestFiles(QString gameName, int modID, QVariant userData) -{ - m_RequestIDs.insert( - m_Interface->requestFiles(gameName, modID, this, userData, m_SubModule)); -} - -void NexusBridge::requestFileInfo(QString gameName, int modID, int fileID, - QVariant userData) -{ - m_RequestIDs.insert(m_Interface->requestFileInfo(gameName, modID, fileID, this, - userData, m_SubModule)); -} - -void NexusBridge::requestDownloadURL(QString gameName, int modID, int fileID, - QVariant userData) -{ - m_RequestIDs.insert(m_Interface->requestDownloadURL(gameName, modID, fileID, this, - userData, m_SubModule)); -} - -void NexusBridge::requestToggleEndorsement(QString gameName, int modID, - QString modVersion, bool endorse, - QVariant userData) -{ - m_RequestIDs.insert(m_Interface->requestToggleEndorsement( - gameName, modID, modVersion, endorse, this, userData, m_SubModule)); -} - -void NexusBridge::requestToggleTracking(QString gameName, int modID, bool track, - QVariant userData) -{ - m_RequestIDs.insert(m_Interface->requestToggleTracking(gameName, modID, track, this, - userData, m_SubModule)); -} - -void NexusBridge::requestGameInfo(QString gameName, QVariant userData) -{ - m_RequestIDs.insert( - m_Interface->requestGameInfo(gameName, this, userData, m_SubModule)); -} - -void NexusBridge::nxmDescriptionAvailable(QString gameName, int modID, - QVariant userData, QVariant resultData, - int requestID) -{ - std::set::iterator const iter = m_RequestIDs.find(requestID); - if (iter != m_RequestIDs.end()) { - m_RequestIDs.erase(iter); - - emit descriptionAvailable(gameName, modID, userData, resultData); - } -} - -void NexusBridge::nxmFilesAvailable(QString gameName, int modID, QVariant userData, - QVariant resultData, int requestID) -{ - std::set::iterator const iter = m_RequestIDs.find(requestID); - if (iter != m_RequestIDs.end()) { - m_RequestIDs.erase(iter); - - QList fileInfoList; - - QVariantMap resultInfo = resultData.toMap(); - QList const resultList = resultInfo["files"].toList(); - - for (const QVariant& file : resultList) { - ModRepositoryFileInfo temp; - QVariantMap fileInfo = file.toMap(); - temp.uri = fileInfo["file_name"].toString(); - temp.name = fileInfo["name"].toString(); - temp.description = BBCode::convertToHTML(fileInfo["description"].toString()); - temp.version = VersionInfo(fileInfo["version"].toString()); - temp.categoryID = fileInfo["category_id"].toInt(); - temp.fileID = fileInfo["file_id"].toInt(); - temp.fileSize = fileInfo["size"].toInt(); - temp.author = fileInfo["author"].toString(); - temp.uploader = fileInfo["uploaded_by"].toString(); - temp.uploaderUrl = fileInfo["uploaded_users_profile_url"].toString(); - fileInfoList.append(&temp); - } - - emit filesAvailable(gameName, modID, userData, fileInfoList); - } -} - -void NexusBridge::nxmFileInfoAvailable(QString gameName, int modID, int fileID, - QVariant userData, QVariant resultData, - int requestID) -{ - std::set::iterator const iter = m_RequestIDs.find(requestID); - if (iter != m_RequestIDs.end()) { - m_RequestIDs.erase(iter); - emit fileInfoAvailable(gameName, modID, fileID, userData, resultData); - } -} - -void NexusBridge::nxmDownloadURLsAvailable(QString gameName, int modID, int fileID, - QVariant userData, QVariant resultData, - int requestID) -{ - std::set::iterator const iter = m_RequestIDs.find(requestID); - if (iter != m_RequestIDs.end()) { - m_RequestIDs.erase(iter); - emit downloadURLsAvailable(gameName, modID, fileID, userData, resultData); - } -} - -void NexusBridge::nxmEndorsementsAvailable(QVariant userData, QVariant resultData, - int requestID) -{ - std::set::iterator const iter = m_RequestIDs.find(requestID); - if (iter != m_RequestIDs.end()) { - m_RequestIDs.erase(iter); - emit endorsementsAvailable(userData, resultData); - } -} - -void NexusBridge::nxmEndorsementToggled(QString gameName, int modID, QVariant userData, - QVariant resultData, int requestID) -{ - std::set::iterator const iter = m_RequestIDs.find(requestID); - if (iter != m_RequestIDs.end()) { - m_RequestIDs.erase(iter); - emit endorsementToggled(gameName, modID, userData, resultData); - } -} - -void NexusBridge::nxmTrackedModsAvailable(QVariant userData, QVariant resultData, - int requestID) -{ - std::set::iterator const iter = m_RequestIDs.find(requestID); - if (iter != m_RequestIDs.end()) { - m_RequestIDs.erase(iter); - emit trackedModsAvailable(userData, resultData); - } -} - -void NexusBridge::nxmTrackingToggled(QString gameName, int modID, QVariant userData, - bool tracked, int requestID) -{ - std::set::iterator const iter = m_RequestIDs.find(requestID); - if (iter != m_RequestIDs.end()) { - m_RequestIDs.erase(iter); - emit trackingToggled(gameName, modID, userData, tracked); - } -} - -void NexusBridge::nxmGameInfoAvailable(QString gameName, QVariant userData, - QVariant resultData, int requestID) -{ - std::set::iterator const iter = m_RequestIDs.find(requestID); - if (iter != m_RequestIDs.end()) { - m_RequestIDs.erase(iter); - emit gameInfoAvailable(gameName, userData, resultData); - } -} - -void NexusBridge::nxmRequestFailed(QString gameName, int modID, int fileID, - QVariant userData, int requestID, int errorCode, - const QString& errorMessage) -{ - std::set::iterator const iter = m_RequestIDs.find(requestID); - if (iter != m_RequestIDs.end()) { - m_RequestIDs.erase(iter); - emit requestFailed(gameName, modID, fileID, userData, errorCode, errorMessage); - } -} - -QAtomicInt NexusInterface::NXMRequestInfo::s_NextID(0); - -APILimits NexusInterface::defaultAPILimits() -{ - // https://app.swaggerhub.com/apis-docs/NexusMods/nexus-mods_public_api_params_in_form_data/1.0#/ - const int MaxDaily = 2500; - const int MaxHourly = 100; - - APILimits limits; - - limits.maxDailyRequests = MaxDaily; - limits.remainingDailyRequests = MaxDaily; - limits.maxHourlyRequests = MaxHourly; - limits.remainingHourlyRequests = MaxHourly; - - return limits; -} - -APILimits NexusInterface::parseLimits(const QNetworkReply* reply) -{ - return parseLimits(reply->rawHeaderPairs()); -} - -APILimits -NexusInterface::parseLimits(const QList& headers) -{ - APILimits limits; - - for (const auto& pair : headers) { - const auto name = QString(pair.first).toLower(); - - if (name == "x-rl-daily-limit") { - limits.maxDailyRequests = pair.second.toInt(); - } else if (name == "x-rl-daily-remaining") { - limits.remainingDailyRequests = pair.second.toInt(); - } else if (name == "x-rl-hourly-limit") { - limits.maxHourlyRequests = pair.second.toInt(); - } else if (name == "x-rl-hourly-remaining") { - limits.remainingHourlyRequests = pair.second.toInt(); - } - } - - return limits; -} - -static NexusInterface* g_instance = nullptr; - -NexusInterface::NexusInterface(Settings* s) -{ - MO_ASSERT(!g_instance); - g_instance = this; - - m_User.limits(defaultAPILimits()); - - m_AccessManager = new NXMAccessManager(this, s, createVersionInfo().string()); - - m_DiskCache = new QNetworkDiskCache(this); - - connect(m_AccessManager, &NXMAccessManager::requestNXMDownload, this, - &NexusInterface::downloadRequestedNXM); - - connect(m_AccessManager, &NXMAccessManager::credentialsReceived, this, - &NexusInterface::setUserAccount); -} - -NexusInterface::~NexusInterface() -{ - cleanup(); - - MO_ASSERT(g_instance == this); - g_instance = nullptr; -} - -NXMAccessManager* NexusInterface::getAccessManager() -{ - return m_AccessManager; -} - -NexusInterface& NexusInterface::instance() -{ - MO_ASSERT(g_instance); - return *g_instance; -} - -void NexusInterface::setCacheDirectory(const QString& directory) -{ - m_DiskCache->setCacheDirectory(directory); - m_AccessManager->setCache(m_DiskCache); -} - -void NexusInterface::loginCompleted() -{ - nextRequest(); -} - -void NexusInterface::setUserAccount(const APIUserAccount& user) -{ - m_User = user; - emit requestsChanged(getAPIStats(), m_User); -} - -void NexusInterface::interpretNexusFileName(const QString& fileName, QString& modName, - int& modID, bool query) -{ - // guess the mod name from the file name - static const QRegularExpression complex( - R"(^([a-zA-Z0-9_'"\-.() ]*?)([-_ ][VvRr]+[0-9]+(?:(?:[\.][0-9]+){0,2}|(?:[_][0-9]+){0,2}|(?:[-.][0-9]+){0,2})?[ab]?)??-([1-9][0-9]+)?-.*?\.(zip|rar|7z))"); - // complex regex explanation: - // group 1: modname. - // group 2: optional version, - // assumed to start with v (empty most of the time). - // group 3: NexusId, - // assumed wrapped in "-", will miss single digit IDs for better accuracy. - // If no id is present the whole regex does not match. - static const QRegularExpression simple(R"(^[^a-zA-Z]*([a-zA-Z_ ]+))"); - auto complexMatch = complex.match(fileName); - auto simpleMatch = simple.match(fileName); - - // assume not found - modID = -1; - - if (complexMatch.hasMatch()) { - modName = complexMatch.captured(1); - if (!query && !complexMatch.captured(3).isNull()) { - modID = complexMatch.captured(3).toInt(); - } - } else if (simpleMatch.hasMatch()) { - modName = simpleMatch.captured(0); - } else { - modName.clear(); - } - - if (query) { - SelectionDialog selection(tr("Please pick the mod ID for \"%1\"").arg(fileName)); - int index = 0; - auto splits = fileName.split(QRegularExpression("[^0-9]"), Qt::KeepEmptyParts); - for (const auto& substr : splits) { - bool ok = false; - int const value = substr.toInt(&ok); - if (ok) { - QString highlight(fileName); - highlight.insert(index, " *"); - highlight.insert(index + substr.length() + 2, "* "); - - QStringList choice; - choice << substr; - choice << (index > 0 ? fileName.left(index - 1) : substr); - selection.addChoice(substr, highlight, choice); - } - index += substr.length() + 1; - } - - if (selection.numChoices() > 0) { - if (selection.exec() == QDialog::Accepted) { - auto choice = selection.getChoiceData().toStringList(); - modID = choice.at(0).toInt(); - modName = choice.at(1); - modName = modName.replace('_', ' ').trimmed(); - log::debug("user selected mod ID {} and mod name \"{}\"", modID, modName); - } else { - log::debug("user canceled mod ID selection"); - } - } else { - log::debug("no possible mod IDs found in file name"); - } - } -} - -bool NexusInterface::isURLGameRelated(const QUrl& url) const -{ - QString const name(url.toString()); - return name.startsWith(getGameURL("") + "/") || - name.startsWith(getOldModsURL("") + "/"); -} - -QString NexusInterface::getGameURL(QString gameName) const -{ - IPluginGame* game = getGame(gameName); - if (game != nullptr) { - QString const gameNexusName = game->gameNexusName().toLower(); - if (gameNexusName.isEmpty()) { - return ""; - } else { - return "https://www.nexusmods.com/" + gameNexusName; - } - } else { - log::error("getGameURL can't find plugin for {}", gameName); - return ""; - } -} - -QString NexusInterface::getOldModsURL(QString gameName) const -{ - IPluginGame* game = getGame(gameName); - if (game != nullptr) { - return "https://" + game->gameNexusName().toLower() + ".nexusmods.com/mods"; - } else { - log::error("getOldModsURL can't find plugin for {}", gameName); - return ""; - } -} - -QString NexusInterface::getModURL(int modID, QString gameName = "") const -{ - return QString("%1/mods/%2").arg(getGameURL(gameName)).arg(modID); -} - -std::vector> -NexusInterface::getGameChoices(const MOBase::IPluginGame* game) -{ - std::vector> choices; - choices.push_back( - std::pair(game->gameShortName(), game->gameName())); - for (const QString& gameName : game->validShortNames()) { - for (auto gamePlugin : m_PluginContainer->plugins()) { - if (gamePlugin->gameShortName().compare(gameName, Qt::CaseInsensitive) == 0) { - choices.push_back(std::pair(gamePlugin->gameShortName(), - gamePlugin->gameName())); - break; - } - } - } - return choices; -} - -bool NexusInterface::isModURL(int modID, const QString& url) const -{ - if (QUrl(url) == QUrl(getModURL(modID))) { - return true; - } - // Try the alternate (old style) mod name - QString const alt = QString("%1/%2").arg(getOldModsURL("")).arg(modID); - return QUrl(alt) == QUrl(url); -} - -void NexusInterface::setPluginContainer(PluginContainer* pluginContainer) -{ - m_PluginContainer = pluginContainer; -} - -int NexusInterface::requestDescription(QString gameName, int modID, QObject* receiver, - QVariant userData, const QString& subModule, - MOBase::IPluginGame const* game) -{ - NXMRequestInfo requestInfo(modID, NXMRequestInfo::TYPE_DESCRIPTION, userData, - subModule, game); - applyGameNameOverride(requestInfo, gameName, game); - m_RequestQueue.enqueue(requestInfo); - - connect(this, SIGNAL(nxmDescriptionAvailable(QString, int, QVariant, QVariant, int)), - receiver, - SLOT(nxmDescriptionAvailable(QString, int, QVariant, QVariant, int)), - Qt::UniqueConnection); - - connect( - this, SIGNAL(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), - receiver, SLOT(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), - Qt::UniqueConnection); - - nextRequest(); - return requestInfo.m_ID; -} - -int NexusInterface::requestModInfo(QString gameName, int modID, QObject* receiver, - QVariant userData, const QString& subModule, - MOBase::IPluginGame const* game) -{ - if (m_User.shouldThrottle()) { - throttledWarning(m_User); - return -1; - } - - NXMRequestInfo requestInfo(modID, NXMRequestInfo::TYPE_MODINFO, userData, subModule, - game); - applyGameNameOverride(requestInfo, gameName, game); - m_RequestQueue.enqueue(requestInfo); - - connect(this, SIGNAL(nxmModInfoAvailable(QString, int, QVariant, QVariant, int)), - receiver, SLOT(nxmModInfoAvailable(QString, int, QVariant, QVariant, int)), - Qt::UniqueConnection); - - connect( - this, SIGNAL(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), - receiver, SLOT(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), - Qt::UniqueConnection); - - nextRequest(); - return requestInfo.m_ID; -} - -int NexusInterface::requestUpdateInfo(QString gameName, - NexusInterface::UpdatePeriod period, - QObject* receiver, QVariant userData, - const QString& subModule, - const MOBase::IPluginGame* game) -{ - if (m_User.shouldThrottle()) { - throttledWarning(m_User); - return -1; - } - - NXMRequestInfo const requestInfo(period, NXMRequestInfo::TYPE_CHECKUPDATES, userData, - subModule, game); - m_RequestQueue.enqueue(requestInfo); - - connect(this, SIGNAL(nxmUpdateInfoAvailable(QString, QVariant, QVariant, int)), - receiver, SLOT(nxmUpdateInfoAvailable(QString, QVariant, QVariant, int)), - Qt::UniqueConnection); - - connect( - this, SIGNAL(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), - receiver, SLOT(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), - Qt::UniqueConnection); - - nextRequest(); - return requestInfo.m_ID; -} - -int NexusInterface::requestUpdates(const int& modID, QObject* receiver, - QVariant userData, QString gameName, - const QString& subModule) -{ - if (m_User.shouldThrottle()) { - throttledWarning(m_User); - return -1; - } - - IPluginGame* game = getGame(gameName); - if (game == nullptr) { - log::error("requestUpdates can't find plugin for {}", gameName); - return -1; - } - - NXMRequestInfo const requestInfo(modID, NXMRequestInfo::TYPE_GETUPDATES, userData, - subModule, game); - m_RequestQueue.enqueue(requestInfo); - - connect(this, SIGNAL(nxmUpdatesAvailable(QString, int, QVariant, QVariant, int)), - receiver, SLOT(nxmUpdatesAvailable(QString, int, QVariant, QVariant, int)), - Qt::UniqueConnection); - - connect( - this, SIGNAL(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), - receiver, SLOT(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), - Qt::UniqueConnection); - - nextRequest(); - return requestInfo.m_ID; -} - -void NexusInterface::fakeFiles() -{ - static int id = 42; - - QVariantList result; - QVariantMap fileMap; - fileMap["uri"] = "fakeURI"; - fileMap["name"] = "fakeName"; - fileMap["description"] = "fakeDescription"; - fileMap["version"] = "1.0.0"; - fileMap["category_id"] = "1"; - fileMap["id"] = "1"; - fileMap["size"] = "512"; - fileMap["author"] = "fakeAuthor"; - fileMap["uploaded_by"] = "fakeUploader"; - fileMap["uploaded_users_profile_url"] = "https://fakeuploader.com"; - result.append(fileMap); - - emit nxmFilesAvailable("fakeGame", 1234, "fake", result, id++); -} - -int NexusInterface::requestFiles(QString gameName, int modID, QObject* receiver, - QVariant userData, const QString& subModule, - MOBase::IPluginGame const* game) -{ - NXMRequestInfo const requestInfo(modID, NXMRequestInfo::TYPE_FILES, userData, subModule, - game); - m_RequestQueue.enqueue(requestInfo); - connect(this, SIGNAL(nxmFilesAvailable(QString, int, QVariant, QVariant, int)), - receiver, SLOT(nxmFilesAvailable(QString, int, QVariant, QVariant, int)), - Qt::UniqueConnection); - - connect( - this, SIGNAL(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), - receiver, SLOT(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), - Qt::UniqueConnection); - - nextRequest(); - return requestInfo.m_ID; -} - -int NexusInterface::requestFileInfo(QString gameName, int modID, int fileID, - QObject* receiver, QVariant userData, - const QString& subModule) -{ - IPluginGame* gamePlugin = getGame(gameName); - if (gamePlugin == nullptr) { - log::error("requestFileInfo can't find plugin for {}", gameName); - return -1; - } - - NXMRequestInfo requestInfo(modID, fileID, NXMRequestInfo::TYPE_FILEINFO, userData, - subModule, gamePlugin); - applyGameNameOverride(requestInfo, gameName, gamePlugin); - m_RequestQueue.enqueue(requestInfo); - - connect( - this, SIGNAL(nxmFileInfoAvailable(QString, int, int, QVariant, QVariant, int)), - receiver, SLOT(nxmFileInfoAvailable(QString, int, int, QVariant, QVariant, int)), - Qt::UniqueConnection); - - connect( - this, SIGNAL(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), - receiver, SLOT(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), - Qt::UniqueConnection); - - nextRequest(); - return requestInfo.m_ID; -} - -int NexusInterface::requestDownloadURL(QString gameName, int modID, int fileID, - QObject* receiver, QVariant userData, - const QString& subModule, - MOBase::IPluginGame const* game) -{ - NXMRequestInfo requestInfo(modID, fileID, NXMRequestInfo::TYPE_DOWNLOADURL, userData, - subModule, game); - applyGameNameOverride(requestInfo, gameName, game); - m_RequestQueue.enqueue(requestInfo); - - connect(this, - SIGNAL(nxmDownloadURLsAvailable(QString, int, int, QVariant, QVariant, int)), - receiver, - SLOT(nxmDownloadURLsAvailable(QString, int, int, QVariant, QVariant, int)), - Qt::UniqueConnection); - - connect( - this, SIGNAL(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), - receiver, SLOT(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), - Qt::UniqueConnection); - - nextRequest(); - return requestInfo.m_ID; -} - -int NexusInterface::requestEndorsementInfo(QObject* receiver, QVariant userData, - const QString& subModule) -{ - NXMRequestInfo const requestInfo(NXMRequestInfo::TYPE_ENDORSEMENTS, userData, subModule); - m_RequestQueue.enqueue(requestInfo); - - connect(this, SIGNAL(nxmEndorsementsAvailable(QVariant, QVariant, int)), receiver, - SLOT(nxmEndorsementsAvailable(QVariant, QVariant, int)), - Qt::UniqueConnection); - - connect( - this, SIGNAL(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), - receiver, SLOT(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), - Qt::UniqueConnection); - - nextRequest(); - return requestInfo.m_ID; -} - -int NexusInterface::requestToggleEndorsement(QString gameName, int modID, - QString modVersion, bool endorse, - QObject* receiver, QVariant userData, - const QString& subModule, - MOBase::IPluginGame const* game) -{ - if (m_User.shouldThrottle()) { - throttledWarning(m_User); - return -1; - } - - NXMRequestInfo requestInfo(modID, modVersion, NXMRequestInfo::TYPE_TOGGLEENDORSEMENT, - userData, subModule, game); - requestInfo.m_Endorse = endorse; - m_RequestQueue.enqueue(requestInfo); - - connect(this, SIGNAL(nxmEndorsementToggled(QString, int, QVariant, QVariant, int)), - receiver, SLOT(nxmEndorsementToggled(QString, int, QVariant, QVariant, int)), - Qt::UniqueConnection); - - connect( - this, SIGNAL(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), - receiver, SLOT(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), - Qt::UniqueConnection); - - nextRequest(); - return requestInfo.m_ID; -} - -int NexusInterface::requestTrackingInfo(QObject* receiver, QVariant userData, - const QString& subModule) -{ - NXMRequestInfo const requestInfo(NXMRequestInfo::TYPE_TRACKEDMODS, userData, subModule); - m_RequestQueue.enqueue(requestInfo); - - connect(this, SIGNAL(nxmTrackedModsAvailable(QVariant, QVariant, int)), receiver, - SLOT(nxmTrackedModsAvailable(QVariant, QVariant, int)), Qt::UniqueConnection); - - connect( - this, SIGNAL(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), - receiver, SLOT(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), - Qt::UniqueConnection); - - nextRequest(); - return requestInfo.m_ID; -} - -int NexusInterface::requestToggleTracking(QString gameName, int modID, bool track, - QObject* receiver, QVariant userData, - const QString& subModule, - MOBase::IPluginGame const* game) -{ - if (m_User.shouldThrottle()) { - throttledWarning(m_User); - return -1; - } - - NXMRequestInfo requestInfo(modID, NXMRequestInfo::TYPE_TOGGLETRACKING, userData, - subModule, game); - requestInfo.m_Track = track; - m_RequestQueue.enqueue(requestInfo); - - connect(this, SIGNAL(nxmTrackingToggled(QString, int, QVariant, bool, int)), receiver, - SLOT(nxmTrackingToggled(QString, int, QVariant, bool, int)), - Qt::UniqueConnection); - - connect( - this, SIGNAL(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), - receiver, SLOT(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), - Qt::UniqueConnection); - - nextRequest(); - return requestInfo.m_ID; -} - -int NexusInterface::requestGameInfo(QString gameName, QObject* receiver, - QVariant userData, const QString& subModule, - MOBase::IPluginGame const* game) -{ - if (m_User.shouldThrottle()) { - throttledWarning(m_User); - return -1; - } - - NXMRequestInfo const requestInfo(NXMRequestInfo::TYPE_GAMEINFO, userData, subModule, game); - m_RequestQueue.enqueue(requestInfo); - - connect(this, SIGNAL(nxmGameInfoAvailable(QString, QVariant, QVariant, int)), - receiver, SLOT(nxmGameInfoAvailable(QString, QVariant, QVariant, int)), - Qt::UniqueConnection); - - connect( - this, SIGNAL(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), - receiver, SLOT(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), - Qt::UniqueConnection); - - nextRequest(); - return requestInfo.m_ID; -} - -int NexusInterface::requestInfoFromMd5(QString gameName, QByteArray& hash, - QObject* receiver, QVariant userData, - const QString& subModule, - MOBase::IPluginGame const* game) -{ - NXMRequestInfo requestInfo(hash, NXMRequestInfo::TYPE_FILEINFO_MD5, userData, - subModule, game); - requestInfo.m_Hash = hash; - requestInfo.m_AllowedErrors[QNetworkReply::NetworkError::ContentNotFoundError].append( - 404); - requestInfo.m_IgnoreGenericErrorHandler = true; - m_RequestQueue.enqueue(requestInfo); - - connect(this, SIGNAL(nxmFileInfoFromMd5Available(QString, QVariant, QVariant, int)), - receiver, SLOT(nxmFileInfoFromMd5Available(QString, QVariant, QVariant, int)), - Qt::UniqueConnection); - - connect( - this, SIGNAL(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), - receiver, SLOT(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), - Qt::UniqueConnection); - - nextRequest(); - return requestInfo.m_ID; -} - -IPluginGame* NexusInterface::getGame(QString gameName) const -{ - auto gamePlugins = m_PluginContainer->plugins(); - IPluginGame* gamePlugin = qApp->property("managed_game").value(); - for (auto plugin : gamePlugins) { - if (plugin != nullptr && - plugin->gameShortName().compare(gameName, Qt::CaseInsensitive) == 0) { - gamePlugin = plugin; - break; - } - } - return gamePlugin; -} - -void NexusInterface::cleanup() -{ - m_AccessManager = nullptr; - m_DiskCache = nullptr; -} - -void NexusInterface::clearCache() -{ - m_DiskCache->clear(); - m_AccessManager->clearCookies(); -} - -void NexusInterface::nextRequest() -{ - if ((m_ActiveRequest.size() >= MAX_ACTIVE_DOWNLOADS) || m_RequestQueue.isEmpty()) { - return; - } - - if (!getAccessManager()->validated()) { - if (!getAccessManager()->validateAttempted()) { - emit needLogin(); - return; - } else if (getAccessManager()->validateWaiting()) { - return; - } else { - log::error( - "{}", - tr("You must authorize MO2 in Settings -> Nexus to use the Nexus API.")); - } - } - - if (m_User.exhausted()) { - m_RequestQueue.clear(); - QTime const time = QTime::currentTime(); - QTime targetTime; - targetTime.setHMS((time.hour() + 1) % 23, 5, 0); - QString warning = tr("You've exceeded the Nexus API rate limit and requests are " - "now being throttled. " - "Your next batch of requests will be available in " - "approximately %1 minutes and %2 seconds.") - .arg(time.secsTo(targetTime) / 60) - .arg(time.secsTo(targetTime) % 60); - - log::warn("{}", warning); - return; - } - - NXMRequestInfo info = m_RequestQueue.dequeue(); - info.m_Timeout = new QTimer(this); - info.m_Timeout->setInterval(60000); - - QJsonObject postObject; - QJsonDocument postData(postObject); - bool requestIsDelete = false; - - QString url; - if (!info.m_Reroute) { - bool const hasParams = false; - switch (info.m_Type) { - case NXMRequestInfo::TYPE_DESCRIPTION: - case NXMRequestInfo::TYPE_MODINFO: { - url = QString("%1/games/%2/mods/%3") - .arg(info.m_URL) - .arg(info.m_GameName) - .arg(info.m_ModID); - } break; - case NXMRequestInfo::TYPE_CHECKUPDATES: { - QString period; - switch (info.m_UpdatePeriod) { - case UpdatePeriod::DAY: - period = "1d"; - break; - case UpdatePeriod::WEEK: - period = "1w"; - break; - case UpdatePeriod::MONTH: - period = "1m"; - break; - } - url = QString("%1/games/%2/mods/updated?period=%3") - .arg(info.m_URL) - .arg(info.m_GameName) - .arg(period); - } break; - case NXMRequestInfo::TYPE_FILES: - case NXMRequestInfo::TYPE_GETUPDATES: { - url = QString("%1/games/%2/mods/%3/files") - .arg(info.m_URL) - .arg(info.m_GameName) - .arg(info.m_ModID); - } break; - case NXMRequestInfo::TYPE_FILEINFO: { - url = QString("%1/games/%2/mods/%3/files/%4") - .arg(info.m_URL) - .arg(info.m_GameName) - .arg(info.m_ModID) - .arg(info.m_FileID); - } break; - case NXMRequestInfo::TYPE_DOWNLOADURL: { - ModRepositoryFileInfo* fileInfo = qobject_cast( - qvariant_cast(info.m_UserData)); - if (m_User.type() == APIUserAccountTypes::Premium) { - url = QString("%1/games/%2/mods/%3/files/%4/download_link") - .arg(info.m_URL) - .arg(info.m_GameName) - .arg(info.m_ModID) - .arg(info.m_FileID); - } else if (!fileInfo->nexusKey.isEmpty() && fileInfo->nexusExpires && - fileInfo->nexusDownloadUser == m_User.id().toInt()) { - url = QString("%1/games/%2/mods/%3/files/%4/download_link?key=%5&expires=%6") - .arg(info.m_URL) - .arg(info.m_GameName) - .arg(info.m_ModID) - .arg(info.m_FileID) - .arg(fileInfo->nexusKey) - .arg(fileInfo->nexusExpires); - } else { - log::warn("{}", tr("Aborting download: Either you clicked on a premium-only " - "link and your account is not premium, " - "or the download link was generated by a different account " - "than the one stored in Mod Organizer.")); - return; - } - } break; - case NXMRequestInfo::TYPE_ENDORSEMENTS: { - url = QString("%1/user/endorsements").arg(info.m_URL); - } break; - case NXMRequestInfo::TYPE_TOGGLEENDORSEMENT: { - QString const endorse = info.m_Endorse ? "endorse" : "abstain"; - url = QString("%1/games/%2/mods/%3/%4") - .arg(info.m_URL) - .arg(info.m_GameName) - .arg(info.m_ModID) - .arg(endorse); - postObject.insert("Version", info.m_ModVersion); - postData.setObject(postObject); - } break; - case NXMRequestInfo::TYPE_TOGGLETRACKING: { - url = QStringLiteral("%1/user/tracked_mods?domain_name=%2") - .arg(info.m_URL) - .arg(info.m_GameName); - postObject.insert("mod_id", info.m_ModID); - postData.setObject(postObject); - requestIsDelete = !info.m_Track; - } break; - case NXMRequestInfo::TYPE_TRACKEDMODS: { - url = QStringLiteral("%1/user/tracked_mods").arg(info.m_URL); - } break; - case NXMRequestInfo::TYPE_FILEINFO_MD5: { - url = QStringLiteral("%1/games/%2/mods/md5_search/%3") - .arg(info.m_URL) - .arg(info.m_GameName) - .arg(QString(info.m_Hash.toHex())); - } break; - case NXMRequestInfo::TYPE_GAMEINFO: { - url = QStringLiteral("%1/games/%2").arg(info.m_URL).arg(info.m_GameName); - } - } - } else { - url = info.m_URL; - } - QNetworkRequest request(url); - request.setAttribute(QNetworkRequest::CacheSaveControlAttribute, false); - request.setAttribute(QNetworkRequest::CacheLoadControlAttribute, - QNetworkRequest::AlwaysNetwork); - request.setRawHeader("APIKEY", m_User.apiKey().toUtf8()); - request.setHeader(QNetworkRequest::KnownHeaders::UserAgentHeader, - m_AccessManager->userAgent(info.m_SubModule)); - request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, - "application/json"); - request.setRawHeader("Protocol-Version", "1.0.0"); - request.setRawHeader("Application-Name", "MO2"); - request.setRawHeader("Application-Version", - QApplication::applicationVersion().toUtf8()); - - if (postData.object().isEmpty()) { - if (!requestIsDelete) { - info.m_Reply = m_AccessManager->get(request); - } else { - info.m_Reply = m_AccessManager->deleteResource(request); - } - } else if (!requestIsDelete) { - info.m_Reply = m_AccessManager->post(request, postData.toJson()); - } else { - // Qt doesn't support DELETE with a payload as that's technically against the HTTP - // standard... - info.m_Reply = - m_AccessManager->sendCustomRequest(request, "DELETE", postData.toJson()); - } - - connect(info.m_Reply, SIGNAL(finished()), this, SLOT(requestFinished())); - if (!info.m_IgnoreGenericErrorHandler) - connect(info.m_Reply, SIGNAL(errorOccurred(QNetworkReply::NetworkError)), this, - SLOT(requestError(QNetworkReply::NetworkError))); - connect(info.m_Timeout, SIGNAL(timeout()), this, SLOT(requestTimeout())); - info.m_Timeout->start(); - m_ActiveRequest.push_back(info); -} - -void NexusInterface::downloadRequestedNXM(const QString& url) -{ - emit requestNXMDownload(url); -} - -void NexusInterface::requestFinished(std::list::iterator iter) -{ - QNetworkReply* reply = iter->m_Reply; - - auto error = reply->error(); - if (error != QNetworkReply::NoError) { - int const statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - QString errorMsg = reply->errorString(); - - if (iter->m_AllowedErrors.contains(error) && - iter->m_AllowedErrors[error].contains(statusCode)) { - // These errors are allows to silently happen. They should be handled in - // nxmRequestFailed below. - } else if (statusCode == 429) { - m_User.limits(parseLimits(reply)); - - if (!m_User.exhausted()) { - log::warn("You appear to be making requests to the Nexus API too quickly and " - "are being throttled. Please inform the MO2 team."); - } else { - log::warn("All API requests have been consumed and are now being denied."); - } - - emit requestsChanged(getAPIStats(), m_User); - log::warn("Error: {}", errorMsg); - } else { - QByteArray const data = reply->readAll(); - if (!data.isEmpty()) { - QJsonDocument const responseDoc = QJsonDocument::fromJson(data); - if (!responseDoc.isNull()) { - auto result = responseDoc.toVariant().toMap(); - auto error = result.find("error"); - if (error != result.end()) - errorMsg = result.value("error").toString(); - } - } - } - emit nxmRequestFailed(iter->m_GameName, iter->m_ModID, iter->m_FileID, - iter->m_UserData, iter->m_ID, statusCode, errorMsg); - } else { - int const statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - if (statusCode == 301) { - // redirect request, return request to queue - iter->m_URL = - reply->attribute(QNetworkRequest::RedirectionTargetAttribute).toString(); - iter->m_Reroute = true; - m_RequestQueue.enqueue(*iter); - // nextRequest(); - return; - } - QByteArray const data = reply->readAll(); - if (data.isNull() || data.isEmpty() || (strcmp(data.constData(), "null") == 0)) { - QString nexusError(reply->rawHeader("NexusErrorInfo")); - if (nexusError.length() == 0) { - nexusError = tr("empty response"); - } - log::debug("nexus error: {}", nexusError); - emit nxmRequestFailed(iter->m_GameName, iter->m_ModID, iter->m_FileID, - iter->m_UserData, iter->m_ID, reply->error(), nexusError); - } else { - QJsonDocument const responseDoc = QJsonDocument::fromJson(data); - if (!responseDoc.isNull()) { - QVariant const result = responseDoc.toVariant(); - switch (iter->m_Type) { - case NXMRequestInfo::TYPE_DESCRIPTION: { - emit nxmDescriptionAvailable(iter->m_GameName, iter->m_ModID, - iter->m_UserData, result, iter->m_ID); - } break; - case NXMRequestInfo::TYPE_MODINFO: { - emit nxmModInfoAvailable(iter->m_GameName, iter->m_ModID, iter->m_UserData, - result, iter->m_ID); - } break; - case NXMRequestInfo::TYPE_CHECKUPDATES: { - emit nxmUpdateInfoAvailable(iter->m_GameName, iter->m_UserData, result, - iter->m_ID); - } break; - case NXMRequestInfo::TYPE_FILES: { - emit nxmFilesAvailable(iter->m_GameName, iter->m_ModID, iter->m_UserData, - result, iter->m_ID); - } break; - case NXMRequestInfo::TYPE_GETUPDATES: { - emit nxmUpdatesAvailable(iter->m_GameName, iter->m_ModID, iter->m_UserData, - result, iter->m_ID); - } break; - case NXMRequestInfo::TYPE_FILEINFO: { - emit nxmFileInfoAvailable(iter->m_GameName, iter->m_ModID, iter->m_FileID, - iter->m_UserData, result, iter->m_ID); - } break; - case NXMRequestInfo::TYPE_DOWNLOADURL: { - emit nxmDownloadURLsAvailable(iter->m_GameName, iter->m_ModID, iter->m_FileID, - iter->m_UserData, result, iter->m_ID); - } break; - case NXMRequestInfo::TYPE_ENDORSEMENTS: { - emit nxmEndorsementsAvailable(iter->m_UserData, result, iter->m_ID); - } break; - case NXMRequestInfo::TYPE_TOGGLEENDORSEMENT: { - emit nxmEndorsementToggled(iter->m_GameName, iter->m_ModID, iter->m_UserData, - result, iter->m_ID); - } break; - case NXMRequestInfo::TYPE_TOGGLETRACKING: { - auto results = result.toMap(); - auto message = results["message"].toString(); - if (message.contains( - QRegularExpression("User [0-9]+ is already Tracking Mod: [0-9]+")) || - message.contains( - QRegularExpression("User [0-9]+ is now Tracking Mod: [0-9]+"))) { - emit nxmTrackingToggled(iter->m_GameName, iter->m_ModID, iter->m_UserData, - true, iter->m_ID); - } else if (message.contains(QRegularExpression( - "User [0-9]+ is no longer tracking [0-9]+")) || - message.contains(QRegularExpression( - "Users is not tracking mod. Unable to untrack."))) { - emit nxmTrackingToggled(iter->m_GameName, iter->m_ModID, iter->m_UserData, - false, iter->m_ID); - } - } break; - case NXMRequestInfo::TYPE_TRACKEDMODS: { - emit nxmTrackedModsAvailable(iter->m_UserData, result, iter->m_ID); - } break; - case NXMRequestInfo::TYPE_FILEINFO_MD5: { - emit nxmFileInfoFromMd5Available(iter->m_GameName, iter->m_UserData, result, - iter->m_ID); - } break; - case NXMRequestInfo::TYPE_GAMEINFO: { - emit nxmGameInfoAvailable(iter->m_GameName, iter->m_UserData, result, - iter->m_ID); - } break; - } - - m_User.limits(parseLimits(reply)); - emit requestsChanged(getAPIStats(), m_User); - } else { - emit nxmRequestFailed(iter->m_GameName, iter->m_ModID, iter->m_FileID, - iter->m_UserData, iter->m_ID, reply->error(), - tr("invalid response")); - } - } - } -} - -void NexusInterface::requestFinished() -{ - QNetworkReply* reply = static_cast(sender()); - for (std::list::iterator iter = m_ActiveRequest.begin(); - iter != m_ActiveRequest.end(); ++iter) { - if (iter->m_Reply == reply) { - iter->m_Timeout->stop(); - iter->m_Timeout->deleteLater(); - requestFinished(iter); - iter->m_Reply->deleteLater(); - m_ActiveRequest.erase(iter); - nextRequest(); - return; - } - } -} - -void NexusInterface::requestError(QNetworkReply::NetworkError) -{ - QNetworkReply* reply = qobject_cast(sender()); - if (reply == nullptr) { - log::warn("invalid sender type"); - return; - } - - log::error("request ({}) error: {} ({})", reply->url().toString(), - reply->errorString(), reply->error()); -} - -void NexusInterface::requestTimeout() -{ - QTimer* timer = qobject_cast(sender()); - if (timer == nullptr) { - log::warn("invalid sender type"); - return; - } - for (std::list::iterator iter = m_ActiveRequest.begin(); - iter != m_ActiveRequest.end(); ++iter) { - if (iter->m_Timeout == timer) { - // this abort causes a "request failed" which cleans up the rest - iter->m_Reply->abort(); - return; - } - } -} - -APIUserAccount NexusInterface::getAPIUserAccount() const -{ - return m_User; -} - -APIStats NexusInterface::getAPIStats() const -{ - APIStats stats; - stats.requestsQueued = m_RequestQueue.size(); - - return stats; -} - -void NexusInterface::applyGameNameOverride(NXMRequestInfo& info, const QString& gameName, - const MOBase::IPluginGame* game) -{ - // When gameName has no matching plugin (e.g. "site" for Nexus tools), getGame() - // returns the managed game as a fallback, causing NXMRequestInfo to store the wrong - // game name (e.g. "skyrimspecialedition" instead of "site"). Override it here. - if (game && game->gameShortName().compare(gameName, Qt::CaseInsensitive) != 0 && - game->gameNexusName().compare(gameName, Qt::CaseInsensitive) != 0) { - info.m_GameName = gameName.toLower(); - } -} - -namespace -{ -QString get_management_url() -{ - return "https://api.nexusmods.com/v1"; -} -} // namespace - -NexusInterface::NXMRequestInfo::NXMRequestInfo( - int modID, NexusInterface::NXMRequestInfo::Type type, QVariant userData, - const QString& subModule, MOBase::IPluginGame const* game) - : m_ModID(modID), m_ModVersion("0"), m_FileID(0), m_Reply(nullptr), m_Type(type), - m_UpdatePeriod(UpdatePeriod::NONE), m_UserData(userData), m_Timeout(nullptr), - m_Reroute(false), m_ID(s_NextID.fetchAndAddAcquire(1)), - m_URL(get_management_url()), m_SubModule(subModule), - m_NexusGameID(game->nexusGameID()), m_GameName(game->gameNexusName()), - m_Endorse(false), m_Track(false) -{} - -NexusInterface::NXMRequestInfo::NXMRequestInfo( - int modID, QString modVersion, NexusInterface::NXMRequestInfo::Type type, - QVariant userData, const QString& subModule, MOBase::IPluginGame const* game) - : m_ModID(modID), m_ModVersion(modVersion), m_FileID(0), m_Reply(nullptr), - m_Type(type), m_UpdatePeriod(UpdatePeriod::NONE), m_UserData(userData), - m_Timeout(nullptr), m_Reroute(false), m_ID(s_NextID.fetchAndAddAcquire(1)), - m_URL(get_management_url()), m_SubModule(subModule), - m_NexusGameID(game->nexusGameID()), m_GameName(game->gameNexusName()), - m_Endorse(false), m_Track(false) -{} - -NexusInterface::NXMRequestInfo::NXMRequestInfo(Type type, QVariant userData, - const QString& subModule, - MOBase::IPluginGame const* game) - : m_ModID(0), m_ModVersion("0"), m_FileID(0), m_Reply(nullptr), m_Type(type), - m_UpdatePeriod(UpdatePeriod::NONE), m_UserData(userData), m_Timeout(nullptr), - m_Reroute(false), m_ID(s_NextID.fetchAndAddAcquire(1)), - m_URL(get_management_url()), m_SubModule(subModule), - m_NexusGameID(game->nexusGameID()), m_GameName(game->gameNexusName()), - m_Endorse(false), m_Track(false) -{} - -NexusInterface::NXMRequestInfo::NXMRequestInfo( - int modID, int fileID, NexusInterface::NXMRequestInfo::Type type, QVariant userData, - const QString& subModule, MOBase::IPluginGame const* game) - : m_ModID(modID), m_ModVersion("0"), m_FileID(fileID), m_Reply(nullptr), - m_Type(type), m_UpdatePeriod(UpdatePeriod::NONE), m_UserData(userData), - m_Timeout(nullptr), m_Reroute(false), m_ID(s_NextID.fetchAndAddAcquire(1)), - m_URL(get_management_url()), m_SubModule(subModule), - m_NexusGameID(game->nexusGameID()), m_GameName(game->gameNexusName()), - m_Endorse(false), m_Track(false) -{} - -NexusInterface::NXMRequestInfo::NXMRequestInfo(Type type, QVariant userData, - const QString& subModule) - : m_ModID(0), m_ModVersion("0"), m_FileID(0), m_Reply(nullptr), m_Type(type), - m_UpdatePeriod(UpdatePeriod::NONE), m_UserData(userData), m_Timeout(nullptr), - m_Reroute(false), m_ID(s_NextID.fetchAndAddAcquire(1)), - m_URL(get_management_url()), m_SubModule(subModule), m_NexusGameID(0), - m_GameName(""), m_Endorse(false), m_Track(false) -{} - -NexusInterface::NXMRequestInfo::NXMRequestInfo( - UpdatePeriod period, NexusInterface::NXMRequestInfo::Type type, QVariant userData, - const QString& subModule, MOBase::IPluginGame const* game) - : m_ModID(0), m_ModVersion("0"), m_FileID(0), m_Reply(nullptr), m_Type(type), - m_UpdatePeriod(period), m_UserData(userData), m_Timeout(nullptr), - m_Reroute(false), m_ID(s_NextID.fetchAndAddAcquire(1)), - m_URL(get_management_url()), m_SubModule(subModule), - m_NexusGameID(game->nexusGameID()), m_GameName(game->gameNexusName()), - m_Endorse(false), m_Track(false) -{} - -NexusInterface::NXMRequestInfo::NXMRequestInfo( - QByteArray& hash, NexusInterface::NXMRequestInfo::Type type, QVariant userData, - const QString& subModule, MOBase::IPluginGame const* game) - : m_ModID(0), m_ModVersion("0"), m_FileID(0), m_Reply(nullptr), m_Type(type), - m_UpdatePeriod(UpdatePeriod::NONE), m_UserData(userData), m_Timeout(nullptr), - m_Reroute(false), m_ID(s_NextID.fetchAndAddAcquire(1)), - m_URL(get_management_url()), m_SubModule(subModule), - m_NexusGameID(game->nexusGameID()), m_GameName(game->gameNexusName()), - m_Endorse(false), m_Track(false), m_Hash(hash) -{} +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "nexusinterface.h" + +#include "bbcode.h" +#include "iplugingame.h" +#include "nxmaccessmanager.h" +#include "selectiondialog.h" +#include "settings.h" +#include "shared/util.h" +#include +#include +#include + +#include +#include +#include +#include + +#include + +using namespace MOBase; +using namespace MOShared; + +void throttledWarning(const APIUserAccount& user) +{ + log::error("You have fewer than {} requests remaining ({}). Only downloads and " + "login validation are being allowed.", + APIUserAccount::ThrottleThreshold, user.remainingRequests()); +} + +NexusBridge::NexusBridge(PluginContainer* pluginContainer, const QString& subModule) + : m_Interface(&NexusInterface::instance()), m_SubModule(subModule) +{} + +void NexusBridge::requestDescription(QString gameName, int modID, QVariant userData) +{ + m_RequestIDs.insert( + m_Interface->requestDescription(gameName, modID, this, userData, m_SubModule)); +} + +void NexusBridge::requestFiles(QString gameName, int modID, QVariant userData) +{ + m_RequestIDs.insert( + m_Interface->requestFiles(gameName, modID, this, userData, m_SubModule)); +} + +void NexusBridge::requestFileInfo(QString gameName, int modID, int fileID, + QVariant userData) +{ + m_RequestIDs.insert(m_Interface->requestFileInfo(gameName, modID, fileID, this, + userData, m_SubModule)); +} + +void NexusBridge::requestDownloadURL(QString gameName, int modID, int fileID, + QVariant userData) +{ + m_RequestIDs.insert(m_Interface->requestDownloadURL(gameName, modID, fileID, this, + userData, m_SubModule)); +} + +void NexusBridge::requestToggleEndorsement(QString gameName, int modID, + QString modVersion, bool endorse, + QVariant userData) +{ + m_RequestIDs.insert(m_Interface->requestToggleEndorsement( + gameName, modID, modVersion, endorse, this, userData, m_SubModule)); +} + +void NexusBridge::requestToggleTracking(QString gameName, int modID, bool track, + QVariant userData) +{ + m_RequestIDs.insert(m_Interface->requestToggleTracking(gameName, modID, track, this, + userData, m_SubModule)); +} + +void NexusBridge::requestGameInfo(QString gameName, QVariant userData) +{ + m_RequestIDs.insert( + m_Interface->requestGameInfo(gameName, this, userData, m_SubModule)); +} + +void NexusBridge::nxmDescriptionAvailable(QString gameName, int modID, + QVariant userData, QVariant resultData, + int requestID) +{ + std::set::iterator const iter = m_RequestIDs.find(requestID); + if (iter != m_RequestIDs.end()) { + m_RequestIDs.erase(iter); + + emit descriptionAvailable(gameName, modID, userData, resultData); + } +} + +void NexusBridge::nxmFilesAvailable(QString gameName, int modID, QVariant userData, + QVariant resultData, int requestID) +{ + std::set::iterator const iter = m_RequestIDs.find(requestID); + if (iter != m_RequestIDs.end()) { + m_RequestIDs.erase(iter); + + QList fileInfoList; + + QVariantMap resultInfo = resultData.toMap(); + QList const resultList = resultInfo["files"].toList(); + + for (const QVariant& file : resultList) { + ModRepositoryFileInfo temp; + QVariantMap fileInfo = file.toMap(); + temp.uri = fileInfo["file_name"].toString(); + temp.name = fileInfo["name"].toString(); + temp.description = BBCode::convertToHTML(fileInfo["description"].toString()); + temp.version = VersionInfo(fileInfo["version"].toString()); + temp.categoryID = fileInfo["category_id"].toInt(); + temp.fileID = fileInfo["file_id"].toInt(); + temp.fileSize = fileInfo["size"].toInt(); + temp.author = fileInfo["author"].toString(); + temp.uploader = fileInfo["uploaded_by"].toString(); + temp.uploaderUrl = fileInfo["uploaded_users_profile_url"].toString(); + fileInfoList.append(&temp); + } + + emit filesAvailable(gameName, modID, userData, fileInfoList); + } +} + +void NexusBridge::nxmFileInfoAvailable(QString gameName, int modID, int fileID, + QVariant userData, QVariant resultData, + int requestID) +{ + std::set::iterator const iter = m_RequestIDs.find(requestID); + if (iter != m_RequestIDs.end()) { + m_RequestIDs.erase(iter); + emit fileInfoAvailable(gameName, modID, fileID, userData, resultData); + } +} + +void NexusBridge::nxmDownloadURLsAvailable(QString gameName, int modID, int fileID, + QVariant userData, QVariant resultData, + int requestID) +{ + std::set::iterator const iter = m_RequestIDs.find(requestID); + if (iter != m_RequestIDs.end()) { + m_RequestIDs.erase(iter); + emit downloadURLsAvailable(gameName, modID, fileID, userData, resultData); + } +} + +void NexusBridge::nxmEndorsementsAvailable(QVariant userData, QVariant resultData, + int requestID) +{ + std::set::iterator const iter = m_RequestIDs.find(requestID); + if (iter != m_RequestIDs.end()) { + m_RequestIDs.erase(iter); + emit endorsementsAvailable(userData, resultData); + } +} + +void NexusBridge::nxmEndorsementToggled(QString gameName, int modID, QVariant userData, + QVariant resultData, int requestID) +{ + std::set::iterator const iter = m_RequestIDs.find(requestID); + if (iter != m_RequestIDs.end()) { + m_RequestIDs.erase(iter); + emit endorsementToggled(gameName, modID, userData, resultData); + } +} + +void NexusBridge::nxmTrackedModsAvailable(QVariant userData, QVariant resultData, + int requestID) +{ + std::set::iterator const iter = m_RequestIDs.find(requestID); + if (iter != m_RequestIDs.end()) { + m_RequestIDs.erase(iter); + emit trackedModsAvailable(userData, resultData); + } +} + +void NexusBridge::nxmTrackingToggled(QString gameName, int modID, QVariant userData, + bool tracked, int requestID) +{ + std::set::iterator const iter = m_RequestIDs.find(requestID); + if (iter != m_RequestIDs.end()) { + m_RequestIDs.erase(iter); + emit trackingToggled(gameName, modID, userData, tracked); + } +} + +void NexusBridge::nxmGameInfoAvailable(QString gameName, QVariant userData, + QVariant resultData, int requestID) +{ + std::set::iterator const iter = m_RequestIDs.find(requestID); + if (iter != m_RequestIDs.end()) { + m_RequestIDs.erase(iter); + emit gameInfoAvailable(gameName, userData, resultData); + } +} + +void NexusBridge::nxmRequestFailed(QString gameName, int modID, int fileID, + QVariant userData, int requestID, int errorCode, + const QString& errorMessage) +{ + std::set::iterator const iter = m_RequestIDs.find(requestID); + if (iter != m_RequestIDs.end()) { + m_RequestIDs.erase(iter); + emit requestFailed(gameName, modID, fileID, userData, errorCode, errorMessage); + } +} + +QAtomicInt NexusInterface::NXMRequestInfo::s_NextID(0); + +APILimits NexusInterface::defaultAPILimits() +{ + // https://app.swaggerhub.com/apis-docs/NexusMods/nexus-mods_public_api_params_in_form_data/1.0#/ + const int MaxDaily = 2500; + const int MaxHourly = 100; + + APILimits limits; + + limits.maxDailyRequests = MaxDaily; + limits.remainingDailyRequests = MaxDaily; + limits.maxHourlyRequests = MaxHourly; + limits.remainingHourlyRequests = MaxHourly; + + return limits; +} + +APILimits NexusInterface::parseLimits(const QNetworkReply* reply) +{ + return parseLimits(reply->rawHeaderPairs()); +} + +APILimits +NexusInterface::parseLimits(const QList& headers) +{ + APILimits limits; + + for (const auto& pair : headers) { + const auto name = QString(pair.first).toLower(); + + if (name == "x-rl-daily-limit") { + limits.maxDailyRequests = pair.second.toInt(); + } else if (name == "x-rl-daily-remaining") { + limits.remainingDailyRequests = pair.second.toInt(); + } else if (name == "x-rl-hourly-limit") { + limits.maxHourlyRequests = pair.second.toInt(); + } else if (name == "x-rl-hourly-remaining") { + limits.remainingHourlyRequests = pair.second.toInt(); + } + } + + return limits; +} + +static NexusInterface* g_instance = nullptr; + +NexusInterface::NexusInterface(Settings* s) +{ + MO_ASSERT(!g_instance); + g_instance = this; + + m_User.limits(defaultAPILimits()); + + m_AccessManager = new NXMAccessManager(this, s, createVersionInfo().string()); + + m_DiskCache = new QNetworkDiskCache(this); + + connect(m_AccessManager, &NXMAccessManager::requestNXMDownload, this, + &NexusInterface::downloadRequestedNXM); + + connect(m_AccessManager, &NXMAccessManager::credentialsReceived, this, + &NexusInterface::setUserAccount); +} + +NexusInterface::~NexusInterface() +{ + cleanup(); + + MO_ASSERT(g_instance == this); + g_instance = nullptr; +} + +NXMAccessManager* NexusInterface::getAccessManager() +{ + return m_AccessManager; +} + +NexusInterface& NexusInterface::instance() +{ + MO_ASSERT(g_instance); + return *g_instance; +} + +void NexusInterface::setCacheDirectory(const QString& directory) +{ + m_DiskCache->setCacheDirectory(directory); + m_AccessManager->setCache(m_DiskCache); +} + +void NexusInterface::loginCompleted() +{ + nextRequest(); +} + +void NexusInterface::setUserAccount(const APIUserAccount& user) +{ + m_User = user; + emit requestsChanged(getAPIStats(), m_User); +} + +void NexusInterface::interpretNexusFileName(const QString& fileName, QString& modName, + int& modID, bool query) +{ + // guess the mod name from the file name + static const QRegularExpression complex( + R"(^([a-zA-Z0-9_'"\-.() ]*?)([-_ ][VvRr]+[0-9]+(?:(?:[\.][0-9]+){0,2}|(?:[_][0-9]+){0,2}|(?:[-.][0-9]+){0,2})?[ab]?)??-([1-9][0-9]+)?-.*?\.(zip|rar|7z))"); + // complex regex explanation: + // group 1: modname. + // group 2: optional version, + // assumed to start with v (empty most of the time). + // group 3: NexusId, + // assumed wrapped in "-", will miss single digit IDs for better accuracy. + // If no id is present the whole regex does not match. + static const QRegularExpression simple(R"(^[^a-zA-Z]*([a-zA-Z_ ]+))"); + auto complexMatch = complex.match(fileName); + auto simpleMatch = simple.match(fileName); + + // assume not found + modID = -1; + + if (complexMatch.hasMatch()) { + modName = complexMatch.captured(1); + if (!query && !complexMatch.captured(3).isNull()) { + modID = complexMatch.captured(3).toInt(); + } + } else if (simpleMatch.hasMatch()) { + modName = simpleMatch.captured(0); + } else { + modName.clear(); + } + + if (query) { + SelectionDialog selection(tr("Please pick the mod ID for \"%1\"").arg(fileName)); + int index = 0; + auto splits = fileName.split(QRegularExpression("[^0-9]"), Qt::KeepEmptyParts); + for (const auto& substr : splits) { + bool ok = false; + int const value = substr.toInt(&ok); + if (ok) { + QString highlight(fileName); + highlight.insert(index, " *"); + highlight.insert(index + substr.length() + 2, "* "); + + QStringList choice; + choice << substr; + choice << (index > 0 ? fileName.left(index - 1) : substr); + selection.addChoice(substr, highlight, choice); + } + index += substr.length() + 1; + } + + if (selection.numChoices() > 0) { + if (selection.exec() == QDialog::Accepted) { + auto choice = selection.getChoiceData().toStringList(); + modID = choice.at(0).toInt(); + modName = choice.at(1); + modName = modName.replace('_', ' ').trimmed(); + log::debug("user selected mod ID {} and mod name \"{}\"", modID, modName); + } else { + log::debug("user canceled mod ID selection"); + } + } else { + log::debug("no possible mod IDs found in file name"); + } + } +} + +bool NexusInterface::isURLGameRelated(const QUrl& url) const +{ + QString const name(url.toString()); + return name.startsWith(getGameURL("") + "/") || + name.startsWith(getOldModsURL("") + "/"); +} + +QString NexusInterface::getGameURL(QString gameName) const +{ + IPluginGame* game = getGame(gameName); + if (game != nullptr) { + QString const gameNexusName = game->gameNexusName().toLower(); + if (gameNexusName.isEmpty()) { + return ""; + } else { + return "https://www.nexusmods.com/" + gameNexusName; + } + } else { + log::error("getGameURL can't find plugin for {}", gameName); + return ""; + } +} + +QString NexusInterface::getOldModsURL(QString gameName) const +{ + IPluginGame* game = getGame(gameName); + if (game != nullptr) { + return "https://" + game->gameNexusName().toLower() + ".nexusmods.com/mods"; + } else { + log::error("getOldModsURL can't find plugin for {}", gameName); + return ""; + } +} + +QString NexusInterface::getModURL(int modID, QString gameName = "") const +{ + return QString("%1/mods/%2").arg(getGameURL(gameName)).arg(modID); +} + +std::vector> +NexusInterface::getGameChoices(const MOBase::IPluginGame* game) +{ + std::vector> choices; + choices.emplace_back(game->gameShortName(), game->gameName()); + for (const QString& gameName : game->validShortNames()) { + for (auto *gamePlugin : m_PluginContainer->plugins()) { + if (gamePlugin->gameShortName().compare(gameName, Qt::CaseInsensitive) == 0) { + choices.emplace_back(gamePlugin->gameShortName(), + gamePlugin->gameName()); + break; + } + } + } + return choices; +} + +bool NexusInterface::isModURL(int modID, const QString& url) const +{ + if (QUrl(url) == QUrl(getModURL(modID))) { + return true; + } + // Try the alternate (old style) mod name + QString const alt = QString("%1/%2").arg(getOldModsURL("")).arg(modID); + return QUrl(alt) == QUrl(url); +} + +void NexusInterface::setPluginContainer(PluginContainer* pluginContainer) +{ + m_PluginContainer = pluginContainer; +} + +int NexusInterface::requestDescription(QString gameName, int modID, QObject* receiver, + QVariant userData, const QString& subModule, + MOBase::IPluginGame const* game) +{ + NXMRequestInfo requestInfo(modID, NXMRequestInfo::TYPE_DESCRIPTION, userData, + subModule, game); + applyGameNameOverride(requestInfo, gameName, game); + m_RequestQueue.enqueue(requestInfo); + + connect(this, SIGNAL(nxmDescriptionAvailable(QString, int, QVariant, QVariant, int)), + receiver, + SLOT(nxmDescriptionAvailable(QString, int, QVariant, QVariant, int)), + Qt::UniqueConnection); + + connect( + this, SIGNAL(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + receiver, SLOT(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + Qt::UniqueConnection); + + nextRequest(); + return requestInfo.m_ID; +} + +int NexusInterface::requestModInfo(QString gameName, int modID, QObject* receiver, + QVariant userData, const QString& subModule, + MOBase::IPluginGame const* game) +{ + if (m_User.shouldThrottle()) { + throttledWarning(m_User); + return -1; + } + + NXMRequestInfo requestInfo(modID, NXMRequestInfo::TYPE_MODINFO, userData, subModule, + game); + applyGameNameOverride(requestInfo, gameName, game); + m_RequestQueue.enqueue(requestInfo); + + connect(this, SIGNAL(nxmModInfoAvailable(QString, int, QVariant, QVariant, int)), + receiver, SLOT(nxmModInfoAvailable(QString, int, QVariant, QVariant, int)), + Qt::UniqueConnection); + + connect( + this, SIGNAL(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + receiver, SLOT(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + Qt::UniqueConnection); + + nextRequest(); + return requestInfo.m_ID; +} + +int NexusInterface::requestUpdateInfo(QString gameName, + NexusInterface::UpdatePeriod period, + QObject* receiver, QVariant userData, + const QString& subModule, + const MOBase::IPluginGame* game) +{ + if (m_User.shouldThrottle()) { + throttledWarning(m_User); + return -1; + } + + NXMRequestInfo const requestInfo(period, NXMRequestInfo::TYPE_CHECKUPDATES, userData, + subModule, game); + m_RequestQueue.enqueue(requestInfo); + + connect(this, SIGNAL(nxmUpdateInfoAvailable(QString, QVariant, QVariant, int)), + receiver, SLOT(nxmUpdateInfoAvailable(QString, QVariant, QVariant, int)), + Qt::UniqueConnection); + + connect( + this, SIGNAL(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + receiver, SLOT(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + Qt::UniqueConnection); + + nextRequest(); + return requestInfo.m_ID; +} + +int NexusInterface::requestUpdates(const int& modID, QObject* receiver, + QVariant userData, QString gameName, + const QString& subModule) +{ + if (m_User.shouldThrottle()) { + throttledWarning(m_User); + return -1; + } + + IPluginGame* game = getGame(gameName); + if (game == nullptr) { + log::error("requestUpdates can't find plugin for {}", gameName); + return -1; + } + + NXMRequestInfo const requestInfo(modID, NXMRequestInfo::TYPE_GETUPDATES, userData, + subModule, game); + m_RequestQueue.enqueue(requestInfo); + + connect(this, SIGNAL(nxmUpdatesAvailable(QString, int, QVariant, QVariant, int)), + receiver, SLOT(nxmUpdatesAvailable(QString, int, QVariant, QVariant, int)), + Qt::UniqueConnection); + + connect( + this, SIGNAL(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + receiver, SLOT(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + Qt::UniqueConnection); + + nextRequest(); + return requestInfo.m_ID; +} + +void NexusInterface::fakeFiles() +{ + static int id = 42; + + QVariantList result; + QVariantMap fileMap; + fileMap["uri"] = "fakeURI"; + fileMap["name"] = "fakeName"; + fileMap["description"] = "fakeDescription"; + fileMap["version"] = "1.0.0"; + fileMap["category_id"] = "1"; + fileMap["id"] = "1"; + fileMap["size"] = "512"; + fileMap["author"] = "fakeAuthor"; + fileMap["uploaded_by"] = "fakeUploader"; + fileMap["uploaded_users_profile_url"] = "https://fakeuploader.com"; + result.append(fileMap); + + emit nxmFilesAvailable("fakeGame", 1234, "fake", result, id++); +} + +int NexusInterface::requestFiles(QString gameName, int modID, QObject* receiver, + QVariant userData, const QString& subModule, + MOBase::IPluginGame const* game) +{ + NXMRequestInfo const requestInfo(modID, NXMRequestInfo::TYPE_FILES, userData, subModule, + game); + m_RequestQueue.enqueue(requestInfo); + connect(this, SIGNAL(nxmFilesAvailable(QString, int, QVariant, QVariant, int)), + receiver, SLOT(nxmFilesAvailable(QString, int, QVariant, QVariant, int)), + Qt::UniqueConnection); + + connect( + this, SIGNAL(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + receiver, SLOT(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + Qt::UniqueConnection); + + nextRequest(); + return requestInfo.m_ID; +} + +int NexusInterface::requestFileInfo(QString gameName, int modID, int fileID, + QObject* receiver, QVariant userData, + const QString& subModule) +{ + IPluginGame* gamePlugin = getGame(gameName); + if (gamePlugin == nullptr) { + log::error("requestFileInfo can't find plugin for {}", gameName); + return -1; + } + + NXMRequestInfo requestInfo(modID, fileID, NXMRequestInfo::TYPE_FILEINFO, userData, + subModule, gamePlugin); + applyGameNameOverride(requestInfo, gameName, gamePlugin); + m_RequestQueue.enqueue(requestInfo); + + connect( + this, SIGNAL(nxmFileInfoAvailable(QString, int, int, QVariant, QVariant, int)), + receiver, SLOT(nxmFileInfoAvailable(QString, int, int, QVariant, QVariant, int)), + Qt::UniqueConnection); + + connect( + this, SIGNAL(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + receiver, SLOT(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + Qt::UniqueConnection); + + nextRequest(); + return requestInfo.m_ID; +} + +int NexusInterface::requestDownloadURL(QString gameName, int modID, int fileID, + QObject* receiver, QVariant userData, + const QString& subModule, + MOBase::IPluginGame const* game) +{ + NXMRequestInfo requestInfo(modID, fileID, NXMRequestInfo::TYPE_DOWNLOADURL, userData, + subModule, game); + applyGameNameOverride(requestInfo, gameName, game); + m_RequestQueue.enqueue(requestInfo); + + connect(this, + SIGNAL(nxmDownloadURLsAvailable(QString, int, int, QVariant, QVariant, int)), + receiver, + SLOT(nxmDownloadURLsAvailable(QString, int, int, QVariant, QVariant, int)), + Qt::UniqueConnection); + + connect( + this, SIGNAL(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + receiver, SLOT(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + Qt::UniqueConnection); + + nextRequest(); + return requestInfo.m_ID; +} + +int NexusInterface::requestEndorsementInfo(QObject* receiver, QVariant userData, + const QString& subModule) +{ + NXMRequestInfo const requestInfo(NXMRequestInfo::TYPE_ENDORSEMENTS, userData, subModule); + m_RequestQueue.enqueue(requestInfo); + + connect(this, SIGNAL(nxmEndorsementsAvailable(QVariant, QVariant, int)), receiver, + SLOT(nxmEndorsementsAvailable(QVariant, QVariant, int)), + Qt::UniqueConnection); + + connect( + this, SIGNAL(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + receiver, SLOT(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + Qt::UniqueConnection); + + nextRequest(); + return requestInfo.m_ID; +} + +int NexusInterface::requestToggleEndorsement(QString gameName, int modID, + QString modVersion, bool endorse, + QObject* receiver, QVariant userData, + const QString& subModule, + MOBase::IPluginGame const* game) +{ + if (m_User.shouldThrottle()) { + throttledWarning(m_User); + return -1; + } + + NXMRequestInfo requestInfo(modID, modVersion, NXMRequestInfo::TYPE_TOGGLEENDORSEMENT, + userData, subModule, game); + requestInfo.m_Endorse = endorse; + m_RequestQueue.enqueue(requestInfo); + + connect(this, SIGNAL(nxmEndorsementToggled(QString, int, QVariant, QVariant, int)), + receiver, SLOT(nxmEndorsementToggled(QString, int, QVariant, QVariant, int)), + Qt::UniqueConnection); + + connect( + this, SIGNAL(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + receiver, SLOT(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + Qt::UniqueConnection); + + nextRequest(); + return requestInfo.m_ID; +} + +int NexusInterface::requestTrackingInfo(QObject* receiver, QVariant userData, + const QString& subModule) +{ + NXMRequestInfo const requestInfo(NXMRequestInfo::TYPE_TRACKEDMODS, userData, subModule); + m_RequestQueue.enqueue(requestInfo); + + connect(this, SIGNAL(nxmTrackedModsAvailable(QVariant, QVariant, int)), receiver, + SLOT(nxmTrackedModsAvailable(QVariant, QVariant, int)), Qt::UniqueConnection); + + connect( + this, SIGNAL(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + receiver, SLOT(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + Qt::UniqueConnection); + + nextRequest(); + return requestInfo.m_ID; +} + +int NexusInterface::requestToggleTracking(QString gameName, int modID, bool track, + QObject* receiver, QVariant userData, + const QString& subModule, + MOBase::IPluginGame const* game) +{ + if (m_User.shouldThrottle()) { + throttledWarning(m_User); + return -1; + } + + NXMRequestInfo requestInfo(modID, NXMRequestInfo::TYPE_TOGGLETRACKING, userData, + subModule, game); + requestInfo.m_Track = track; + m_RequestQueue.enqueue(requestInfo); + + connect(this, SIGNAL(nxmTrackingToggled(QString, int, QVariant, bool, int)), receiver, + SLOT(nxmTrackingToggled(QString, int, QVariant, bool, int)), + Qt::UniqueConnection); + + connect( + this, SIGNAL(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + receiver, SLOT(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + Qt::UniqueConnection); + + nextRequest(); + return requestInfo.m_ID; +} + +int NexusInterface::requestGameInfo(QString gameName, QObject* receiver, + QVariant userData, const QString& subModule, + MOBase::IPluginGame const* game) +{ + if (m_User.shouldThrottle()) { + throttledWarning(m_User); + return -1; + } + + NXMRequestInfo const requestInfo(NXMRequestInfo::TYPE_GAMEINFO, userData, subModule, game); + m_RequestQueue.enqueue(requestInfo); + + connect(this, SIGNAL(nxmGameInfoAvailable(QString, QVariant, QVariant, int)), + receiver, SLOT(nxmGameInfoAvailable(QString, QVariant, QVariant, int)), + Qt::UniqueConnection); + + connect( + this, SIGNAL(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + receiver, SLOT(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + Qt::UniqueConnection); + + nextRequest(); + return requestInfo.m_ID; +} + +int NexusInterface::requestInfoFromMd5(QString gameName, QByteArray& hash, + QObject* receiver, QVariant userData, + const QString& subModule, + MOBase::IPluginGame const* game) +{ + NXMRequestInfo requestInfo(hash, NXMRequestInfo::TYPE_FILEINFO_MD5, userData, + subModule, game); + requestInfo.m_Hash = hash; + requestInfo.m_AllowedErrors[QNetworkReply::NetworkError::ContentNotFoundError].append( + 404); + requestInfo.m_IgnoreGenericErrorHandler = true; + m_RequestQueue.enqueue(requestInfo); + + connect(this, SIGNAL(nxmFileInfoFromMd5Available(QString, QVariant, QVariant, int)), + receiver, SLOT(nxmFileInfoFromMd5Available(QString, QVariant, QVariant, int)), + Qt::UniqueConnection); + + connect( + this, SIGNAL(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + receiver, SLOT(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + Qt::UniqueConnection); + + nextRequest(); + return requestInfo.m_ID; +} + +IPluginGame* NexusInterface::getGame(QString gameName) const +{ + auto gamePlugins = m_PluginContainer->plugins(); + IPluginGame* gamePlugin = qApp->property("managed_game").value(); + for (auto *plugin : gamePlugins) { + if (plugin != nullptr && + plugin->gameShortName().compare(gameName, Qt::CaseInsensitive) == 0) { + gamePlugin = plugin; + break; + } + } + return gamePlugin; +} + +void NexusInterface::cleanup() +{ + m_AccessManager = nullptr; + m_DiskCache = nullptr; +} + +void NexusInterface::clearCache() +{ + m_DiskCache->clear(); + m_AccessManager->clearCookies(); +} + +void NexusInterface::nextRequest() +{ + if ((m_ActiveRequest.size() >= MAX_ACTIVE_DOWNLOADS) || m_RequestQueue.isEmpty()) { + return; + } + + if (!getAccessManager()->validated()) { + if (!getAccessManager()->validateAttempted()) { + emit needLogin(); + return; + } else if (getAccessManager()->validateWaiting()) { + return; + } else { + log::error( + "{}", + tr("You must authorize MO2 in Settings -> Nexus to use the Nexus API.")); + } + } + + if (m_User.exhausted()) { + m_RequestQueue.clear(); + QTime const time = QTime::currentTime(); + QTime targetTime; + targetTime.setHMS((time.hour() + 1) % 23, 5, 0); + QString warning = tr("You've exceeded the Nexus API rate limit and requests are " + "now being throttled. " + "Your next batch of requests will be available in " + "approximately %1 minutes and %2 seconds.") + .arg(time.secsTo(targetTime) / 60) + .arg(time.secsTo(targetTime) % 60); + + log::warn("{}", warning); + return; + } + + NXMRequestInfo info = m_RequestQueue.dequeue(); + info.m_Timeout = new QTimer(this); + info.m_Timeout->setInterval(60000); + + QJsonObject postObject; + QJsonDocument postData(postObject); + bool requestIsDelete = false; + + QString url; + if (!info.m_Reroute) { + bool const hasParams = false; + switch (info.m_Type) { + case NXMRequestInfo::TYPE_DESCRIPTION: + case NXMRequestInfo::TYPE_MODINFO: { + url = QString("%1/games/%2/mods/%3") + .arg(info.m_URL) + .arg(info.m_GameName) + .arg(info.m_ModID); + } break; + case NXMRequestInfo::TYPE_CHECKUPDATES: { + QString period; + switch (info.m_UpdatePeriod) { + case UpdatePeriod::DAY: + period = "1d"; + break; + case UpdatePeriod::WEEK: + period = "1w"; + break; + case UpdatePeriod::MONTH: + period = "1m"; + break; + } + url = QString("%1/games/%2/mods/updated?period=%3") + .arg(info.m_URL) + .arg(info.m_GameName) + .arg(period); + } break; + case NXMRequestInfo::TYPE_FILES: + case NXMRequestInfo::TYPE_GETUPDATES: { + url = QString("%1/games/%2/mods/%3/files") + .arg(info.m_URL) + .arg(info.m_GameName) + .arg(info.m_ModID); + } break; + case NXMRequestInfo::TYPE_FILEINFO: { + url = QString("%1/games/%2/mods/%3/files/%4") + .arg(info.m_URL) + .arg(info.m_GameName) + .arg(info.m_ModID) + .arg(info.m_FileID); + } break; + case NXMRequestInfo::TYPE_DOWNLOADURL: { + ModRepositoryFileInfo* fileInfo = qobject_cast( + qvariant_cast(info.m_UserData)); + if (m_User.type() == APIUserAccountTypes::Premium) { + url = QString("%1/games/%2/mods/%3/files/%4/download_link") + .arg(info.m_URL) + .arg(info.m_GameName) + .arg(info.m_ModID) + .arg(info.m_FileID); + } else if (!fileInfo->nexusKey.isEmpty() && fileInfo->nexusExpires && + fileInfo->nexusDownloadUser == m_User.id().toInt()) { + url = QString("%1/games/%2/mods/%3/files/%4/download_link?key=%5&expires=%6") + .arg(info.m_URL) + .arg(info.m_GameName) + .arg(info.m_ModID) + .arg(info.m_FileID) + .arg(fileInfo->nexusKey) + .arg(fileInfo->nexusExpires); + } else { + log::warn("{}", tr("Aborting download: Either you clicked on a premium-only " + "link and your account is not premium, " + "or the download link was generated by a different account " + "than the one stored in Mod Organizer.")); + return; + } + } break; + case NXMRequestInfo::TYPE_ENDORSEMENTS: { + url = QString("%1/user/endorsements").arg(info.m_URL); + } break; + case NXMRequestInfo::TYPE_TOGGLEENDORSEMENT: { + QString const endorse = info.m_Endorse ? "endorse" : "abstain"; + url = QString("%1/games/%2/mods/%3/%4") + .arg(info.m_URL) + .arg(info.m_GameName) + .arg(info.m_ModID) + .arg(endorse); + postObject.insert("Version", info.m_ModVersion); + postData.setObject(postObject); + } break; + case NXMRequestInfo::TYPE_TOGGLETRACKING: { + url = QStringLiteral("%1/user/tracked_mods?domain_name=%2") + .arg(info.m_URL) + .arg(info.m_GameName); + postObject.insert("mod_id", info.m_ModID); + postData.setObject(postObject); + requestIsDelete = !info.m_Track; + } break; + case NXMRequestInfo::TYPE_TRACKEDMODS: { + url = QStringLiteral("%1/user/tracked_mods").arg(info.m_URL); + } break; + case NXMRequestInfo::TYPE_FILEINFO_MD5: { + url = QStringLiteral("%1/games/%2/mods/md5_search/%3") + .arg(info.m_URL) + .arg(info.m_GameName) + .arg(QString(info.m_Hash.toHex())); + } break; + case NXMRequestInfo::TYPE_GAMEINFO: { + url = QStringLiteral("%1/games/%2").arg(info.m_URL).arg(info.m_GameName); + } + } + } else { + url = info.m_URL; + } + QNetworkRequest request(url); + request.setAttribute(QNetworkRequest::CacheSaveControlAttribute, false); + request.setAttribute(QNetworkRequest::CacheLoadControlAttribute, + QNetworkRequest::AlwaysNetwork); + request.setRawHeader("APIKEY", m_User.apiKey().toUtf8()); + request.setHeader(QNetworkRequest::KnownHeaders::UserAgentHeader, + m_AccessManager->userAgent(info.m_SubModule)); + request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, + "application/json"); + request.setRawHeader("Protocol-Version", "1.0.0"); + request.setRawHeader("Application-Name", "MO2"); + request.setRawHeader("Application-Version", + QApplication::applicationVersion().toUtf8()); + + if (postData.object().isEmpty()) { + if (!requestIsDelete) { + info.m_Reply = m_AccessManager->get(request); + } else { + info.m_Reply = m_AccessManager->deleteResource(request); + } + } else if (!requestIsDelete) { + info.m_Reply = m_AccessManager->post(request, postData.toJson()); + } else { + // Qt doesn't support DELETE with a payload as that's technically against the HTTP + // standard... + info.m_Reply = + m_AccessManager->sendCustomRequest(request, "DELETE", postData.toJson()); + } + + connect(info.m_Reply, SIGNAL(finished()), this, SLOT(requestFinished())); + if (!info.m_IgnoreGenericErrorHandler) + connect(info.m_Reply, SIGNAL(errorOccurred(QNetworkReply::NetworkError)), this, + SLOT(requestError(QNetworkReply::NetworkError))); + connect(info.m_Timeout, SIGNAL(timeout()), this, SLOT(requestTimeout())); + info.m_Timeout->start(); + m_ActiveRequest.push_back(info); +} + +void NexusInterface::downloadRequestedNXM(const QString& url) +{ + emit requestNXMDownload(url); +} + +void NexusInterface::requestFinished(std::list::iterator iter) +{ + QNetworkReply* reply = iter->m_Reply; + + auto error = reply->error(); + if (error != QNetworkReply::NoError) { + int const statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + QString errorMsg = reply->errorString(); + + if (iter->m_AllowedErrors.contains(error) && + iter->m_AllowedErrors[error].contains(statusCode)) { + // These errors are allows to silently happen. They should be handled in + // nxmRequestFailed below. + } else if (statusCode == 429) { + m_User.limits(parseLimits(reply)); + + if (!m_User.exhausted()) { + log::warn("You appear to be making requests to the Nexus API too quickly and " + "are being throttled. Please inform the MO2 team."); + } else { + log::warn("All API requests have been consumed and are now being denied."); + } + + emit requestsChanged(getAPIStats(), m_User); + log::warn("Error: {}", errorMsg); + } else { + QByteArray const data = reply->readAll(); + if (!data.isEmpty()) { + QJsonDocument const responseDoc = QJsonDocument::fromJson(data); + if (!responseDoc.isNull()) { + auto result = responseDoc.toVariant().toMap(); + auto error = result.find("error"); + if (error != result.end()) + errorMsg = result.value("error").toString(); + } + } + } + emit nxmRequestFailed(iter->m_GameName, iter->m_ModID, iter->m_FileID, + iter->m_UserData, iter->m_ID, statusCode, errorMsg); + } else { + int const statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (statusCode == 301) { + // redirect request, return request to queue + iter->m_URL = + reply->attribute(QNetworkRequest::RedirectionTargetAttribute).toString(); + iter->m_Reroute = true; + m_RequestQueue.enqueue(*iter); + // nextRequest(); + return; + } + QByteArray const data = reply->readAll(); + if (data.isNull() || data.isEmpty() || (strcmp(data.constData(), "null") == 0)) { + QString nexusError(reply->rawHeader("NexusErrorInfo")); + if (nexusError.length() == 0) { + nexusError = tr("empty response"); + } + log::debug("nexus error: {}", nexusError); + emit nxmRequestFailed(iter->m_GameName, iter->m_ModID, iter->m_FileID, + iter->m_UserData, iter->m_ID, reply->error(), nexusError); + } else { + QJsonDocument const responseDoc = QJsonDocument::fromJson(data); + if (!responseDoc.isNull()) { + QVariant const result = responseDoc.toVariant(); + switch (iter->m_Type) { + case NXMRequestInfo::TYPE_DESCRIPTION: { + emit nxmDescriptionAvailable(iter->m_GameName, iter->m_ModID, + iter->m_UserData, result, iter->m_ID); + } break; + case NXMRequestInfo::TYPE_MODINFO: { + emit nxmModInfoAvailable(iter->m_GameName, iter->m_ModID, iter->m_UserData, + result, iter->m_ID); + } break; + case NXMRequestInfo::TYPE_CHECKUPDATES: { + emit nxmUpdateInfoAvailable(iter->m_GameName, iter->m_UserData, result, + iter->m_ID); + } break; + case NXMRequestInfo::TYPE_FILES: { + emit nxmFilesAvailable(iter->m_GameName, iter->m_ModID, iter->m_UserData, + result, iter->m_ID); + } break; + case NXMRequestInfo::TYPE_GETUPDATES: { + emit nxmUpdatesAvailable(iter->m_GameName, iter->m_ModID, iter->m_UserData, + result, iter->m_ID); + } break; + case NXMRequestInfo::TYPE_FILEINFO: { + emit nxmFileInfoAvailable(iter->m_GameName, iter->m_ModID, iter->m_FileID, + iter->m_UserData, result, iter->m_ID); + } break; + case NXMRequestInfo::TYPE_DOWNLOADURL: { + emit nxmDownloadURLsAvailable(iter->m_GameName, iter->m_ModID, iter->m_FileID, + iter->m_UserData, result, iter->m_ID); + } break; + case NXMRequestInfo::TYPE_ENDORSEMENTS: { + emit nxmEndorsementsAvailable(iter->m_UserData, result, iter->m_ID); + } break; + case NXMRequestInfo::TYPE_TOGGLEENDORSEMENT: { + emit nxmEndorsementToggled(iter->m_GameName, iter->m_ModID, iter->m_UserData, + result, iter->m_ID); + } break; + case NXMRequestInfo::TYPE_TOGGLETRACKING: { + auto results = result.toMap(); + auto message = results["message"].toString(); + if (message.contains( + QRegularExpression("User [0-9]+ is already Tracking Mod: [0-9]+")) || + message.contains( + QRegularExpression("User [0-9]+ is now Tracking Mod: [0-9]+"))) { + emit nxmTrackingToggled(iter->m_GameName, iter->m_ModID, iter->m_UserData, + true, iter->m_ID); + } else if (message.contains(QRegularExpression( + "User [0-9]+ is no longer tracking [0-9]+")) || + message.contains(QRegularExpression( + "Users is not tracking mod. Unable to untrack."))) { + emit nxmTrackingToggled(iter->m_GameName, iter->m_ModID, iter->m_UserData, + false, iter->m_ID); + } + } break; + case NXMRequestInfo::TYPE_TRACKEDMODS: { + emit nxmTrackedModsAvailable(iter->m_UserData, result, iter->m_ID); + } break; + case NXMRequestInfo::TYPE_FILEINFO_MD5: { + emit nxmFileInfoFromMd5Available(iter->m_GameName, iter->m_UserData, result, + iter->m_ID); + } break; + case NXMRequestInfo::TYPE_GAMEINFO: { + emit nxmGameInfoAvailable(iter->m_GameName, iter->m_UserData, result, + iter->m_ID); + } break; + } + + m_User.limits(parseLimits(reply)); + emit requestsChanged(getAPIStats(), m_User); + } else { + emit nxmRequestFailed(iter->m_GameName, iter->m_ModID, iter->m_FileID, + iter->m_UserData, iter->m_ID, reply->error(), + tr("invalid response")); + } + } + } +} + +void NexusInterface::requestFinished() +{ + QNetworkReply* reply = static_cast(sender()); + for (std::list::iterator iter = m_ActiveRequest.begin(); + iter != m_ActiveRequest.end(); ++iter) { + if (iter->m_Reply == reply) { + iter->m_Timeout->stop(); + iter->m_Timeout->deleteLater(); + requestFinished(iter); + iter->m_Reply->deleteLater(); + m_ActiveRequest.erase(iter); + nextRequest(); + return; + } + } +} + +void NexusInterface::requestError(QNetworkReply::NetworkError) +{ + QNetworkReply* reply = qobject_cast(sender()); + if (reply == nullptr) { + log::warn("invalid sender type"); + return; + } + + log::error("request ({}) error: {} ({})", reply->url().toString(), + reply->errorString(), reply->error()); +} + +void NexusInterface::requestTimeout() +{ + QTimer* timer = qobject_cast(sender()); + if (timer == nullptr) { + log::warn("invalid sender type"); + return; + } + for (std::list::iterator iter = m_ActiveRequest.begin(); + iter != m_ActiveRequest.end(); ++iter) { + if (iter->m_Timeout == timer) { + // this abort causes a "request failed" which cleans up the rest + iter->m_Reply->abort(); + return; + } + } +} + +APIUserAccount NexusInterface::getAPIUserAccount() const +{ + return m_User; +} + +APIStats NexusInterface::getAPIStats() const +{ + APIStats stats; + stats.requestsQueued = m_RequestQueue.size(); + + return stats; +} + +void NexusInterface::applyGameNameOverride(NXMRequestInfo& info, const QString& gameName, + const MOBase::IPluginGame* game) +{ + // When gameName has no matching plugin (e.g. "site" for Nexus tools), getGame() + // returns the managed game as a fallback, causing NXMRequestInfo to store the wrong + // game name (e.g. "skyrimspecialedition" instead of "site"). Override it here. + if (game && game->gameShortName().compare(gameName, Qt::CaseInsensitive) != 0 && + game->gameNexusName().compare(gameName, Qt::CaseInsensitive) != 0) { + info.m_GameName = gameName.toLower(); + } +} + +namespace +{ +QString get_management_url() +{ + return "https://api.nexusmods.com/v1"; +} +} // namespace + +NexusInterface::NXMRequestInfo::NXMRequestInfo( + int modID, NexusInterface::NXMRequestInfo::Type type, QVariant userData, + const QString& subModule, MOBase::IPluginGame const* game) + : m_ModID(modID), m_ModVersion("0"), m_FileID(0), m_Reply(nullptr), m_Type(type), + m_UpdatePeriod(UpdatePeriod::NONE), m_UserData(userData), m_Timeout(nullptr), + m_Reroute(false), m_ID(s_NextID.fetchAndAddAcquire(1)), + m_URL(get_management_url()), m_SubModule(subModule), + m_NexusGameID(game->nexusGameID()), m_GameName(game->gameNexusName()), + m_Endorse(false), m_Track(false) +{} + +NexusInterface::NXMRequestInfo::NXMRequestInfo( + int modID, QString modVersion, NexusInterface::NXMRequestInfo::Type type, + QVariant userData, const QString& subModule, MOBase::IPluginGame const* game) + : m_ModID(modID), m_ModVersion(modVersion), m_FileID(0), m_Reply(nullptr), + m_Type(type), m_UpdatePeriod(UpdatePeriod::NONE), m_UserData(userData), + m_Timeout(nullptr), m_Reroute(false), m_ID(s_NextID.fetchAndAddAcquire(1)), + m_URL(get_management_url()), m_SubModule(subModule), + m_NexusGameID(game->nexusGameID()), m_GameName(game->gameNexusName()), + m_Endorse(false), m_Track(false) +{} + +NexusInterface::NXMRequestInfo::NXMRequestInfo(Type type, QVariant userData, + const QString& subModule, + MOBase::IPluginGame const* game) + : m_ModID(0), m_ModVersion("0"), m_FileID(0), m_Reply(nullptr), m_Type(type), + m_UpdatePeriod(UpdatePeriod::NONE), m_UserData(userData), m_Timeout(nullptr), + m_Reroute(false), m_ID(s_NextID.fetchAndAddAcquire(1)), + m_URL(get_management_url()), m_SubModule(subModule), + m_NexusGameID(game->nexusGameID()), m_GameName(game->gameNexusName()), + m_Endorse(false), m_Track(false) +{} + +NexusInterface::NXMRequestInfo::NXMRequestInfo( + int modID, int fileID, NexusInterface::NXMRequestInfo::Type type, QVariant userData, + const QString& subModule, MOBase::IPluginGame const* game) + : m_ModID(modID), m_ModVersion("0"), m_FileID(fileID), m_Reply(nullptr), + m_Type(type), m_UpdatePeriod(UpdatePeriod::NONE), m_UserData(userData), + m_Timeout(nullptr), m_Reroute(false), m_ID(s_NextID.fetchAndAddAcquire(1)), + m_URL(get_management_url()), m_SubModule(subModule), + m_NexusGameID(game->nexusGameID()), m_GameName(game->gameNexusName()), + m_Endorse(false), m_Track(false) +{} + +NexusInterface::NXMRequestInfo::NXMRequestInfo(Type type, QVariant userData, + const QString& subModule) + : m_ModID(0), m_ModVersion("0"), m_FileID(0), m_Reply(nullptr), m_Type(type), + m_UpdatePeriod(UpdatePeriod::NONE), m_UserData(userData), m_Timeout(nullptr), + m_Reroute(false), m_ID(s_NextID.fetchAndAddAcquire(1)), + m_URL(get_management_url()), m_SubModule(subModule), m_NexusGameID(0), + m_GameName(""), m_Endorse(false), m_Track(false) +{} + +NexusInterface::NXMRequestInfo::NXMRequestInfo( + UpdatePeriod period, NexusInterface::NXMRequestInfo::Type type, QVariant userData, + const QString& subModule, MOBase::IPluginGame const* game) + : m_ModID(0), m_ModVersion("0"), m_FileID(0), m_Reply(nullptr), m_Type(type), + m_UpdatePeriod(period), m_UserData(userData), m_Timeout(nullptr), + m_Reroute(false), m_ID(s_NextID.fetchAndAddAcquire(1)), + m_URL(get_management_url()), m_SubModule(subModule), + m_NexusGameID(game->nexusGameID()), m_GameName(game->gameNexusName()), + m_Endorse(false), m_Track(false) +{} + +NexusInterface::NXMRequestInfo::NXMRequestInfo( + QByteArray& hash, NexusInterface::NXMRequestInfo::Type type, QVariant userData, + const QString& subModule, MOBase::IPluginGame const* game) + : m_ModID(0), m_ModVersion("0"), m_FileID(0), m_Reply(nullptr), m_Type(type), + m_UpdatePeriod(UpdatePeriod::NONE), m_UserData(userData), m_Timeout(nullptr), + m_Reroute(false), m_ID(s_NextID.fetchAndAddAcquire(1)), + m_URL(get_management_url()), m_SubModule(subModule), + m_NexusGameID(game->nexusGameID()), m_GameName(game->gameNexusName()), + m_Endorse(false), m_Track(false), m_Hash(hash) +{} diff --git a/src/src/nxmaccessmanager.cpp b/src/src/nxmaccessmanager.cpp index 9400a43..30f1fbd 100644 --- a/src/src/nxmaccessmanager.cpp +++ b/src/src/nxmaccessmanager.cpp @@ -1,985 +1,985 @@ -/* -Copyright (C) 2012 Sebastian Herbord. All rights reserved. - -This file is part of Mod Organizer. - -Mod Organizer is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -Mod Organizer is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with Mod Organizer. If not, see . -*/ - -#include "nxmaccessmanager.h" -#include "iplugingame.h" -#include "nexusinterface.h" -#include "nxmurl.h" -#include "persistentcookiejar.h" -#include "report.h" -#include "selfupdater.h" -#include "settings.h" -#include "utility.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -using namespace MOBase; -using namespace std::chrono_literals; - -const QString NexusBaseUrl("https://api.nexusmods.com/v1"); -const QString NexusSSO("wss://sso.nexusmods.com"); -const QString - NexusSSOPage("https://www.nexusmods.com/sso?id=%1&application=modorganizer2"); - -ValidationProgressDialog::ValidationProgressDialog(Settings* s, NexusKeyValidator& v) - : m_settings(s), m_validator(v) -{ - ui.reset(new Ui::ValidationProgressDialog); - ui->setupUi(this); - - connect(ui->hide, &QPushButton::clicked, [&] { - onHide(); - }); - connect(ui->cancel, &QPushButton::clicked, [&] { - onCancel(); - }); -} - -void ValidationProgressDialog::setParentWidget(QWidget* w) -{ - const auto wasVisible = isVisible(); - - hide(); - setParent(w, windowFlags() | Qt::Dialog); - setModal(false); - - if (w && wasVisible) { - setVisible(true); - raise(); - } -} - -void ValidationProgressDialog::start() -{ - if (!m_updateTimer) { - m_updateTimer = new QTimer(this); - connect(m_updateTimer, &QTimer::timeout, [&] { - onTimer(); - }); - m_updateTimer->setInterval(100ms); - } - - updateProgress(); - m_updateTimer->start(); - - show(); -} - -void ValidationProgressDialog::stop() -{ - if (m_updateTimer) { - m_updateTimer->stop(); - } - - hide(); -} - -void ValidationProgressDialog::showEvent(QShowEvent* e) -{ - if (m_first) { - if (m_settings) { - m_settings->geometry().centerOnMainWindowMonitor(this); - } - - m_first = false; - } -} - -void ValidationProgressDialog::closeEvent(QCloseEvent* e) -{ - hide(); - e->ignore(); -} - -void ValidationProgressDialog::onHide() -{ - hide(); -} - -void ValidationProgressDialog::onCancel() -{ - m_validator.cancel(); -} - -void ValidationProgressDialog::onTimer() -{ - updateProgress(); -} - -void ValidationProgressDialog::updateProgress() -{ - const auto* current = m_validator.currentAttempt(); - - if (current) { - ui->progress->setRange(0, current->timeout().count()); - ui->progress->setValue(current->elapsed().elapsed() / 1000); - } else { - // indeterminate - ui->progress->setRange(0, 0); - } - - if (const auto* a = m_validator.lastAttempt()) { - ui->label->setText(a->message() + ". " + tr("Trying again...")); - } else if (current) { - ui->label->setText(tr("Connecting to Nexus...")); - } else { - ui->label->setText("?"); - } -} - -NexusSSOLogin::NexusSSOLogin() -{ - m_timeout.setInterval(10s); - m_timeout.setSingleShot(true); - - QObject::connect(&m_socket, &QWebSocket::connected, [&] { - onConnected(); - }); - - QObject::connect(&m_socket, &QWebSocket::errorOccurred, [&](auto&& e) { - onError(e); - }); - - QObject::connect(&m_socket, &QWebSocket::sslErrors, [&](auto&& errors) { - onSslErrors(errors); - }); - - QObject::connect(&m_socket, &QWebSocket::textMessageReceived, [&](auto&& s) { - onMessage(s); - }); - - QObject::connect(&m_socket, &QWebSocket::disconnected, [&] { - onDisconnected(); - }); - - QObject::connect(&m_timeout, &QTimer::timeout, [&] { - onTimeout(); - }); -} - -QString NexusSSOLogin::stateToString(States s, const QString& e) -{ - switch (s) { - case ConnectingToSSO: - return QObject::tr("Connecting to Nexus..."); - - case WaitingForToken: - return QObject::tr("Waiting for Nexus..."); - - case WaitingForBrowser: - return QObject::tr("Opened Nexus in browser.") + "\n" + - QObject::tr("Switch to your browser and accept the request."); - - case Finished: - return QObject::tr("Finished."); - - case Timeout: - return QObject::tr("No answer from Nexus.") + "\n" + - QObject::tr("A firewall might be blocking Mod Organizer."); - - case ClosedByRemote: - return QObject::tr("Nexus closed the connection.") + "\n" + - QObject::tr("A firewall might be blocking Mod Organizer."); - - case Cancelled: - return QObject::tr("Cancelled."); - - case Error: // fall-through - default: { - if (e.isEmpty()) { - return QString("%1").arg(s); - } else { - return e; - } - } - } -} - -void NexusSSOLogin::start() -{ - m_active = true; - setState(ConnectingToSSO); - m_timeout.start(); - m_socket.open(NexusSSO); -} - -void NexusSSOLogin::cancel() -{ - if (m_active) { - abort(); - setState(Cancelled); - } -} - -void NexusSSOLogin::close() -{ - if (m_active) { - m_active = false; - m_timeout.stop(); - m_socket.close(); - } -} - -void NexusSSOLogin::abort() -{ - m_active = false; - m_timeout.stop(); - m_socket.abort(); -} - -bool NexusSSOLogin::isActive() const -{ - return m_active; -} - -void NexusSSOLogin::setState(States s, const QString& error) const -{ - if (stateChanged) { - stateChanged(s, error); - } -} - -void NexusSSOLogin::onConnected() -{ - setState(WaitingForToken); - - m_keyReceived = false; - - boost::uuids::random_generator generator; - boost::uuids::uuid const sessionId = generator(); - m_guid = boost::uuids::to_string(sessionId).c_str(); - - QJsonObject data; - data.insert(QString("id"), QJsonValue(m_guid)); - data.insert(QString("protocol"), 2); - - const QString message = QJsonDocument(data).toJson(); - m_socket.sendTextMessage(message); -} - -void NexusSSOLogin::onMessage(const QString& s) -{ - const QJsonDocument doc = QJsonDocument::fromJson(s.toUtf8()); - const QVariantMap root = doc.object().toVariantMap(); - - if (!root["success"].toBool()) { - close(); - - setState(Error, QString("There was a problem with SSO initialization: %1") - .arg(root["error"].toString())); - - return; - } - - const QVariantMap data = root["data"].toMap(); - - if (data.contains("connection_token")) { - // first answer - - // open browser - const QUrl url = NexusSSOPage.arg(m_guid); - shell::Open(url); - - m_timeout.stop(); - setState(WaitingForBrowser); - } else { - // second answer - const auto key = data["api_key"].toString(); - close(); - - if (keyChanged) { - keyChanged(key); - } - - setState(Finished); - } -} - -void NexusSSOLogin::onDisconnected() -{ - if (m_active) { - if (!m_keyReceived) { - close(); - setState(ClosedByRemote); - } else { - m_active = false; - } - } -} - -void NexusSSOLogin::onError(QAbstractSocket::SocketError e) -{ - if (m_active) { - close(); - setState(Error, m_socket.errorString()); - } -} - -void NexusSSOLogin::onSslErrors(const QList& errors) -{ - if (m_active) { - for (const auto& e : errors) { - setState(Error, e.errorString()); - } - } -} - -void NexusSSOLogin::onTimeout() -{ - abort(); - setState(Timeout); -} - -ValidationAttempt::ValidationAttempt(std::chrono::seconds timeout) - -{ - m_timeout.setSingleShot(true); - m_timeout.setInterval(timeout); - - QObject::connect(&m_timeout, &QTimer::timeout, [&] { - onTimeout(); - }); -} - -void ValidationAttempt::start(NXMAccessManager& m, const QString& key) -{ - if (!sendRequest(m, key)) { - return; - } - - m_elapsed.start(); - m_timeout.start(); - - log::debug("nexus: attempt started with timeout of {} seconds", timeout().count()); -} - -bool ValidationAttempt::sendRequest(NXMAccessManager& m, const QString& key) -{ - const QString requestUrl(NexusBaseUrl + "/users/validate"); - QNetworkRequest request(requestUrl); - - request.setRawHeader("APIKEY", key.toUtf8()); - request.setHeader(QNetworkRequest::KnownHeaders::UserAgentHeader, - m.userAgent().toUtf8()); - request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, - "application/json"); - request.setRawHeader("Protocol-Version", "1.0.0"); - request.setRawHeader("Application-Name", "MO2"); - request.setRawHeader("Application-Version", m.MOVersion().toUtf8()); - - m_reply = m.get(request); - - if (!m_reply) { - setFailure(SoftError, QObject::tr("Failed to request %1").arg(requestUrl)); - return false; - } - - QObject::connect(m_reply, &QNetworkReply::finished, [&] { - onFinished(); - }); - - QObject::connect(m_reply, &QNetworkReply::sslErrors, [&](auto&& errors) { - onSslErrors(errors); - }); - - return true; -} - -void ValidationAttempt::cancel() -{ - if (!m_reply || m_result != None) { - // not running - return; - } - - setFailure(Cancelled, QObject::tr("Cancelled")); - - if (m_reply) { - m_reply->abort(); - } - - cleanup(); -} - -bool ValidationAttempt::done() const -{ - return (m_result != None); -} - -ValidationAttempt::Result ValidationAttempt::result() const -{ - return m_result; -} - -const QString& ValidationAttempt::message() const -{ - return m_message; -} - -std::chrono::seconds ValidationAttempt::timeout() const -{ - return std::chrono::duration_cast( - m_timeout.intervalAsDuration()); -} - -QElapsedTimer ValidationAttempt::elapsed() const -{ - return m_elapsed; -} - -void ValidationAttempt::onFinished() -{ - if (m_result == Cancelled) { - return; - } - - log::debug("nexus: request has finished"); - - if (!m_reply) { - // shouldn't happen - log::error("nexus: reply is null"); - setFailure(HardError, QObject::tr("Internal error")); - return; - } - - const auto code = - m_reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - - if (code == 0) { - // request wasn't even sent - log::error("nexus: code is 0"); - setFailure(SoftError, m_reply->errorString()); - return; - } - - const auto doc = QJsonDocument::fromJson(m_reply->readAll()); - const auto headers = m_reply->rawHeaderPairs(); - const auto httpError = m_reply->errorString(); - - const QJsonObject data = doc.object(); - - if (code != 200) { - // http request failed - - QString s = m_reply->errorString(); - - const auto nexusMessage = data.value("message").toString(); - if (!nexusMessage.isEmpty()) { - if (!s.isEmpty()) { - s += ", "; - } - - s += nexusMessage; - } - - if (s.isEmpty()) { - s = QObject::tr("HTTP code %1").arg(code); - } else { - s += QString(" (%1)").arg(code); - } - - setFailure(SoftError, s); - return; - } - - if (doc.isNull()) { - setFailure(HardError, QObject::tr("Invalid JSON")); - return; - } - - if (!data.contains("user_id")) { - setFailure(HardError, QObject::tr("Bad response")); - return; - } - - const int id = data.value("user_id").toInt(); - const QString key = data.value("key").toString(); - const QString name = data.value("name").toString(); - const bool premium = data.value("is_premium").toBool(); - - if (key.isEmpty()) { - setFailure(HardError, QObject::tr("API key is empty")); - return; - } - - const auto user = - APIUserAccount() - .apiKey(key) - .id(QString("%1").arg(id)) - .name(name) - .type(premium ? APIUserAccountTypes::Premium : APIUserAccountTypes::Regular) - .limits(NexusInterface::parseLimits(headers)); - - setSuccess(user); -} - -void ValidationAttempt::onSslErrors(const QList& errors) -{ - log::error("nexus: ssl errors"); - - for (auto& e : errors) { - log::error(" . {}", e.errorString()); - } - - setFailure(HardError, QObject::tr("SSL error")); -} - -void ValidationAttempt::onTimeout() -{ - setFailure(SoftError, QObject::tr("Timed out")); -} - -void ValidationAttempt::setFailure(Result r, const QString& error) -{ - if (r != Cancelled) { - // don't spam the log - log::error("nexus: {}", error); - } - - cleanup(); - - m_result = r; - m_message = error; - - if (failure) { - failure(); - } -} - -void ValidationAttempt::setSuccess(const APIUserAccount& user) -{ - log::debug("nexus connection successful"); - cleanup(); - - m_result = Success; - m_message = ""; - - if (success) { - success(user); - } -} - -void ValidationAttempt::cleanup() -{ - m_timeout.stop(); - - if (m_reply) { - m_reply->disconnect(); - m_reply->deleteLater(); - m_reply = nullptr; - } -} - -NexusKeyValidator::NexusKeyValidator(Settings* s, NXMAccessManager& am) - : m_settings(s), m_manager(am) -{} - -NexusKeyValidator::~NexusKeyValidator() -{ - cancel(); -} - -std::vector NexusKeyValidator::getTimeouts() const -{ - if (m_settings) { - return m_settings->nexus().validationTimeouts(); - } else { - return {10s, 15s, 20s}; - } -} - -void NexusKeyValidator::start(const QString& key, Behaviour b) -{ - if (isActive()) { - log::debug("nexus: trying to start while ongoing; ignoring"); - return; - } - - m_key = key; - - const auto timeouts = getTimeouts(); - - switch (b) { - case OneShot: { - createAttempts({timeouts[0]}); - break; - } - - case Retry: { - createAttempts(timeouts); - break; - } - } - - nextTry(); -} - -void NexusKeyValidator::createAttempts( - const std::vector& timeouts) -{ - m_attempts.clear(); - - for (auto&& t : timeouts) { - m_attempts.push_back(std::make_unique(t)); - } -} - -void NexusKeyValidator::cancel() -{ - log::debug("nexus: connection cancelled"); - - for (auto&& a : m_attempts) { - a->cancel(); - } -} - -bool NexusKeyValidator::isActive() const -{ - for (auto&& a : m_attempts) { - if (!a->done()) { - return true; - } - } - - return false; -} - -const ValidationAttempt* NexusKeyValidator::lastAttempt() const -{ - const ValidationAttempt* last = nullptr; - - for (auto&& a : m_attempts) { - if (a->done()) { - last = a.get(); - } else { - break; - } - } - - return last; -} - -const ValidationAttempt* NexusKeyValidator::currentAttempt() const -{ - for (auto&& a : m_attempts) { - if (!a->done()) { - return a.get(); - } - } - - return nullptr; -} - -bool NexusKeyValidator::nextTry() -{ - for (auto&& a : m_attempts) { - if (!a->done()) { - a->success = [&](auto&& user) { - onAttemptSuccess(*a, user); - }; - a->failure = [&] { - onAttemptFailure(*a); - }; - - a->start(m_manager, m_key); - return true; - } - } - - // no more - return false; -} - -void NexusKeyValidator::onAttemptSuccess(const ValidationAttempt& a, - const APIUserAccount& u) -{ - if (attemptFinished) { - attemptFinished(a); - } - - setFinished(ValidationAttempt::Success, "", u); -} - -void NexusKeyValidator::onAttemptFailure(const ValidationAttempt& a) -{ - if (attemptFinished) { - attemptFinished(a); - } - - switch (a.result()) { - case ValidationAttempt::SoftError: { - if (!nextTry()) { - setFinished(a.result(), a.message(), {}); - } - - break; - } - - case ValidationAttempt::HardError: { - cancel(); - setFinished(a.result(), a.message(), {}); - break; - } - - case ValidationAttempt::Cancelled: { - setFinished(ValidationAttempt::Cancelled, QObject::tr("Cancelled"), {}); - break; - } - } -} - -void NexusKeyValidator::setFinished(ValidationAttempt::Result r, const QString& message, - std::optional user) const -{ - if (finished) { - finished(r, message, user); - } -} - -NXMAccessManager::NXMAccessManager(QObject* parent, Settings* s, - const QString& moVersion) - : QNetworkAccessManager(parent), m_Settings(s), m_MOVersion(moVersion), - m_validator(s, *this) -{ - m_validator.finished = [&](auto&& r, auto&& m, auto&& u) { - onValidatorFinished(r, m, u); - }; - - m_validator.attemptFinished = [&](auto&& a) { - onValidatorAttemptFinished(a); - }; - - if (m_Settings) { - setCookieJar(new PersistentCookieJar(QDir::fromNativeSeparators( - m_Settings->paths().cache() + "/nexus_cookies.dat"))); - } -} - -void NXMAccessManager::setTopLevelWidget(QWidget* w) -{ - if (w) { - if (m_ProgressDialog) { - m_ProgressDialog->setParentWidget(w); - } - } else { - m_ProgressDialog.reset(); - m_validator.cancel(); - } -} - -QNetworkReply* -NXMAccessManager::createRequest(QNetworkAccessManager::Operation operation, - const QNetworkRequest& request, QIODevice* device) -{ - if (request.url().scheme() != "nxm") { - return QNetworkAccessManager::createRequest(operation, request, device); - } - if (operation == GetOperation) { - emit requestNXMDownload(request.url().toString()); - - // eat the request, everything else will be done by the download manager - return QNetworkAccessManager::createRequest(QNetworkAccessManager::GetOperation, - QNetworkRequest(QUrl())); - } else if (operation == PostOperation) { - return QNetworkAccessManager::createRequest(operation, request, device); - ; - } else { - return QNetworkAccessManager::createRequest(operation, request, device); - } -} - -void NXMAccessManager::showCookies() const -{ - QUrl const url(NexusBaseUrl + "/"); - for (const QNetworkCookie& cookie : cookieJar()->cookiesForUrl(url)) { - log::debug("{} - {} (expires: {})", cookie.name().constData(), - cookie.value().constData(), cookie.expirationDate().toString()); - } -} - -void NXMAccessManager::clearCookies() -{ - PersistentCookieJar* jar = qobject_cast(cookieJar()); - if (jar != nullptr) { - jar->clear(); - } else { - log::warn("failed to clear cookies, invalid cookie jar"); - } -} - -void NXMAccessManager::startValidationCheck(const QString& key) -{ - m_validationState = NotChecked; - m_validator.start(key, NexusKeyValidator::Retry); - - if (m_ProgressDialog) { - // don't show the progress dialog on startup for the first attempt; the - // dialog will be shown in onValidatorAttemptFinished() if it failed - startProgress(); - } -} - -void NXMAccessManager::onValidatorFinished(ValidationAttempt::Result r, - const QString& message, - std::optional user) -{ - stopProgress(); - - if (user) { - m_validationState = Valid; - emit credentialsReceived(*user); - emit validateSuccessful(true); - } else { - if (r == ValidationAttempt::Cancelled) { - m_validationState = NotChecked; - } else { - m_validationState = Invalid; - emit validateFailed(message); - } - } -} - -void NXMAccessManager::onValidatorAttemptFinished(const ValidationAttempt& a) -{ - if (!m_ProgressDialog) { - switch (a.result()) { - case ValidationAttempt::SoftError: - case ValidationAttempt::HardError: { - startProgress(); - break; - } - - case ValidationAttempt::None: - case ValidationAttempt::Success: - case ValidationAttempt::Cancelled: - default: { - // don't show the dialog - break; - } - } - } -} - -bool NXMAccessManager::validated() const -{ - if (m_validationState == Valid) { - return true; - } - - if (m_validator.isActive()) { - const_cast(this)->startProgress(); - } - - return false; -} - -void NXMAccessManager::refuseValidation() -{ - m_validationState = Invalid; -} - -bool NXMAccessManager::validateAttempted() const -{ - return (m_validationState != NotChecked); -} - -bool NXMAccessManager::validateWaiting() const -{ - return m_validator.isActive(); -} - -void NXMAccessManager::apiCheck(const QString& apiKey, bool force) -{ - if (m_validator.isActive()) { - return; - } - - if (m_Settings && m_Settings->network().offlineMode()) { - m_validationState = NotChecked; - return; - } - - if (force) { - m_validationState = NotChecked; - } - - if (m_validationState == Valid) { - emit validateSuccessful(false); - return; - } - - startValidationCheck(apiKey); -} - -const QString& NXMAccessManager::MOVersion() const -{ - return m_MOVersion; -} - -QString NXMAccessManager::userAgent(const QString& subModule) const -{ - QStringList comments; - QString const os; - if (QSysInfo::productType() == "windows") - comments << ((QSysInfo::kernelType() == "winnt") ? "Windows_NT " : "Windows ") + - QSysInfo::kernelVersion(); - else - comments << QSysInfo::kernelType().left(1).toUpper() + QSysInfo::kernelType().mid(1) - << QSysInfo::productType().left(1).toUpper() + - QSysInfo::kernelType().mid(1) + " " + QSysInfo::productVersion(); - if (!subModule.isEmpty()) { - comments << "module: " + subModule; - } - comments << ((QSysInfo::buildCpuArchitecture() == "x86_64") ? "x64" : "x86"); - - return QString("Mod Organizer/%1 (%2) Qt/%3") - .arg(m_MOVersion, comments.join("; "), qVersion()); -} - -void NXMAccessManager::clearApiKey() -{ - m_validator.cancel(); - emit credentialsReceived(APIUserAccount()); -} - -void NXMAccessManager::startProgress() -{ - if (!m_ProgressDialog) { - m_ProgressDialog.reset(new ValidationProgressDialog(m_Settings, m_validator)); - } - - m_ProgressDialog->start(); -} - -void NXMAccessManager::stopProgress() -{ - if (m_ProgressDialog) { - m_ProgressDialog->stop(); - } -} +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "nxmaccessmanager.h" +#include "iplugingame.h" +#include "nexusinterface.h" +#include "nxmurl.h" +#include "persistentcookiejar.h" +#include "report.h" +#include "selfupdater.h" +#include "settings.h" +#include "utility.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace MOBase; +using namespace std::chrono_literals; + +const QString NexusBaseUrl("https://api.nexusmods.com/v1"); +const QString NexusSSO("wss://sso.nexusmods.com"); +const QString + NexusSSOPage("https://www.nexusmods.com/sso?id=%1&application=modorganizer2"); + +ValidationProgressDialog::ValidationProgressDialog(Settings* s, NexusKeyValidator& v) + : m_settings(s), m_validator(v) +{ + ui.reset(new Ui::ValidationProgressDialog); + ui->setupUi(this); + + connect(ui->hide, &QPushButton::clicked, [&] { + onHide(); + }); + connect(ui->cancel, &QPushButton::clicked, [&] { + onCancel(); + }); +} + +void ValidationProgressDialog::setParentWidget(QWidget* w) +{ + const auto wasVisible = isVisible(); + + hide(); + setParent(w, windowFlags() | Qt::Dialog); + setModal(false); + + if (w && wasVisible) { + setVisible(true); + raise(); + } +} + +void ValidationProgressDialog::start() +{ + if (!m_updateTimer) { + m_updateTimer = new QTimer(this); + connect(m_updateTimer, &QTimer::timeout, [&] { + onTimer(); + }); + m_updateTimer->setInterval(100ms); + } + + updateProgress(); + m_updateTimer->start(); + + show(); +} + +void ValidationProgressDialog::stop() +{ + if (m_updateTimer) { + m_updateTimer->stop(); + } + + hide(); +} + +void ValidationProgressDialog::showEvent(QShowEvent* e) +{ + if (m_first) { + if (m_settings) { + m_settings->geometry().centerOnMainWindowMonitor(this); + } + + m_first = false; + } +} + +void ValidationProgressDialog::closeEvent(QCloseEvent* e) +{ + hide(); + e->ignore(); +} + +void ValidationProgressDialog::onHide() +{ + hide(); +} + +void ValidationProgressDialog::onCancel() +{ + m_validator.cancel(); +} + +void ValidationProgressDialog::onTimer() +{ + updateProgress(); +} + +void ValidationProgressDialog::updateProgress() +{ + const auto* current = m_validator.currentAttempt(); + + if (current) { + ui->progress->setRange(0, current->timeout().count()); + ui->progress->setValue(current->elapsed().elapsed() / 1000); + } else { + // indeterminate + ui->progress->setRange(0, 0); + } + + if (const auto* a = m_validator.lastAttempt()) { + ui->label->setText(a->message() + ". " + tr("Trying again...")); + } else if (current) { + ui->label->setText(tr("Connecting to Nexus...")); + } else { + ui->label->setText("?"); + } +} + +NexusSSOLogin::NexusSSOLogin() +{ + m_timeout.setInterval(10s); + m_timeout.setSingleShot(true); + + QObject::connect(&m_socket, &QWebSocket::connected, [&] { + onConnected(); + }); + + QObject::connect(&m_socket, &QWebSocket::errorOccurred, [&](auto&& e) { + onError(e); + }); + + QObject::connect(&m_socket, &QWebSocket::sslErrors, [&](auto&& errors) { + onSslErrors(errors); + }); + + QObject::connect(&m_socket, &QWebSocket::textMessageReceived, [&](auto&& s) { + onMessage(s); + }); + + QObject::connect(&m_socket, &QWebSocket::disconnected, [&] { + onDisconnected(); + }); + + QObject::connect(&m_timeout, &QTimer::timeout, [&] { + onTimeout(); + }); +} + +QString NexusSSOLogin::stateToString(States s, const QString& e) +{ + switch (s) { + case ConnectingToSSO: + return QObject::tr("Connecting to Nexus..."); + + case WaitingForToken: + return QObject::tr("Waiting for Nexus..."); + + case WaitingForBrowser: + return QObject::tr("Opened Nexus in browser.") + "\n" + + QObject::tr("Switch to your browser and accept the request."); + + case Finished: + return QObject::tr("Finished."); + + case Timeout: + return QObject::tr("No answer from Nexus.") + "\n" + + QObject::tr("A firewall might be blocking Mod Organizer."); + + case ClosedByRemote: + return QObject::tr("Nexus closed the connection.") + "\n" + + QObject::tr("A firewall might be blocking Mod Organizer."); + + case Cancelled: + return QObject::tr("Cancelled."); + + case Error: // fall-through + default: { + if (e.isEmpty()) { + return QString("%1").arg(s); + } else { + return e; + } + } + } +} + +void NexusSSOLogin::start() +{ + m_active = true; + setState(ConnectingToSSO); + m_timeout.start(); + m_socket.open(NexusSSO); +} + +void NexusSSOLogin::cancel() +{ + if (m_active) { + abort(); + setState(Cancelled); + } +} + +void NexusSSOLogin::close() +{ + if (m_active) { + m_active = false; + m_timeout.stop(); + m_socket.close(); + } +} + +void NexusSSOLogin::abort() +{ + m_active = false; + m_timeout.stop(); + m_socket.abort(); +} + +bool NexusSSOLogin::isActive() const +{ + return m_active; +} + +void NexusSSOLogin::setState(States s, const QString& error) const +{ + if (stateChanged) { + stateChanged(s, error); + } +} + +void NexusSSOLogin::onConnected() +{ + setState(WaitingForToken); + + m_keyReceived = false; + + boost::uuids::random_generator generator; + boost::uuids::uuid const sessionId = generator(); + m_guid = boost::uuids::to_string(sessionId).c_str(); + + QJsonObject data; + data.insert(QString("id"), QJsonValue(m_guid)); + data.insert(QString("protocol"), 2); + + const QString message = QJsonDocument(data).toJson(); + m_socket.sendTextMessage(message); +} + +void NexusSSOLogin::onMessage(const QString& s) +{ + const QJsonDocument doc = QJsonDocument::fromJson(s.toUtf8()); + const QVariantMap root = doc.object().toVariantMap(); + + if (!root["success"].toBool()) { + close(); + + setState(Error, QString("There was a problem with SSO initialization: %1") + .arg(root["error"].toString())); + + return; + } + + const QVariantMap data = root["data"].toMap(); + + if (data.contains("connection_token")) { + // first answer + + // open browser + const QUrl url = NexusSSOPage.arg(m_guid); + shell::Open(url); + + m_timeout.stop(); + setState(WaitingForBrowser); + } else { + // second answer + const auto key = data["api_key"].toString(); + close(); + + if (keyChanged) { + keyChanged(key); + } + + setState(Finished); + } +} + +void NexusSSOLogin::onDisconnected() +{ + if (m_active) { + if (!m_keyReceived) { + close(); + setState(ClosedByRemote); + } else { + m_active = false; + } + } +} + +void NexusSSOLogin::onError(QAbstractSocket::SocketError e) +{ + if (m_active) { + close(); + setState(Error, m_socket.errorString()); + } +} + +void NexusSSOLogin::onSslErrors(const QList& errors) +{ + if (m_active) { + for (const auto& e : errors) { + setState(Error, e.errorString()); + } + } +} + +void NexusSSOLogin::onTimeout() +{ + abort(); + setState(Timeout); +} + +ValidationAttempt::ValidationAttempt(std::chrono::seconds timeout) + +{ + m_timeout.setSingleShot(true); + m_timeout.setInterval(timeout); + + QObject::connect(&m_timeout, &QTimer::timeout, [&] { + onTimeout(); + }); +} + +void ValidationAttempt::start(NXMAccessManager& m, const QString& key) +{ + if (!sendRequest(m, key)) { + return; + } + + m_elapsed.start(); + m_timeout.start(); + + log::debug("nexus: attempt started with timeout of {} seconds", timeout().count()); +} + +bool ValidationAttempt::sendRequest(NXMAccessManager& m, const QString& key) +{ + const QString requestUrl(NexusBaseUrl + "/users/validate"); + QNetworkRequest request(requestUrl); + + request.setRawHeader("APIKEY", key.toUtf8()); + request.setHeader(QNetworkRequest::KnownHeaders::UserAgentHeader, + m.userAgent().toUtf8()); + request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, + "application/json"); + request.setRawHeader("Protocol-Version", "1.0.0"); + request.setRawHeader("Application-Name", "MO2"); + request.setRawHeader("Application-Version", m.MOVersion().toUtf8()); + + m_reply = m.get(request); + + if (!m_reply) { + setFailure(SoftError, QObject::tr("Failed to request %1").arg(requestUrl)); + return false; + } + + QObject::connect(m_reply, &QNetworkReply::finished, [&] { + onFinished(); + }); + + QObject::connect(m_reply, &QNetworkReply::sslErrors, [&](auto&& errors) { + onSslErrors(errors); + }); + + return true; +} + +void ValidationAttempt::cancel() +{ + if (!m_reply || m_result != None) { + // not running + return; + } + + setFailure(Cancelled, QObject::tr("Cancelled")); + + if (m_reply) { + m_reply->abort(); + } + + cleanup(); +} + +bool ValidationAttempt::done() const +{ + return (m_result != None); +} + +ValidationAttempt::Result ValidationAttempt::result() const +{ + return m_result; +} + +const QString& ValidationAttempt::message() const +{ + return m_message; +} + +std::chrono::seconds ValidationAttempt::timeout() const +{ + return std::chrono::duration_cast( + m_timeout.intervalAsDuration()); +} + +QElapsedTimer ValidationAttempt::elapsed() const +{ + return m_elapsed; +} + +void ValidationAttempt::onFinished() +{ + if (m_result == Cancelled) { + return; + } + + log::debug("nexus: request has finished"); + + if (!m_reply) { + // shouldn't happen + log::error("nexus: reply is null"); + setFailure(HardError, QObject::tr("Internal error")); + return; + } + + const auto code = + m_reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + if (code == 0) { + // request wasn't even sent + log::error("nexus: code is 0"); + setFailure(SoftError, m_reply->errorString()); + return; + } + + const auto doc = QJsonDocument::fromJson(m_reply->readAll()); + const auto headers = m_reply->rawHeaderPairs(); + const auto httpError = m_reply->errorString(); + + const QJsonObject data = doc.object(); + + if (code != 200) { + // http request failed + + QString s = m_reply->errorString(); + + const auto nexusMessage = data.value("message").toString(); + if (!nexusMessage.isEmpty()) { + if (!s.isEmpty()) { + s += ", "; + } + + s += nexusMessage; + } + + if (s.isEmpty()) { + s = QObject::tr("HTTP code %1").arg(code); + } else { + s += QString(" (%1)").arg(code); + } + + setFailure(SoftError, s); + return; + } + + if (doc.isNull()) { + setFailure(HardError, QObject::tr("Invalid JSON")); + return; + } + + if (!data.contains("user_id")) { + setFailure(HardError, QObject::tr("Bad response")); + return; + } + + const int id = data.value("user_id").toInt(); + const QString key = data.value("key").toString(); + const QString name = data.value("name").toString(); + const bool premium = data.value("is_premium").toBool(); + + if (key.isEmpty()) { + setFailure(HardError, QObject::tr("API key is empty")); + return; + } + + const auto user = + APIUserAccount() + .apiKey(key) + .id(QString("%1").arg(id)) + .name(name) + .type(premium ? APIUserAccountTypes::Premium : APIUserAccountTypes::Regular) + .limits(NexusInterface::parseLimits(headers)); + + setSuccess(user); +} + +void ValidationAttempt::onSslErrors(const QList& errors) +{ + log::error("nexus: ssl errors"); + + for (const auto& e : errors) { + log::error(" . {}", e.errorString()); + } + + setFailure(HardError, QObject::tr("SSL error")); +} + +void ValidationAttempt::onTimeout() +{ + setFailure(SoftError, QObject::tr("Timed out")); +} + +void ValidationAttempt::setFailure(Result r, const QString& error) +{ + if (r != Cancelled) { + // don't spam the log + log::error("nexus: {}", error); + } + + cleanup(); + + m_result = r; + m_message = error; + + if (failure) { + failure(); + } +} + +void ValidationAttempt::setSuccess(const APIUserAccount& user) +{ + log::debug("nexus connection successful"); + cleanup(); + + m_result = Success; + m_message = ""; + + if (success) { + success(user); + } +} + +void ValidationAttempt::cleanup() +{ + m_timeout.stop(); + + if (m_reply) { + m_reply->disconnect(); + m_reply->deleteLater(); + m_reply = nullptr; + } +} + +NexusKeyValidator::NexusKeyValidator(Settings* s, NXMAccessManager& am) + : m_settings(s), m_manager(am) +{} + +NexusKeyValidator::~NexusKeyValidator() +{ + cancel(); +} + +std::vector NexusKeyValidator::getTimeouts() const +{ + if (m_settings) { + return m_settings->nexus().validationTimeouts(); + } else { + return {10s, 15s, 20s}; + } +} + +void NexusKeyValidator::start(const QString& key, Behaviour b) +{ + if (isActive()) { + log::debug("nexus: trying to start while ongoing; ignoring"); + return; + } + + m_key = key; + + const auto timeouts = getTimeouts(); + + switch (b) { + case OneShot: { + createAttempts({timeouts[0]}); + break; + } + + case Retry: { + createAttempts(timeouts); + break; + } + } + + nextTry(); +} + +void NexusKeyValidator::createAttempts( + const std::vector& timeouts) +{ + m_attempts.clear(); + + for (auto&& t : timeouts) { + m_attempts.push_back(std::make_unique(t)); + } +} + +void NexusKeyValidator::cancel() +{ + log::debug("nexus: connection cancelled"); + + for (auto&& a : m_attempts) { + a->cancel(); + } +} + +bool NexusKeyValidator::isActive() const +{ + for (auto&& a : m_attempts) { + if (!a->done()) { + return true; + } + } + + return false; +} + +const ValidationAttempt* NexusKeyValidator::lastAttempt() const +{ + const ValidationAttempt* last = nullptr; + + for (auto&& a : m_attempts) { + if (a->done()) { + last = a.get(); + } else { + break; + } + } + + return last; +} + +const ValidationAttempt* NexusKeyValidator::currentAttempt() const +{ + for (auto&& a : m_attempts) { + if (!a->done()) { + return a.get(); + } + } + + return nullptr; +} + +bool NexusKeyValidator::nextTry() +{ + for (auto&& a : m_attempts) { + if (!a->done()) { + a->success = [&](auto&& user) { + onAttemptSuccess(*a, user); + }; + a->failure = [&] { + onAttemptFailure(*a); + }; + + a->start(m_manager, m_key); + return true; + } + } + + // no more + return false; +} + +void NexusKeyValidator::onAttemptSuccess(const ValidationAttempt& a, + const APIUserAccount& u) +{ + if (attemptFinished) { + attemptFinished(a); + } + + setFinished(ValidationAttempt::Success, "", u); +} + +void NexusKeyValidator::onAttemptFailure(const ValidationAttempt& a) +{ + if (attemptFinished) { + attemptFinished(a); + } + + switch (a.result()) { + case ValidationAttempt::SoftError: { + if (!nextTry()) { + setFinished(a.result(), a.message(), {}); + } + + break; + } + + case ValidationAttempt::HardError: { + cancel(); + setFinished(a.result(), a.message(), {}); + break; + } + + case ValidationAttempt::Cancelled: { + setFinished(ValidationAttempt::Cancelled, QObject::tr("Cancelled"), {}); + break; + } + } +} + +void NexusKeyValidator::setFinished(ValidationAttempt::Result r, const QString& message, + std::optional user) const +{ + if (finished) { + finished(r, message, user); + } +} + +NXMAccessManager::NXMAccessManager(QObject* parent, Settings* s, + const QString& moVersion) + : QNetworkAccessManager(parent), m_Settings(s), m_MOVersion(moVersion), + m_validator(s, *this) +{ + m_validator.finished = [&](auto&& r, auto&& m, auto&& u) { + onValidatorFinished(r, m, u); + }; + + m_validator.attemptFinished = [&](auto&& a) { + onValidatorAttemptFinished(a); + }; + + if (m_Settings) { + setCookieJar(new PersistentCookieJar(QDir::fromNativeSeparators( + m_Settings->paths().cache() + "/nexus_cookies.dat"))); + } +} + +void NXMAccessManager::setTopLevelWidget(QWidget* w) +{ + if (w) { + if (m_ProgressDialog) { + m_ProgressDialog->setParentWidget(w); + } + } else { + m_ProgressDialog.reset(); + m_validator.cancel(); + } +} + +QNetworkReply* +NXMAccessManager::createRequest(QNetworkAccessManager::Operation operation, + const QNetworkRequest& request, QIODevice* device) +{ + if (request.url().scheme() != "nxm") { + return QNetworkAccessManager::createRequest(operation, request, device); + } + if (operation == GetOperation) { + emit requestNXMDownload(request.url().toString()); + + // eat the request, everything else will be done by the download manager + return QNetworkAccessManager::createRequest(QNetworkAccessManager::GetOperation, + QNetworkRequest(QUrl())); + } else if (operation == PostOperation) { + return QNetworkAccessManager::createRequest(operation, request, device); + ; + } else { + return QNetworkAccessManager::createRequest(operation, request, device); + } +} + +void NXMAccessManager::showCookies() const +{ + QUrl const url(NexusBaseUrl + "/"); + for (const QNetworkCookie& cookie : cookieJar()->cookiesForUrl(url)) { + log::debug("{} - {} (expires: {})", cookie.name().constData(), + cookie.value().constData(), cookie.expirationDate().toString()); + } +} + +void NXMAccessManager::clearCookies() +{ + PersistentCookieJar* jar = qobject_cast(cookieJar()); + if (jar != nullptr) { + jar->clear(); + } else { + log::warn("failed to clear cookies, invalid cookie jar"); + } +} + +void NXMAccessManager::startValidationCheck(const QString& key) +{ + m_validationState = NotChecked; + m_validator.start(key, NexusKeyValidator::Retry); + + if (m_ProgressDialog) { + // don't show the progress dialog on startup for the first attempt; the + // dialog will be shown in onValidatorAttemptFinished() if it failed + startProgress(); + } +} + +void NXMAccessManager::onValidatorFinished(ValidationAttempt::Result r, + const QString& message, + std::optional user) +{ + stopProgress(); + + if (user) { + m_validationState = Valid; + emit credentialsReceived(*user); + emit validateSuccessful(true); + } else { + if (r == ValidationAttempt::Cancelled) { + m_validationState = NotChecked; + } else { + m_validationState = Invalid; + emit validateFailed(message); + } + } +} + +void NXMAccessManager::onValidatorAttemptFinished(const ValidationAttempt& a) +{ + if (!m_ProgressDialog) { + switch (a.result()) { + case ValidationAttempt::SoftError: + case ValidationAttempt::HardError: { + startProgress(); + break; + } + + case ValidationAttempt::None: + case ValidationAttempt::Success: + case ValidationAttempt::Cancelled: + default: { + // don't show the dialog + break; + } + } + } +} + +bool NXMAccessManager::validated() const +{ + if (m_validationState == Valid) { + return true; + } + + if (m_validator.isActive()) { + const_cast(this)->startProgress(); + } + + return false; +} + +void NXMAccessManager::refuseValidation() +{ + m_validationState = Invalid; +} + +bool NXMAccessManager::validateAttempted() const +{ + return (m_validationState != NotChecked); +} + +bool NXMAccessManager::validateWaiting() const +{ + return m_validator.isActive(); +} + +void NXMAccessManager::apiCheck(const QString& apiKey, bool force) +{ + if (m_validator.isActive()) { + return; + } + + if (m_Settings && m_Settings->network().offlineMode()) { + m_validationState = NotChecked; + return; + } + + if (force) { + m_validationState = NotChecked; + } + + if (m_validationState == Valid) { + emit validateSuccessful(false); + return; + } + + startValidationCheck(apiKey); +} + +const QString& NXMAccessManager::MOVersion() const +{ + return m_MOVersion; +} + +QString NXMAccessManager::userAgent(const QString& subModule) const +{ + QStringList comments; + QString const os; + if (QSysInfo::productType() == "windows") + comments << ((QSysInfo::kernelType() == "winnt") ? "Windows_NT " : "Windows ") + + QSysInfo::kernelVersion(); + else + comments << QSysInfo::kernelType().left(1).toUpper() + QSysInfo::kernelType().mid(1) + << QSysInfo::productType().left(1).toUpper() + + QSysInfo::kernelType().mid(1) + " " + QSysInfo::productVersion(); + if (!subModule.isEmpty()) { + comments << "module: " + subModule; + } + comments << ((QSysInfo::buildCpuArchitecture() == "x86_64") ? "x64" : "x86"); + + return QString("Mod Organizer/%1 (%2) Qt/%3") + .arg(m_MOVersion, comments.join("; "), qVersion()); +} + +void NXMAccessManager::clearApiKey() +{ + m_validator.cancel(); + emit credentialsReceived(APIUserAccount()); +} + +void NXMAccessManager::startProgress() +{ + if (!m_ProgressDialog) { + m_ProgressDialog.reset(new ValidationProgressDialog(m_Settings, m_validator)); + } + + m_ProgressDialog->start(); +} + +void NXMAccessManager::stopProgress() +{ + if (m_ProgressDialog) { + m_ProgressDialog->stop(); + } +} diff --git a/src/src/nxmhandler_linux.cpp b/src/src/nxmhandler_linux.cpp index 8350c1b..3c9ef2a 100644 --- a/src/src/nxmhandler_linux.cpp +++ b/src/src/nxmhandler_linux.cpp @@ -138,7 +138,7 @@ std::optional NxmLink::parse(const QString& url) const uint64_t expires = query.queryItemValue("expires").toULongLong(); const int userId = query.queryItemValue("user_id").toInt(); - return NxmLink{gameDomain, modId, fileId, key, expires, userId}; + return NxmLink{.game_domain=gameDomain, .mod_id=modId, .file_id=fileId, .key=key, .expires=expires, .user_id=userId}; } QString NxmLink::lookupKey() const @@ -172,7 +172,7 @@ QString NxmHandlerLinux::socketPath() return QDir::homePath() + "/.local/share/fluorine/tmp/mo2-nxm.sock"; } -void NxmHandlerLinux::registerHandler() +void NxmHandlerLinux::registerHandler() { const QString home = QDir::homePath(); if (home.isEmpty()) { diff --git a/src/src/organizer_en.ts b/src/src/organizer_en.ts index b4d8529..0575bee 100644 --- a/src/src/organizer_en.ts +++ b/src/src/organizer_en.ts @@ -2393,12 +2393,12 @@ This is likely due to a corrupted or incompatible download or unrecognized archi - 7z.dll not found + 7z.so not found - 7z.dll isn't valid + 7z.so isn't valid @@ -7300,7 +7300,7 @@ Destination: - invalid 7-zip32.dll: %1 + invalid 7z.so: %1 diff --git a/src/src/organizercore.cpp b/src/src/organizercore.cpp index 2f05857..1897ea3 100644 --- a/src/src/organizercore.cpp +++ b/src/src/organizercore.cpp @@ -1,3004 +1,3004 @@ -#include "organizercore.h" -#include "categoriesdialog.h" -#include "credentialsdialog.h" -#include "fluorineupdater.h" -#include "delayedfilewriter.h" -#include "directoryrefresher.h" -#include "env.h" -#include "envfs.h" -#include "envmodule.h" -#include "filedialogmemory.h" -#include "guessedvalue.h" -#include "imodinterface.h" -#include "imoinfo.h" -#include "instancemanager.h" -#include "iplugingame.h" -#include "iuserinterface.h" -#include "messagedialog.h" -#include "modlistsortproxy.h" -#include "modrepositoryfileinfo.h" -#include "nexusinterface.h" -#include "nxmaccessmanager.h" -#include "plugincontainer.h" -#include "previewdialog.h" -#include "profile.h" -#include "shared/appconfig.h" -#include "shared/directoryentry.h" -#include "shared/fileentry.h" -#include "shared/filesorigin.h" -#include "shared/util.h" -#include "spawn.h" -#include "syncoverwritedialog.h" -#include "virtualfiletree.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include "fluorineconfig.h" -#include "protonlauncher.h" -#include "wineprefix.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include // for qUtf8Printable, etc - -#include -#include -#include // for memset, wcsrchr - -#include -#include -#include -#include -#include -#include //for wstring -#include -#include -#include - -#include - -#include "organizerproxy.h" - -using namespace MOShared; -using namespace MOBase; - -static env::CoreDumpTypes g_coreDumpType = env::CoreDumpTypes::Mini; - -template -QStringList toStringList(InputIterator current, InputIterator end) -{ - QStringList result; - for (; current != end; ++current) { - result.append(*current); - } - return result; -} - -QString resolveWinePrefixPath(const Settings& settings, - const IPluginGame* managedGame) -{ - Q_UNUSED(managedGame); - - if (auto cfg = FluorineConfig::load(); cfg.has_value() && cfg->prefixExists()) { - return cfg->prefix_path.trimmed(); - } - - // Same precedence rule as spawn.cpp's resolvePrefixPath: explicit - // fluorine/prefix_path wins over the legacy Settings/* keys, which may - // have been auto-populated with an external manager's prefix (Heroic, - // Bottles). Without this, switching instances or rebuilding the Fluorine - // config can silently drop us onto the wrong prefix (issue #52). - const QSettings instanceSettings(settings.filename(), QSettings::IniFormat); - const QString explicitPath = - instanceSettings.value("fluorine/prefix_path").toString().trimmed(); - if (!explicitPath.isEmpty()) { - return explicitPath; - } - for (const auto& key : {"Settings/proton_prefix_path", "Settings/prefix_path", - "Proton/prefix_path"}) { - const QString value = instanceSettings.value(key).toString().trimmed(); - if (!value.isEmpty()) { - return value; - } - } - - return {}; -} - -QString resolveWineDataDirName(const IPluginGame* managedGame) -{ - if (managedGame == nullptr) { - return {}; - } - - // Primary: the My Games subfolder name matches the AppData/Local folder - // for almost every Bethesda game. - const QDir docsDir = managedGame->documentsDirectory(); - if (docsDir.exists()) { - const QString docsLeaf = docsDir.dirName().trimmed(); - if (!docsLeaf.isEmpty() && docsLeaf != QStringLiteral(".")) { - return docsLeaf; - } - } - - // Fallback: gameShortName is used by the base Gamebryo mappings() for - // the AppData/Local folder and matches for most games. - const QString shortName = managedGame->gameShortName(); - if (!shortName.isEmpty()) { - log::warn("resolveWineDataDirName: documentsDirectory() is empty or " - "invalid, falling back to gameShortName '{}'", - shortName); - return shortName; - } - - log::warn("resolveWineDataDirName: both documentsDirectory() and " - "gameShortName() are empty, falling back to gameName '{}'", - managedGame->gameName()); - return managedGame->gameName(); -} - -QString resolvePrefixGameDocumentsDir(const WinePrefix& prefix, - const QString& dataDirName) -{ - return QDir(prefix.myGamesPath()).filePath(dataDirName); -} - -QString resolveAbsoluteSaveDir(const WinePrefix& prefix, - const IPluginGame* managedGame, - MOBase::LocalSavegames* localSaves, - std::shared_ptr profile) -{ - if (profile == nullptr || managedGame == nullptr) { - const QString dataDirName = resolveWineDataDirName(managedGame); - return QDir(prefix.myGamesPath()).filePath(dataDirName + "/Saves"); - } - - const QString profileSaveDir = - QDir(profile->absolutePath()).filePath("saves"); - - // Strategy 1: Use LocalSavegames::mappings() if available. - // Extract the user-relative portion of the destination (after drive_c/users//) - // and reconstruct it under our prefix's userProfilePath(). - if (localSaves != nullptr) { - const MappingType mappings = localSaves->mappings(QDir(profileSaveDir)); - for (const auto& mapping : mappings) { - if (!mapping.isDirectory) { - continue; - } - - const QString source = QDir::cleanPath(mapping.source); - const QString destination = QDir::cleanPath(mapping.destination); - if (source != QDir::cleanPath(profileSaveDir)) { - continue; - } - - // Extract the user-relative path (after drive_c/users//) - static const QRegularExpression userDirRe( - "drive_c/users/[^/]+/(.+)", QRegularExpression::CaseInsensitiveOption); - const auto match = userDirRe.match(destination); - if (match.hasMatch()) { - const QString userRelative = match.captured(1); - const QString resolved = - QDir(prefix.userProfilePath()).filePath(userRelative); - log::debug("resolveAbsoluteSaveDir: from mappings -> '{}'", resolved); - return resolved; - } - } - } - - // Strategy 2: Use managedGame->savesDirectory() - { - const QDir savesDir = managedGame->savesDirectory(); - const QString savesPath = QDir::cleanPath(savesDir.absolutePath()); - static const QRegularExpression userDirRe( - "drive_c/users/[^/]+/(.+)", QRegularExpression::CaseInsensitiveOption); - const auto match = userDirRe.match(savesPath); - if (match.hasMatch()) { - const QString userRelative = match.captured(1); - const QString resolved = - QDir(prefix.userProfilePath()).filePath(userRelative); - log::debug("resolveAbsoluteSaveDir: from savesDirectory -> '{}'", resolved); - return resolved; - } - } - - // Fallback: Documents/My Games//Saves (old Bethesda behavior) - const QString dataDirName = resolveWineDataDirName(managedGame); - const QString fallback = - QDir(prefix.myGamesPath()).filePath(dataDirName + "/Saves"); - log::debug("resolveAbsoluteSaveDir: fallback -> '{}'", fallback); - return fallback; -} - -OrganizerCore::OrganizerCore(Settings& settings) - : - m_CurrentProfile(nullptr), m_Settings(settings), - m_Updater(&NexusInterface::instance()), m_ModList(m_PluginContainer, this), - m_PluginList(*this), - m_DirectoryRefresher(new DirectoryRefresher(this, settings.refreshThreadCount())), - m_DirectoryStructure(new DirectoryEntry(L"data", nullptr, 0)), - m_VirtualFileTree([this]() { - return VirtualFileTree::makeTree(m_DirectoryStructure); - }), - m_DownloadManager(&NexusInterface::instance(), this), m_DirectoryUpdate(false), - - m_PluginListsWriter(std::bind(&OrganizerCore::savePluginList, this)) -{ - env::setHandleCloserThreadCount(settings.refreshThreadCount()); - m_DownloadManager.setOutputDirectory(m_Settings.paths().downloads(), false); - - NexusInterface::instance().setCacheDirectory(m_Settings.paths().cache()); - - m_InstallationManager.setModsDirectory(m_Settings.paths().mods()); - m_InstallationManager.setDownloadDirectory(m_Settings.paths().downloads()); - - connect(&m_DownloadManager, SIGNAL(downloadSpeed(QString, int)), this, - SLOT(downloadSpeed(QString, int))); - connect(m_DirectoryRefresher.get(), &DirectoryRefresher::refreshed, this, - &OrganizerCore::onDirectoryRefreshed); - - connect(&m_ModList, SIGNAL(removeOrigin(QString)), this, SLOT(removeOrigin(QString))); - connect(&m_ModList, &ModList::modStatesChanged, [=, this] { - currentProfile()->writeModlist(); - }); - connect(&m_ModList, &ModList::modPrioritiesChanged, [this](auto&& indexes) { - modPrioritiesChanged(indexes); - }); - - connect(NexusInterface::instance().getAccessManager(), - SIGNAL(validateSuccessful(bool)), this, SLOT(loginSuccessful(bool))); - connect(NexusInterface::instance().getAccessManager(), - SIGNAL(validateFailed(QString)), this, SLOT(loginFailed(QString))); - - // This seems awfully imperative - connect(this, SIGNAL(managedGameChanged(MOBase::IPluginGame const*)), &m_Settings, - SLOT(managedGameChanged(MOBase::IPluginGame const*))); - connect(this, SIGNAL(managedGameChanged(MOBase::IPluginGame const*)), - &m_DownloadManager, SLOT(managedGameChanged(MOBase::IPluginGame const*))); - connect(this, SIGNAL(managedGameChanged(MOBase::IPluginGame const*)), &m_PluginList, - SLOT(managedGameChanged(MOBase::IPluginGame const*))); - - connect(&m_PluginList, &PluginList::writePluginsList, &m_PluginListsWriter, - &DelayedFileWriterBase::write); - - // make directory refresher run in a separate thread - m_RefresherThread.start(); - m_DirectoryRefresher->moveToThread(&m_RefresherThread); - - connect(&settings.plugins(), &PluginSettings::pluginSettingChanged, - [this](auto const&... args) { - m_PluginSettingChanged(args...); - }); -} - -OrganizerCore::~OrganizerCore() -{ - m_RefresherThread.exit(); - m_RefresherThread.wait(); - - if (m_StructureDeleter.joinable()) { - m_StructureDeleter.join(); - } - - saveCurrentProfile(); - - // profile has to be cleaned up before the modinfo-buffer is cleared - m_CurrentProfile.reset(); - - ModInfo::clear(); - m_ModList.setProfile(nullptr); - // NexusInterface::instance()->cleanup(); - - delete m_DirectoryStructure; -} - -void OrganizerCore::storeSettings() -{ - if (m_CurrentProfile != nullptr) { - m_Settings.game().setSelectedProfileName(m_CurrentProfile->name()); - } - - m_ExecutablesList.store(m_Settings); - - FileDialogMemory::save(m_Settings); - - const auto result = m_Settings.sync(); - - if (result != QSettings::NoError) { - QString reason; - - if (result == QSettings::AccessError) { - reason = tr("File is write protected"); - } else if (result == QSettings::FormatError) { - reason = tr("Invalid file format (probably a bug)"); - } else { - reason = tr("Unknown error %1").arg(result); - } - - QMessageBox::critical( - qApp->activeWindow(), tr("Failed to write settings"), - tr("An error occurred trying to write back MO settings to %1: %2") - .arg(m_Settings.filename(), reason)); - } -} - -void OrganizerCore::updateExecutablesList() -{ - if (m_PluginContainer == nullptr) { - log::error("can't update executables list now"); - return; - } - - m_ExecutablesList.load(managedGame(), m_Settings); -} - -void OrganizerCore::updateModInfoFromDisc() -{ - const QString modsPath = m_Settings.paths().mods(); - log::debug("updateModInfoFromDisc: base='{}', mods='{}'", - m_Settings.paths().base(), modsPath); - ModInfo::updateFromDisc(modsPath, *this, - m_Settings.interface().displayForeign(), - m_Settings.refreshThreadCount()); -} - -void OrganizerCore::setUserInterface(IUserInterface* ui) -{ - storeSettings(); - - m_UserInterface = ui; - - QWidget* w = nullptr; - if (m_UserInterface) { - w = m_UserInterface->mainWindow(); - } - - m_InstallationManager.setParentWidget(w); - m_Updater.setUserInterface(w); - m_UILocker.setUserInterface(w); - m_DownloadManager.setParentWidget(w); - - checkForUpdates(); -} - -void OrganizerCore::checkForUpdates() -{ - // this currently wouldn't work reliably if the ui isn't initialized yet to - // display the result - if (m_UserInterface != nullptr) { - m_Updater.testForUpdate(m_Settings); - checkForFluorineUpdates(); - } -} - -void OrganizerCore::checkForFluorineUpdates() -{ - // Set up the Fluorine self-update checker lazily so repeated calls don't - // leak QNetworkAccessManager instances. The member is forward-declared in - // the header (pointer-only); the include lives here to keep the header - // lightweight for its many consumers. - if (m_FluorineUpdater == nullptr) { - m_FluorineUpdater = new FluorineUpdater(this); - - connect(m_FluorineUpdater, &FluorineUpdater::updateAvailable, this, - [](const FluorineUpdater::ReleaseInfo& info) { - const QString channel = - FluorineUpdater::channelToString(info.channel); - MOBase::log::info( - "Fluorine update available ({}): {} at {}", - channel, - info.tagName.isEmpty() ? info.name : info.tagName, - info.htmlUrl); - }); - connect(m_FluorineUpdater, &FluorineUpdater::upToDate, this, - [](const FluorineUpdater::ReleaseInfo& info) { - MOBase::log::debug("Fluorine is up to date ({})", - FluorineUpdater::channelToString(info.channel)); - }); - connect(m_FluorineUpdater, &FluorineUpdater::checkFailed, this, - [](const QString& reason) { - MOBase::log::debug("Fluorine update check failed: {}", reason); - }); - } - - const FluorineUpdater::Channel channel = FluorineUpdater::channelFromString( - m_Settings.fluorineUpdateChannel(), FluorineUpdater::buildChannel()); - m_FluorineUpdater->checkForUpdates(channel); -} - -void OrganizerCore::connectPlugins(PluginContainer* container) -{ - m_PluginContainer = container; - m_Updater.setPluginContainer(m_PluginContainer); - m_InstallationManager.setPluginContainer(m_PluginContainer); - m_DownloadManager.setPluginContainer(m_PluginContainer); - m_ModList.setPluginContainer(m_PluginContainer); - - if (!m_GameName.isEmpty()) { - m_GamePlugin = m_PluginContainer->game(m_GameName); - emit managedGameChanged(m_GamePlugin); - } - - connect(m_PluginContainer, &PluginContainer::pluginEnabled, [&](IPlugin* plugin) { - m_PluginEnabled(plugin); - }); - connect(m_PluginContainer, &PluginContainer::pluginDisabled, [&](IPlugin* plugin) { - m_PluginDisabled(plugin); - }); - - connect(&m_PluginContainer->gameFeatures(), &GameFeatures::modDataContentUpdated, - [this](ModDataContent const* contentFeature) { - if (contentFeature) { - m_Contents = ModDataContentHolder(contentFeature->getAllContents()); - } else { - m_Contents = ModDataContentHolder(); - } - }); -} - -void OrganizerCore::setManagedGame(MOBase::IPluginGame* game) -{ - m_GameName = game->gameName(); - m_GamePlugin = game; - qApp->setProperty("managed_game", QVariant::fromValue(m_GamePlugin)); - emit managedGameChanged(m_GamePlugin); -} - -Settings& OrganizerCore::settings() -{ - return m_Settings; -} - -bool OrganizerCore::nexusApi(bool retry) -{ - auto* accessManager = NexusInterface::instance().getAccessManager(); - - if ((accessManager->validateAttempted() || accessManager->validated()) && !retry) { - // previous attempt, maybe even successful - return false; - } else { - QString apiKey; - if (GlobalSettings::nexusApiKey(apiKey)) { - // credentials stored or user entered them manually - log::debug("attempt to verify nexus api key"); - accessManager->apiCheck(apiKey); - return true; - } else { - // no credentials stored and user didn't enter them - accessManager->refuseValidation(); - return false; - } - } -} - -void OrganizerCore::startMOUpdate() -{ - if (nexusApi()) { - m_PostLoginTasks.append([&]() { - m_Updater.startUpdate(); - }); - } else { - m_Updater.startUpdate(); - } -} - -void OrganizerCore::downloadRequestedNXM(const QString& url) -{ - log::debug("download requested: {}", url); - if (nexusApi()) { - m_PendingDownloads.append(url); - } else { - m_DownloadManager.addNXMDownload(url); - } -} - -void OrganizerCore::userInterfaceInitialized() -{ - m_UserInterfaceInitialized(m_UserInterface->mainWindow()); -} - -void OrganizerCore::profileCreated(MOBase::IProfile* profile) -{ - m_ProfileCreated(profile); -} - -void OrganizerCore::profileRenamed(MOBase::IProfile* profile, QString const& oldName, - QString const& newName) -{ - m_ProfileRenamed(profile, oldName, newName); -} - -void OrganizerCore::profileRemoved(QString const& profileName) -{ - m_ProfileRemoved(profileName); -} - -void OrganizerCore::downloadRequested(QNetworkReply* reply, QString gameName, int modID, - const QString& fileName) -{ - try { - if (m_DownloadManager.addDownload(reply, QStringList(), fileName, gameName, modID, - 0, new ModRepositoryFileInfo(gameName, modID))) { - MessageDialog::showMessage(tr("Download started"), qApp->activeWindow()); - } - } catch (const std::exception& e) { - MessageDialog::showMessage(tr("Download failed"), qApp->activeWindow()); - log::error("exception starting download: {}", e.what()); - } -} - -void OrganizerCore::removeOrigin(const QString& name) -{ - const auto wname = ToWString(name); - if (m_DirectoryStructure->originExists(wname)) { - FilesOrigin& origin = m_DirectoryStructure->getOriginByName(wname); - origin.enable(false); - } - refreshLists(); -} - -void OrganizerCore::downloadSpeed(const QString& serverName, int bytesPerSecond) -{ - m_Settings.network().setDownloadSpeed(serverName, bytesPerSecond); -} - -InstallationManager* OrganizerCore::installationManager() -{ - return &m_InstallationManager; -} - -bool OrganizerCore::createDirectory(const QString& path) -{ - if (!QDir(path).exists() && !QDir().mkpath(path)) { - QMessageBox::critical(nullptr, QObject::tr("Error"), - QObject::tr("Failed to create \"%1\". Your user " - "account probably lacks permission.") - .arg(QDir::toNativeSeparators(path))); - return false; - } else { - return true; - } -} - -bool OrganizerCore::checkPathSymlinks() -{ - const bool hasSymlink = (QFileInfo(m_Settings.paths().profiles()).isSymLink() || - QFileInfo(m_Settings.paths().mods()).isSymLink() || - QFileInfo(m_Settings.paths().overwrite()).isSymLink()); - - if (hasSymlink) { - log::warn( - "{}", - QObject::tr( - "One of the configured MO2 directories (profiles, mods, or overwrite) " - "is on a path containing a symbolic (or other) link. This is likely to " - "be incompatible with MO2's virtual filesystem.")); - - return false; - } - - return true; -} - -bool OrganizerCore::bootstrap() -{ - const auto dirs = {m_Settings.paths().profiles(), m_Settings.paths().mods(), - m_Settings.paths().downloads(), m_Settings.paths().overwrite(), - QString::fromStdWString(getGlobalCoreDumpPath())}; - - for (auto&& dir : dirs) { - if (!createDirectory(dir)) { - return false; - } - } - - if (!checkPathSymlinks()) { - return false; - } - - if (!cycleDiagnostics()) { - return false; - } - - // log if there are any dmp files - const auto hasCrashDumps = !QDir(QString::fromStdWString(getGlobalCoreDumpPath())) - .entryList({"*.dmp"}, QDir::Files) - .empty(); - - if (hasCrashDumps) { - log::debug("there are crash dumps in '{}'", - QString::fromStdWString(getGlobalCoreDumpPath())); - } - - return true; -} - -void OrganizerCore::createDefaultProfile() -{ - QString const profilesPath = settings().paths().profiles(); - if (QDir(profilesPath).entryList(QDir::AllDirs | QDir::NoDotAndDotDot).empty()) { - Profile newProf(QString::fromStdWString(AppConfig::defaultProfileName()), - managedGame(), gameFeatures(), false); - - m_ProfileCreated(&newProf); - } -} - -void OrganizerCore::createOverwriteDirectories() -{ - QString const overwritePath = settings().paths().overwrite(); - for (const auto& modDirectory : managedGame()->getModMappings().keys()) { - if (!modDirectory.isEmpty()) { - QDir(overwritePath).mkdir(modDirectory); - } - } -} - -void OrganizerCore::prepareVFS() -{ - // Read the load order and pass it to the FUSE VFS so plugin files get - // incrementing timestamps matching their position. This prevents LOOT - // from reporting "ambiguous load order". - { - std::vector loadOrder; - QFile loFile(m_CurrentProfile->getLoadOrderFileName()); - if (!loFile.exists()) { - loFile.setFileName(m_CurrentProfile->getPluginsFileName()); - } - if (loFile.open(QIODevice::ReadOnly | QIODevice::Text)) { - QTextStream in(&loFile); - while (!in.atEnd()) { - QString line = in.readLine().trimmed(); - if (line.isEmpty() || line.startsWith('#')) { - continue; - } - if (line.startsWith('*')) { - line = line.mid(1); - } - loadOrder.push_back(line.toStdString()); - } - } - m_USVFS.setPluginLoadOrder(loadOrder); - } - - // Set up tracked writes file (per-profile, next to the overwrite folder) - { - QString const owPath = settings().paths().overwrite(); - QDir const owDir(owPath); - QString trackPath = owDir.absoluteFilePath("../tracked_writes.json"); - trackPath = QDir::cleanPath(trackPath); - std::fprintf(stderr, "[VFS] prepareVFS: owPath='%s' trackPath='%s'\n", - owPath.toStdString().c_str(), trackPath.toStdString().c_str()); - m_USVFS.setTrackingFilePath(trackPath.toStdString()); - } - - // VFS Root Builder: read per-instance setting and configure. - { - bool vfsRootBuilder = false; - if (const auto* s = Settings::maybeInstance()) { - const QSettings instanceIni(s->filename(), QSettings::IniFormat); - vfsRootBuilder = instanceIni.value("fluorine/vfs_root_builder", true).toBool(); - } - const QString storageDir = - QDir(QDir::fromNativeSeparators(basePath())).filePath("rootbuilder"); - m_USVFS.setRootBuilderEnabled(vfsRootBuilder, storageDir.toStdString()); - } - - m_USVFS.updateMapping(fileMapping(m_CurrentProfile->name(), QString())); -} - -void OrganizerCore::unmountVFS() -{ - m_USVFS.unmount(); -} - -void OrganizerCore::trackOverwriteMove(const QString& relativePath, - const QString& modFolderPath) -{ - auto tw = m_USVFS.trackedWrites(); - if (tw) { - tw->track(relativePath.toStdString(), modFolderPath.toStdString()); - } -} - -void OrganizerCore::discardVFSStagingOnUnmount() -{ - m_USVFS.discardStagingOnUnmount(); -} - -void OrganizerCore::updateVFSParams(log::Levels logLevel, - env::CoreDumpTypes coreDumpType, - const QString& crashDumpsPath, - std::chrono::seconds spawnDelay, - QString executableBlacklist, - const QStringList& skipFileSuffixes, - const QStringList& skipDirectories) -{ - setGlobalCoreDumpType(coreDumpType); - - m_USVFS.updateParams(logLevel, coreDumpType, crashDumpsPath, spawnDelay, - executableBlacklist, skipFileSuffixes, skipDirectories); -} - -void OrganizerCore::setLogLevel(log::Levels level) -{ - m_Settings.diagnostics().setLogLevel(level); - - updateVFSParams( - m_Settings.diagnostics().logLevel(), m_Settings.diagnostics().coreDumpType(), - QString::fromStdWString(getGlobalCoreDumpPath()), - m_Settings.diagnostics().spawnDelay(), m_Settings.executablesBlacklist(), - m_Settings.skipFileSuffixes(), m_Settings.skipDirectories()); - - log::getDefault().setLevel(m_Settings.diagnostics().logLevel()); -} - -bool OrganizerCore::cycleDiagnostics() -{ - const auto maxDumps = settings().diagnostics().maxCoreDumps(); - const auto path = QString::fromStdWString(getGlobalCoreDumpPath()); - - if (maxDumps > 0) { - removeOldFiles(path, "*.dmp", maxDumps, QDir::Time | QDir::Reversed); - } - - return true; -} - -env::CoreDumpTypes OrganizerCore::getGlobalCoreDumpType() -{ - return g_coreDumpType; -} - -void OrganizerCore::setGlobalCoreDumpType(env::CoreDumpTypes type) -{ - g_coreDumpType = type; -} - -std::wstring OrganizerCore::getGlobalCoreDumpPath() -{ - if (qApp) { - const auto dp = qApp->property("dataPath"); - if (!dp.isNull()) { - return dp.toString().toStdWString() + L"/" + AppConfig::dumpsDir(); - } - } - - return {}; -} - -void OrganizerCore::setCurrentProfile(const QString& profileName) -{ - if ((m_CurrentProfile != nullptr) && (profileName == m_CurrentProfile->name())) { - return; - } - - log::debug("selecting profile '{}'", profileName); - - QDir const profileBaseDir(settings().paths().profiles()); - - const auto subdirs = profileBaseDir.entryList(QDir::AllDirs | QDir::NoDotAndDotDot); - - QString profileDir; - - // the profile name may not have the correct case, which breaks other parts - // of the ui like the profile combobox, which walks directories on its own - // - // find the real name with the correct case by walking the directories - for (auto&& dirName : subdirs) { - if (QString::compare(dirName, profileName, Qt::CaseInsensitive) == 0) { - profileDir = profileBaseDir.absoluteFilePath(dirName); - break; - } - } - - if (profileDir.isEmpty()) { - log::error("profile '{}' does not exist", profileName); - - // selected profile doesn't exist. Ensure there is at least one profile, - // then pick any one - createDefaultProfile(); - - profileDir = profileBaseDir.absoluteFilePath( - profileBaseDir.entryList(QDir::AllDirs | QDir::NoDotAndDotDot).at(0)); - - log::error("picked profile '{}' instead", QDir(profileDir).dirName()); - - reportError(tr("The selected profile '%1' does not exist. The profile '%2' will be " - "used instead") - .arg(profileName) - .arg(QDir(profileDir).dirName())); - } - - // Keep the old profile to emit signal-changed: - auto oldProfile = std::move(m_CurrentProfile); - - m_CurrentProfile = - std::make_unique(QDir(profileDir), managedGame(), gameFeatures()); - - m_ModList.setProfile(m_CurrentProfile.get()); - - if (m_CurrentProfile->invalidationActive(nullptr)) { - m_CurrentProfile->activateInvalidation(); - } else { - m_CurrentProfile->deactivateInvalidation(); - } - - m_Settings.game().setSelectedProfileName(m_CurrentProfile->name()); - - connect(m_CurrentProfile.get(), qOverload(&Profile::modStatusChanged), - [this](auto&& index) { - modStatusChanged(index); - }); - connect(m_CurrentProfile.get(), qOverload>(&Profile::modStatusChanged), - [this](auto&& indexes) { - modStatusChanged(indexes); - }); - refreshDirectoryStructure(); - - m_CurrentProfile->debugDump(); - - emit profileChanged(oldProfile.get(), m_CurrentProfile.get()); - m_ProfileChanged(oldProfile.get(), m_CurrentProfile.get()); -} - -QStringList OrganizerCore::profileNames() const -{ - QDir const profilesDir(m_Settings.paths().profiles()); - return profilesDir.entryList(QDir::AllDirs | QDir::NoDotAndDotDot); -} - -std::shared_ptr -OrganizerCore::getProfile(const QString& profileName) const -{ - QDir profileDir(m_Settings.paths().profiles()); - profileDir.cd(profileName); - if (!profileDir.exists()) { - return nullptr; - } - - return std::make_shared(profileDir, managedGame(), gameFeatures()); -} - -MOBase::IModRepositoryBridge* OrganizerCore::createNexusBridge() const -{ - return new NexusBridge(m_PluginContainer); -} - -QString OrganizerCore::profileName() const -{ - if (m_CurrentProfile != nullptr) { - return m_CurrentProfile->name(); - } else { - return ""; - } -} - -QString OrganizerCore::profilePath() const -{ - if (m_CurrentProfile != nullptr) { - return m_CurrentProfile->absolutePath(); - } else { - return ""; - } -} - -QString OrganizerCore::downloadsPath() const -{ - return QDir::fromNativeSeparators(m_Settings.paths().downloads()); -} - -QString OrganizerCore::overwritePath() const -{ - return QDir::fromNativeSeparators(m_Settings.paths().overwrite()); -} - -QString OrganizerCore::basePath() const -{ - return QDir::fromNativeSeparators(m_Settings.paths().base()); -} - -QString OrganizerCore::modsPath() const -{ - return QDir::fromNativeSeparators(m_Settings.paths().mods()); -} - -MOBase::Version OrganizerCore::version() const -{ - return m_Updater.getVersion(); -} - -MOBase::IPluginGame* OrganizerCore::getGame(const QString& name) const -{ - for (IPluginGame* game : m_PluginContainer->plugins()) { - if (game != nullptr && - game->gameShortName().compare(name, Qt::CaseInsensitive) == 0) - return game; - } - return nullptr; -} - -MOBase::IModInterface* OrganizerCore::createMod(GuessedValue& name) -{ - auto result = m_InstallationManager.testOverwrite(name); - if (!result) { - return nullptr; - } - - m_InstallationManager.setModsDirectory(m_Settings.paths().mods()); - - QString const targetDirectory = - QDir::fromNativeSeparators(m_Settings.paths().mods()).append("/").append(name); - - QSettings settingsFile(targetDirectory + "/meta.ini", QSettings::IniFormat); - - if (!result.merged()) { - settingsFile.setValue("modid", 0); - settingsFile.setValue("version", ""); - settingsFile.setValue("newestVersion", ""); - settingsFile.setValue("category", 0); - settingsFile.setValue("installationFile", ""); - - settingsFile.remove("installedFiles"); - settingsFile.beginWriteArray("installedFiles", 0); - settingsFile.endArray(); - } - - // shouldn't this use the existing mod in case of a merge? also, this does not refresh - // the indices in the ModInfo structure - return ModInfo::createFrom(QDir(targetDirectory), *this).data(); -} - -void OrganizerCore::modDataChanged(MOBase::IModInterface*) -{ - refresh(false); -} - -QVariant OrganizerCore::pluginSetting(const QString& pluginName, - const QString& key) const -{ - return m_Settings.plugins().setting(pluginName, key); -} - -void OrganizerCore::setPluginSetting(const QString& pluginName, const QString& key, - const QVariant& value) -{ - m_Settings.plugins().setSetting(pluginName, key, value); -} - -QVariant OrganizerCore::persistent(const QString& pluginName, const QString& key, - const QVariant& def) const -{ - return m_Settings.plugins().persistent(pluginName, key, def); -} - -void OrganizerCore::setPersistent(const QString& pluginName, const QString& key, - const QVariant& value, bool sync) -{ - m_Settings.plugins().setPersistent(pluginName, key, value, sync); -} - -QString OrganizerCore::pluginDataPath() -{ - // The plugins/ directory may contain symlinks into a read-only bundled - // directory (e.g. /app/ in Flatpak). Place plugin data in a separate - // writable directory so mkdir() never hits a read-only filesystem. - return AppConfig::basePath() + "/plugin_data"; -} - -MOBase::IModInterface* OrganizerCore::installMod(const QString& archivePath, - int priority, bool reinstallation, - ModInfo::Ptr currentMod, - const QString& initModName) -{ - return installArchive(archivePath, reinstallation ? -1 : priority, reinstallation, - currentMod, initModName) - .get(); -} - -std::pair -OrganizerCore::doInstall(const QString& archivePath, GuessedValue modName, - ModInfo::Ptr currentMod, int priority, bool reinstallation) -{ - if (m_CurrentProfile == nullptr) { - return {-1, nullptr}; - } - - if (m_InstallationManager.isRunning()) { - QMessageBox::information(qApp->activeWindow(), tr("Installation cancelled"), - tr("Another installation is currently in progress."), - QMessageBox::Ok); - return {-1, nullptr}; - } - - bool const hasIniTweaks = false; - m_CurrentProfile->writeModlistNow(); - m_InstallationManager.setModsDirectory(m_Settings.paths().mods()); - m_InstallationManager.notifyInstallationStart(archivePath, reinstallation, - currentMod); - auto result = m_InstallationManager.install(archivePath, modName, hasIniTweaks); - - if (result) { - MessageDialog::showMessage(tr("Installation successful"), qApp->activeWindow()); - - // we wait for the directory structure to be ready before notifying the mod list, - // this prevents issue with third-party plugins, e.g., if the installed mod is - // activated before the structure is ready - // - // we need to fetch modIndex() within the call back because the index is only - // valid after the call to refresh(), but we do not want to connect after - // refresh() - // - connect( - this, &OrganizerCore::directoryStructureReady, this, - [=, this] { - const int modIndex = ModInfo::getIndex(modName); - if (modIndex != UINT_MAX) { - const auto modInfo = ModInfo::getByIndex(modIndex); - m_ModList.notifyModInstalled(modInfo.get()); - } - }, - Qt::SingleShotConnection); - - refresh(); - - const auto modIndex = ModInfo::getIndex(modName); - ModInfo::Ptr modInfo = nullptr; - if (modIndex != UINT_MAX) { - modInfo = ModInfo::getByIndex(modIndex); - - if (priority != -1 && !result.mergedOrReplaced()) { - m_ModList.changeModPriority(modIndex, priority); - } - - if (hasIniTweaks && m_UserInterface != nullptr && - (QMessageBox::question(qApp->activeWindow(), tr("Configure Mod"), - tr("This mod contains ini tweaks. Do you " - "want to configure them now?"), - QMessageBox::Yes | QMessageBox::No) == - QMessageBox::Yes)) { - m_UserInterface->displayModInformation(modInfo, modIndex, - ModInfoTabIDs::IniFiles); - } - - m_InstallationManager.notifyInstallationEnd(result, modInfo); - } else { - reportError(tr("mod not found: %1").arg(qUtf8Printable(modName))); - } - emit modInstalled(modName); - return {modIndex, modInfo}; - } else { - if (result.result() == MOBase::IPluginInstaller::RESULT_CATEGORYREQUESTED) { - CategoriesDialog dialog(qApp->activeWindow()); - - if (dialog.exec() == QDialog::Accepted) { - dialog.commitChanges(); - refresh(); - } - } else { - m_InstallationManager.notifyInstallationEnd(result, nullptr); - if (m_InstallationManager.wasCancelled()) { - QMessageBox::information( - qApp->activeWindow(), tr("Extraction cancelled"), - tr("The installation was cancelled while extracting files. " - "If this was prior to a FOMOD setup, this warning may be ignored. " - "However, if this was during installation, the mod will likely be " - "missing " - "files."), - QMessageBox::Ok); - refresh(); - } - } - } - - return {-1, nullptr}; -} - -ModInfo::Ptr OrganizerCore::installDownload(int index, int priority) -{ - ScopedDisableDirWatcher const scopedDirwatcher(&m_DownloadManager); - - try { - QString const fileName = m_DownloadManager.getFilePath(index); - QString const gameName = m_DownloadManager.getGameName(index); - int const modID = m_DownloadManager.getModID(index); - int const fileID = m_DownloadManager.getFileInfo(index)->fileID; - ModInfo::Ptr currentMod = nullptr; - GuessedValue modName; - - // see if there already are mods with the specified mod id - if (modID > 0) { - std::vector modInfo = ModInfo::getByModID(gameName, modID); - for (auto iter = modInfo.begin(); iter != modInfo.end(); ++iter) { - std::vector flags = (*iter)->getFlags(); - if (std::find(flags.begin(), flags.end(), ModInfo::FLAG_BACKUP) == - flags.end()) { - modName.update((*iter)->name(), GUESS_PRESET); - currentMod = *iter; - (*iter)->saveMeta(); - } - } - } - - const auto [modIndex, modInfo] = - doInstall(fileName, modName, currentMod, priority, false); - - if (modInfo != nullptr) { - modInfo->addInstalledFile(modID, fileID); - m_DownloadManager.markInstalled(index); - if (settings().interface().hideDownloadsAfterInstallation()) { - m_DownloadManager.removeDownload(index, false); - } - } - - return modInfo; - } catch (const std::exception& e) { - log::error("installDownload exception: type={}, what='{}'", - typeid(e).name(), e.what()); - reportError(tr("Installation failed: %1").arg(e.what())); - } - - return nullptr; -} - -ModInfo::Ptr OrganizerCore::installArchive(const QString& archivePath, int priority, - bool reinstallation, ModInfo::Ptr currentMod, - const QString& initModName) -{ - GuessedValue modName; - if (!initModName.isEmpty()) { - modName.update(initModName, GUESS_USER); - } - const auto [modIndex, modInfo] = - doInstall(archivePath, modName, currentMod, priority, reinstallation); - if (m_CurrentProfile == nullptr) { - return nullptr; - } - - if (modInfo != nullptr) { - auto dlIdx = m_DownloadManager.indexByName(QFileInfo(archivePath).fileName()); - if (dlIdx != -1) { - int const modId = m_DownloadManager.getModID(dlIdx); - int const fileId = m_DownloadManager.getFileInfo(dlIdx)->fileID; - modInfo->addInstalledFile(modId, fileId); - } - m_DownloadManager.markInstalled(archivePath); - } - return modInfo; -} - -QString OrganizerCore::resolvePath(const QString& fileName) const -{ - if (m_DirectoryStructure == nullptr) { - return {}; - } - const FileEntryPtr file = - m_DirectoryStructure->searchFile(ToWString(fileName), nullptr); - if (file.get() != nullptr) { - return ToQString(file->getFullPath()); - } else { - return {}; - } -} - -QStringList OrganizerCore::listDirectories(const QString& directoryName) const -{ - QStringList result; - DirectoryEntry* dir = m_DirectoryStructure; - if (!directoryName.isEmpty()) - dir = dir->findSubDirectoryRecursive(ToWString(directoryName)); - if (dir != nullptr) { - for (const auto& d : dir->getSubDirectories()) { - result.append(ToQString(d->getName())); - } - } - return result; -} - -QStringList -OrganizerCore::findFiles(const QString& path, - const std::function& filter) const -{ - QStringList result; - DirectoryEntry* dir = m_DirectoryStructure; - if (!path.isEmpty() && path != ".") - dir = dir->findSubDirectoryRecursive(ToWString(path)); - if (dir != nullptr) { - std::vector const files = dir->getFiles(); - for (FileEntryPtr const& file : files) { - QString const fullPath = ToQString(file->getFullPath()); - if (filter(ToQString(file->getName()))) { - result.append(fullPath); - } - } - } - return result; -} - -QStringList OrganizerCore::getFileOrigins(const QString& fileName) const -{ - QStringList result; - const FileEntryPtr file = - m_DirectoryStructure->searchFile(ToWString(fileName), nullptr); - - if (file.get() != nullptr) { - result.append( - ToQString(m_DirectoryStructure->getOriginByID(file->getOrigin()).getName())); - foreach (const auto& i, file->getAlternatives()) { - result.append( - ToQString(m_DirectoryStructure->getOriginByID(i.originID()).getName())); - } - } - return result; -} - -QList OrganizerCore::findFileInfos( - const QString& path, - const std::function& filter) const -{ - QList result; - DirectoryEntry* dir = m_DirectoryStructure; - if (!path.isEmpty() && path != ".") - dir = dir->findSubDirectoryRecursive(ToWString(path)); - if (dir != nullptr) { - std::vector const files = dir->getFiles(); - for (const FileEntryPtr& file : files) { - IOrganizer::FileInfo info; - info.filePath = ToQString(file->getFullPath()); - bool fromArchive = false; - info.origins.append(ToQString( - m_DirectoryStructure->getOriginByID(file->getOrigin(fromArchive)).getName())); - info.archive = fromArchive ? ToQString(file->getArchive().name()) : ""; - for (const auto& idx : file->getAlternatives()) { - info.origins.append( - ToQString(m_DirectoryStructure->getOriginByID(idx.originID()).getName())); - } - - if (filter(info)) { - result.append(info); - } - } - } - return result; -} - -DownloadManager* OrganizerCore::downloadManager() -{ - return &m_DownloadManager; -} - -PluginList* OrganizerCore::pluginList() -{ - return &m_PluginList; -} - -ModList* OrganizerCore::modList() -{ - return &m_ModList; -} - -bool OrganizerCore::previewFileWithAlternatives(QWidget* parent, QString fileName, - int selectedOrigin) -{ - fileName = QDir::fromNativeSeparators(fileName); - - // what we have is an absolute path to the file in its actual location (for the - // primary origin) what we want is the path relative to the virtual data directory - - // we need to look in the virtual directory for the file to make sure the info is up - // to date. - - // check if the file comes from the actual data folder instead of a mod - QDir const gameDirectory = managedGame()->dataDirectory().absolutePath(); - QString const relativePath = gameDirectory.relativeFilePath(fileName); - QDir const dirRelativePath = gameDirectory.relativeFilePath(fileName); - - // if the file is on a different drive the dirRelativePath will actually be an - // absolute path so we make sure that is not the case - if (!dirRelativePath.isAbsolute() && !relativePath.startsWith("..")) { - fileName = relativePath; - } else { - // crude: we search for the next slash after the base mod directory to skip - // everything up to the data-relative directory - int offset = settings().paths().mods().size() + 1; - offset = fileName.indexOf("/", offset); - fileName = fileName.mid(offset + 1); - } - - const FileEntryPtr file = - directoryStructure()->searchFile(ToWString(fileName), nullptr); - - if (file.get() == nullptr) { - reportError(tr("file not found: %1").arg(qUtf8Printable(fileName))); - return false; - } - - // Standalone top-level window (no QObject parent) to decouple from the - // enclosing ModInfoDialog stack frame. ApplicationModal stacks it on - // top of any already-open modal dialog. WA_DeleteOnClose cleans up. - auto* preview = new PreviewDialog(fileName, nullptr); - preview->setAttribute(Qt::WA_DeleteOnClose); - preview->setWindowModality(Qt::ApplicationModal); - (void)parent; - - auto addFunc = [&](int originId, std::wstring archiveName = L"") { - FilesOrigin const& origin = directoryStructure()->getOriginByID(originId); - QString const filePath = - QDir::fromNativeSeparators(ToQString(origin.getPath())) + "/" + fileName; - if (QFile::exists(filePath)) { - // it's very possible the file doesn't exist, because it's inside an archive. we - // don't support that - QWidget* wid = m_PluginContainer->previewGenerator().genPreview(filePath); - if (wid == nullptr) { - reportError(tr("failed to generate preview for %1").arg(filePath)); - } else { - preview->addVariant(ToQString(origin.getName()), wid); - } - } else if (!archiveName.empty()) { - auto archiveFile = directoryStructure()->searchFile(archiveName); - if (archiveFile.get() != nullptr) { - try { - libbsarch::bs_archive archiveLoader; - archiveLoader.load_from_disk(archiveFile->getFullPath()); - libbsarch::memory_blob const fileData = - archiveLoader.extract_to_memory(fileName.toStdWString()); - QByteArray const convertedFileData((char*)(fileData.data), fileData.size); - QWidget* wid = m_PluginContainer->previewGenerator().genArchivePreview( - convertedFileData, filePath); - if (wid == nullptr) { - reportError(tr("failed to generate preview for %1").arg(filePath)); - } else { - preview->addVariant(ToQString(origin.getName()), wid); - } - } catch (std::exception& e) { - } - } - } - }; - - if (selectedOrigin == -1) { - // don't bother with the vector of origins, just add them as they come - addFunc(file->getOrigin(), file->isFromArchive() ? file->getArchive().name() : L""); - for (const auto& alt : file->getAlternatives()) { - addFunc(alt.originID(), alt.isFromArchive() ? alt.archive().name() : L""); - } - } else { - std::vector origins; - - // start with the primary origin - origins.push_back(file->getOrigin()); - - // add other origins, push to front if it's the selected one - for (const auto& alt : file->getAlternatives()) { - if (alt.originID() == selectedOrigin) { - origins.insert(origins.begin(), alt.originID()); - } else { - origins.push_back(alt.originID()); - } - } - - // can't be empty; either the primary origin was the selected one, or it - // was one of the alternatives, which got inserted in front - - if (origins[0] != selectedOrigin) { - // sanity check, this shouldn't happen unless the caller passed an - // incorrect id - - log::warn("selected preview origin {} not found in list of alternatives", - selectedOrigin); - } - - for (int const id : origins) { - addFunc(id); - } - } - - if (preview->numVariants() > 0) { - preview->show(); - preview->raise(); - preview->activateWindow(); - return true; - } else { - delete preview; - QMessageBox::information(parent, tr("Sorry"), - tr("Sorry, can't preview anything. This function " - "currently does not support extracting from bsas.")); - - return false; - } -} - -bool OrganizerCore::previewFile(QWidget* parent, const QString& originName, - const QString& path) -{ - if (!QFile::exists(path)) { - reportError(tr("File '%1' not found.").arg(path)); - return false; - } - - QWidget* wid = m_PluginContainer->previewGenerator().genPreview(path); - if (wid == nullptr) { - reportError(tr("Failed to generate preview for %1").arg(path)); - return false; - } - - // Standalone top-level window — no QObject parent so lifetime is - // decoupled from the enclosing ModInfoDialog (which is stack-allocated - // in modlistviewactions.cpp, so passing &dialog as parent makes preview - // a child of a stack object). ApplicationModal makes preview stack on - // top of the outer modal ModInfoDialog — newest modal wins. Without - // modality, ModInfoDialog blocks all input on the preview. - // WA_DeleteOnClose handles cleanup when user closes it. - auto* preview = new PreviewDialog(path, nullptr); - preview->setAttribute(Qt::WA_DeleteOnClose); - preview->setWindowModality(Qt::ApplicationModal); - preview->addVariant(originName, wid); - preview->show(); - preview->raise(); - preview->activateWindow(); - (void)parent; - - return true; -} - -bool OrganizerCore::previewFileData(QWidget* parent, const QString& fileName, - const QByteArray& fileData) -{ - if (fileData.isEmpty()) { - return false; - } - - const QString ext = QFileInfo(fileName).suffix().toLower(); - if (!m_PluginContainer->previewGenerator().previewSupported(ext, true)) { - return false; - } - - QWidget* wid = - m_PluginContainer->previewGenerator().genArchivePreview(fileData, fileName); - if (wid == nullptr) { - return false; - } - - // Use QDialog::open() instead of exec(). open() is async window-modal — - // it shows the dialog modal to its parent but returns immediately without - // starting a nested QEventLoop. Nesting exec() inside a caller that's - // itself running in QDialog::exec() (mod info filetree → preview_bsa file - // tree → nif preview) has been causing event-loop unwinding to softlock - // on close. WA_DeleteOnClose cleans up automatically. - auto* preview = new PreviewDialog(fileName, parent); - preview->setAttribute(Qt::WA_DeleteOnClose); - preview->addVariant(QFileInfo(fileName).fileName(), wid); - preview->open(); - return true; -} - -boost::signals2::connection OrganizerCore::onAboutToRun( - const std::function& func) -{ - return m_AboutToRun.connect(func); -} - -boost::signals2::connection OrganizerCore::onFinishedRun( - const std::function& func) -{ - return m_FinishedRun.connect(func); -} - -boost::signals2::connection -OrganizerCore::onUserInterfaceInitialized(std::function const& func) -{ - return m_UserInterfaceInitialized.connect(func); -} - -boost::signals2::connection -OrganizerCore::onProfileCreated(std::function const& func) -{ - return m_ProfileCreated.connect(func); -} - -boost::signals2::connection OrganizerCore::onProfileRenamed( - std::function const& func) -{ - return m_ProfileRenamed.connect(func); -} - -boost::signals2::connection -OrganizerCore::onProfileRemoved(std::function const& func) -{ - return m_ProfileRemoved.connect(func); -} - -boost::signals2::connection -OrganizerCore::onProfileChanged(std::function const& func) -{ - return m_ProfileChanged.connect(func); -} - -boost::signals2::connection OrganizerCore::onPluginSettingChanged( - std::function const& func) -{ - return m_PluginSettingChanged.connect(func); -} - -boost::signals2::connection -OrganizerCore::onPluginEnabled(std::function const& func) -{ - return m_PluginEnabled.connect(func); -} - -boost::signals2::connection -OrganizerCore::onPluginDisabled(std::function const& func) -{ - return m_PluginDisabled.connect(func); -} - -boost::signals2::connection -OrganizerCore::onNextRefresh(std::function const& func, - RefreshCallbackGroup group, RefreshCallbackMode mode) -{ - if (m_DirectoryUpdate || mode == RefreshCallbackMode::FORCE_WAIT_FOR_REFRESH) { - return m_OnNextRefreshCallbacks.connect(static_cast(group), func); - } else { - func(); - return {}; - } -} - -void OrganizerCore::refresh(bool saveChanges) -{ - // don't lose changes! - if (saveChanges) { - m_CurrentProfile->writeModlistNow(true); - } - - updateModInfoFromDisc(); - m_CurrentProfile->refreshModStatus(); - - m_ModList.notifyChange(-1); - - refreshDirectoryStructure(); - - emit refreshTriggered(); -} - -void OrganizerCore::refreshESPList(bool force) -{ - onNextRefresh( - [this, force] { - TimeThis const tt("OrganizerCore::refreshESPList()"); - - m_CurrentProfile->writeModlist(); - - // clear list - try { - m_PluginList.refresh(m_CurrentProfile->name(), *m_DirectoryStructure, - m_CurrentProfile->getLockedOrderFileName(), force); - } catch (const std::exception& e) { - reportError(tr("Failed to refresh list of esps: %1").arg(e.what())); - } - }, - RefreshCallbackGroup::CORE, RefreshCallbackMode::RUN_NOW_IF_POSSIBLE); -} - -void OrganizerCore::refreshBSAList() -{ - TimeThis const tt("OrganizerCore::refreshBSAList()"); - - auto archives = gameFeatures().gameFeature(); - - if (archives != nullptr) { - m_ArchivesInit = false; - - // default archives are the ones enabled outside MO. if the list can't be - // found (which might - // happen if ini files are missing) use hard-coded defaults (preferrably the - // same the game would use) - m_DefaultArchives = archives->archives(m_CurrentProfile.get()); - if (m_DefaultArchives.empty()) { - m_DefaultArchives = archives->vanillaArchives(); - } - - m_ActiveArchives.clear(); - - auto iter = enabledArchives(); - m_ActiveArchives = toStringList(iter.begin(), iter.end()); - if (m_ActiveArchives.isEmpty()) { - m_ActiveArchives = m_DefaultArchives; - } - - if (m_UserInterface != nullptr) { - m_UserInterface->updateBSAList(m_DefaultArchives, m_ActiveArchives); - m_UserInterface->archivesWriter().write(); - } - - m_ArchivesInit = true; - } -} - -void OrganizerCore::refreshLists() -{ - if ((m_CurrentProfile != nullptr) && m_DirectoryStructure->isPopulated()) { - refreshESPList(true); - refreshBSAList(); - } // no point in refreshing lists if no files have been added to the directory - // tree -} - -void OrganizerCore::updateModActiveState(int index, bool active) -{ - QList modsToUpdate; - modsToUpdate.append(index); - updateModsActiveState(modsToUpdate, active); -} - -void OrganizerCore::updateModsActiveState(const QList& modIndices, - bool active) -{ - int enabled = 0; - for (auto index : modIndices) { - ModInfo::Ptr const modInfo = ModInfo::getByIndex(index); - QDir const dir(modInfo->absolutePath()); - for (const QString& esm : dir.entryList(QStringList() << "*.esm", QDir::Files)) { - const FileEntryPtr file = m_DirectoryStructure->findFile(ToWString(esm)); - if (file.get() == nullptr) { - log::warn("failed to activate {}", esm); - continue; - } - - if (active != m_PluginList.isEnabled(esm) && file->getAlternatives().empty()) { - m_PluginList.blockSignals(true); - m_PluginList.enableESP(esm, active); - m_PluginList.blockSignals(false); - } - } - - for (const QString& esl : dir.entryList(QStringList() << "*.esl", QDir::Files)) { - const FileEntryPtr file = m_DirectoryStructure->findFile(ToWString(esl)); - if (file.get() == nullptr) { - log::warn("failed to activate {}", esl); - continue; - } - - if (active != m_PluginList.isEnabled(esl) && file->getAlternatives().empty()) { - m_PluginList.blockSignals(true); - m_PluginList.enableESP(esl, active); - m_PluginList.blockSignals(false); - ++enabled; - } - } - QStringList const esps = dir.entryList(QStringList() << "*.esp", QDir::Files); - for (const QString& esp : esps) { - const FileEntryPtr file = m_DirectoryStructure->findFile(ToWString(esp)); - if (file.get() == nullptr) { - log::warn("failed to activate {}", esp); - continue; - } - - if (active != m_PluginList.isEnabled(esp) && file->getAlternatives().empty()) { - m_PluginList.blockSignals(true); - m_PluginList.enableESP(esp, active); - m_PluginList.blockSignals(false); - ++enabled; - } - } - } - if (active && (enabled > 1)) { - MessageDialog::showMessage( - tr("Multiple esps/esls activated, please check that they don't conflict."), - qApp->activeWindow()); - } - m_PluginList.refreshLoadOrder(); - // immediately save affected lists - m_PluginListsWriter.writeImmediately(false); -} - -void OrganizerCore::updateModInDirectoryStructure(unsigned int index, - ModInfo::Ptr modInfo) -{ - QMap allModInfo; - allModInfo[index] = modInfo; - updateModsInDirectoryStructure(allModInfo); -} - -void OrganizerCore::updateModsInDirectoryStructure( - QMap modInfo) -{ - std::vector entries; - - for (auto idx : modInfo.keys()) { - QString path = modInfo[idx]->absolutePath(); - QString const modDataDir = managedGame()->modDataDirectory(); - path = modDataDir.isEmpty() ? path : path + "/" + modDataDir; - entries.push_back({modInfo[idx]->name(), - path, - modInfo[idx]->stealFiles(), - {}, - m_CurrentProfile->getModPriority(idx)}); - } - - m_DirectoryRefresher->addMultipleModsFilesToStructure(m_DirectoryStructure, entries); - - DirectoryRefresher::cleanStructure(m_DirectoryStructure); - // need to refresh plugin list now so we can activate esps - refreshESPList(true); - // activate all esps of the specified mod so the bsas get activated along with - // it - m_PluginList.blockSignals(true); - updateModsActiveState(modInfo.keys(), true); - m_PluginList.blockSignals(false); - // now we need to refresh the bsa list and save it so there is no confusion - // about what archives are available and active - refreshBSAList(); - if (m_UserInterface != nullptr) { - m_UserInterface->archivesWriter().writeImmediately(false); - } - - std::vector archives = enabledArchives(); - m_DirectoryRefresher->setMods(m_CurrentProfile->getActiveMods(), - std::set(archives.begin(), archives.end())); - - // finally also add files from bsas to the directory structure - for (auto idx : modInfo.keys()) { - QString path = modInfo[idx]->absolutePath(); - QString const modDataDir = managedGame()->modDataDirectory(); - path = modDataDir.isEmpty() ? path : path + "/" + modDataDir; - m_DirectoryRefresher->addModBSAToStructure( - m_DirectoryStructure, modInfo[idx]->name(), - m_CurrentProfile->getModPriority(idx), path, modInfo[idx]->archives()); - } -} - -void OrganizerCore::loggedInAction(QWidget* parent, std::function f) -{ - if (NexusInterface::instance().getAccessManager()->validated()) { - f(); - } else if (!m_Settings.network().offlineMode()) { - QString apiKey; - if (GlobalSettings::nexusApiKey(apiKey)) { - doAfterLogin([f] { - f(); - }); - NexusInterface::instance().getAccessManager()->apiCheck(apiKey); - } else { - MessageDialog::showMessage(tr("You need to be logged in with Nexus"), parent); - } - } -} - -void OrganizerCore::requestDownload(const QUrl& url, QNetworkReply* reply) -{ - if (!m_PluginContainer) { - return; - } - for (IPluginModPage* modPage : m_PluginContainer->plugins()) { - if (m_PluginContainer->isEnabled(modPage)) { - ModRepositoryFileInfo* fileInfo = new ModRepositoryFileInfo(); - if (modPage->handlesDownload(url, reply->url(), *fileInfo)) { - fileInfo->repository = modPage->name(); - m_DownloadManager.addDownload(reply, fileInfo); - return; - } - } - } - - // no mod found that could handle the download. Is it a nexus mod? - if (url.host() == "www.nexusmods.com") { - QString gameName = ""; - int modID = 0; - int fileID = 0; - QRegularExpression const nameExp(R"(www\.nexusmods\.com/(\a+)/)"); - auto match = nameExp.match(url.toString()); - if (match.hasMatch()) { - gameName = match.captured(1); - } - QRegularExpression const modExp("mods/(\\d+)"); - match = modExp.match(url.toString()); - if (match.hasMatch()) { - modID = match.captured(1).toInt(); - } - QRegularExpression const fileExp("fid=(\\d+)"); - match = fileExp.match(url.toString()); - if (match.hasMatch()) { - fileID = match.captured(1).toInt(); - } - m_DownloadManager.addDownload(reply, - new ModRepositoryFileInfo(gameName, modID, fileID)); - } else { - if (QMessageBox::question(qApp->activeWindow(), tr("Download?"), - tr("A download has been started but no installed " - "page plugin recognizes it.\n" - "If you download anyway no information (i.e. " - "version) will be associated with the " - "download.\n" - "Continue?"), - QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { - m_DownloadManager.addDownload(reply, new ModRepositoryFileInfo()); - } - } -} - -PluginContainer& OrganizerCore::pluginContainer() const -{ - return *m_PluginContainer; -} - -GameFeatures& OrganizerCore::gameFeatures() const -{ - return pluginContainer().gameFeatures(); -} - -IPluginGame const* OrganizerCore::managedGame() const -{ - return m_GamePlugin; -} - -IOrganizer const* OrganizerCore::managedGameOrganizer() const -{ - return m_PluginContainer->requirements(m_GamePlugin).m_Organizer; -} - -std::vector OrganizerCore::enabledArchives() -{ - std::vector result; - if (settings().archiveParsing()) { - QFile archiveFile(m_CurrentProfile->getArchivesFileName()); - if (archiveFile.open(QIODevice::ReadOnly)) { - while (!archiveFile.atEnd()) { - result.push_back(QString::fromUtf8(archiveFile.readLine()).trimmed()); - } - archiveFile.close(); - } - } - return result; -} - -void OrganizerCore::refreshDirectoryStructure() -{ - if (m_DirectoryUpdate) { - log::debug("can't refresh, already in progress"); - return; - } - - log::debug("refreshing structure"); - m_DirectoryUpdate = true; - - m_CurrentProfile->writeModlistNow(true); - const auto activeModList = m_CurrentProfile->getActiveMods(); - const auto archives = enabledArchives(); - - m_DirectoryRefresher->setMods(activeModList, - std::set(archives.begin(), archives.end())); - - // runs refresh() in a thread - QTimer::singleShot(0, m_DirectoryRefresher.get(), &DirectoryRefresher::refresh); -} - -void OrganizerCore::onDirectoryRefreshed() -{ - log::debug("directory refreshed, finishing up"); - TimeThis const tt("OrganizerCore::onDirectoryRefreshed()"); - - DirectoryEntry* newStructure = m_DirectoryRefresher->stealDirectoryStructure(); - Q_ASSERT(newStructure != m_DirectoryStructure); - - if (newStructure == nullptr) { - // TODO: don't know why this happens, this slot seems to get called twice - // with only one emit - return; - } - - std::swap(m_DirectoryStructure, newStructure); - m_VirtualFileTree.invalidate(); - - if (m_StructureDeleter.joinable()) { - m_StructureDeleter.join(); - } - - m_StructureDeleter = MOShared::startSafeThread([=] { - log::debug("structure deleter thread start"); - delete newStructure; - log::debug("structure deleter thread done"); - }); - - log::debug("clearing caches"); - for (int i = 0; i < m_ModList.rowCount(); ++i) { - ModInfo::Ptr const modInfo = ModInfo::getByIndex(i); - modInfo->clearCaches(); - } - - // needs to be done before post refresh tasks - m_DirectoryUpdate = false; - - log::debug("running post refresh tasks"); - m_OnNextRefreshCallbacks(); - m_OnNextRefreshCallbacks.disconnect_all_slots(); - - if (m_CurrentProfile != nullptr) { - log::debug("refreshing lists"); - refreshLists(); - } - - emit directoryStructureReady(); - - log::debug("refresh done"); -} - -void OrganizerCore::clearCaches(std::vector const& indices) const -{ - const auto insert = [](auto& dest, const auto& from) { - dest.insert(from.begin(), from.end()); - }; - std::set allIndices; - for (const auto index : indices) { - ModInfo::Ptr const modInfo = ModInfo::getByIndex(index); - - if (m_CurrentProfile->modEnabled(index)) { - // if the mod is enabled, we need to first clear its cache so that - // getModOverwrite(), ..., returns the newly conflicting mods (in case - // the mod just got enabled) - modInfo->clearCaches(); - insert(allIndices, modInfo->getModOverwrite()); - insert(allIndices, modInfo->getModOverwritten()); - insert(allIndices, modInfo->getModArchiveOverwrite()); - insert(allIndices, modInfo->getModArchiveOverwritten()); - insert(allIndices, modInfo->getModArchiveLooseOverwrite()); - insert(allIndices, modInfo->getModArchiveLooseOverwritten()); - } else { - // if the mod is disabled, we need to first fetch the conflicting - // mods, and then clear the cache - insert(allIndices, modInfo->getModOverwrite()); - insert(allIndices, modInfo->getModOverwritten()); - insert(allIndices, modInfo->getModArchiveOverwrite()); - insert(allIndices, modInfo->getModArchiveOverwritten()); - insert(allIndices, modInfo->getModArchiveLooseOverwrite()); - insert(allIndices, modInfo->getModArchiveLooseOverwritten()); - modInfo->clearCaches(); - } - } - - for (auto& index : allIndices) { - ModInfo::getByIndex(index)->clearCaches(); - } -} - -void OrganizerCore::modPrioritiesChanged(const QModelIndexList& indices) -{ - for (unsigned int i = 0; i < currentProfile()->numMods(); ++i) { - int const priority = currentProfile()->getModPriority(i); - if (currentProfile()->modEnabled(i)) { - ModInfo::Ptr const modInfo = ModInfo::getByIndex(i); - const auto name = MOBase::ToWString(modInfo->internalName()); - // priorities in the directory structure are one higher because data is 0 - if (directoryStructure()->originExists(name)) { - directoryStructure()->getOriginByName(name).setPriority(priority + 1); - } - } - } - refreshBSAList(); - currentProfile()->writeModlist(); - directoryStructure()->getFileRegister()->sortOrigins(); - - std::vector vindices; - - for (auto& idx : indices) { - vindices.push_back(idx.data(ModList::IndexRole).toInt()); - } - - clearCaches(vindices); -} - -void OrganizerCore::modStatusChanged(unsigned int index) -{ - try { - ModInfo::Ptr const modInfo = ModInfo::getByIndex(index); - if (m_CurrentProfile->modEnabled(index)) { - updateModInDirectoryStructure(index, modInfo); - } else { - updateModActiveState(index, false); - if (m_DirectoryStructure->originExists(ToWString(modInfo->name()))) { - FilesOrigin& origin = - m_DirectoryStructure->getOriginByName(ToWString(modInfo->name())); - origin.enable(false); - } - if (m_UserInterface != nullptr) { - m_UserInterface->archivesWriter().write(); - } - } - - for (unsigned int i = 0; i < m_CurrentProfile->numMods(); ++i) { - ModInfo::Ptr const modInfo = ModInfo::getByIndex(i); - int const priority = m_CurrentProfile->getModPriority(i); - if (m_DirectoryStructure->originExists(ToWString(modInfo->name()))) { - // priorities in the directory structure are one higher because data is - // 0 - m_DirectoryStructure->getOriginByName(ToWString(modInfo->name())) - .setPriority(priority + 1); - } - } - m_DirectoryStructure->getFileRegister()->sortOrigins(); - - refreshLists(); - clearCaches({index}); - m_ModList.notifyModStateChanged({index}); - - } catch (const std::exception& e) { - reportError(tr("failed to update mod list: %1").arg(e.what())); - } -} - -void OrganizerCore::modStatusChanged(QList index) -{ - try { - QMap modsToEnable; - QMap modsToDisable; - std::vector vindices; - for (auto idx : index) { - if (m_CurrentProfile->modEnabled(idx)) { - modsToEnable[idx] = ModInfo::getByIndex(idx); - } else { - modsToDisable[idx] = ModInfo::getByIndex(idx); - } - vindices.push_back(idx); - } - if (!modsToEnable.isEmpty()) { - updateModsInDirectoryStructure(modsToEnable); - } - if (!modsToDisable.isEmpty()) { - updateModsActiveState(modsToDisable.keys(), false); - for (auto idx : modsToDisable.keys()) { - if (m_DirectoryStructure->originExists(ToWString(modsToDisable[idx]->name()))) { - FilesOrigin& origin = m_DirectoryStructure->getOriginByName( - ToWString(modsToDisable[idx]->name())); - origin.enable(false); - } - } - if (m_UserInterface != nullptr) { - m_UserInterface->archivesWriter().write(); - } - } - - for (unsigned int i = 0; i < m_CurrentProfile->numMods(); ++i) { - ModInfo::Ptr const modInfo = ModInfo::getByIndex(i); - int const priority = m_CurrentProfile->getModPriority(i); - if (m_DirectoryStructure->originExists(ToWString(modInfo->name()))) { - // priorities in the directory structure are one higher because data is - // 0 - m_DirectoryStructure->getOriginByName(ToWString(modInfo->name())) - .setPriority(priority + 1); - } - } - m_DirectoryStructure->getFileRegister()->sortOrigins(); - - refreshLists(); - clearCaches(vindices); - m_ModList.notifyModStateChanged(index); - - } catch (const std::exception& e) { - reportError(tr("failed to update mod list: %1").arg(e.what())); - } -} - -void OrganizerCore::loginSuccessful(bool necessary) -{ - if (necessary) { - MessageDialog::showMessage(tr("login successful"), qApp->activeWindow()); - } - for (const QString& url : m_PendingDownloads) { - downloadRequestedNXM(url); - } - m_PendingDownloads.clear(); - for (const auto& task : m_PostLoginTasks) { - task(); - } - - m_PostLoginTasks.clear(); - NexusInterface::instance().loginCompleted(); -} - -void OrganizerCore::loginSuccessfulUpdate(bool necessary) -{ - if (necessary) { - MessageDialog::showMessage(tr("login successful"), qApp->activeWindow()); - } - m_Updater.startUpdate(); -} - -void OrganizerCore::loginFailed(const QString& message) -{ - qCritical().nospace().noquote() << "Nexus API validation failed: " << message; - - if (QMessageBox::question(qApp->activeWindow(), tr("Login failed"), - tr("Login failed, try again?")) == QMessageBox::Yes) { - if (nexusApi(true)) { - return; - } - } - - if (!m_PendingDownloads.isEmpty()) { - MessageDialog::showMessage( - tr("login failed: %1. Download will not be associated with an account") - .arg(message), - qApp->activeWindow()); - for (const QString& url : m_PendingDownloads) { - downloadRequestedNXM(url); - } - m_PendingDownloads.clear(); - } else { - MessageDialog::showMessage(tr("login failed: %1").arg(message), - qApp->activeWindow()); - m_PostLoginTasks.clear(); - } - NexusInterface::instance().loginCompleted(); -} - -void OrganizerCore::loginFailedUpdate(const QString& message) -{ - MessageDialog::showMessage( - tr("login failed: %1. You need to log-in with Nexus to update MO.").arg(message), - qApp->activeWindow()); -} - -void OrganizerCore::syncOverwrite() -{ - ModInfo::Ptr const modInfo = ModInfo::getOverwrite(); - - // Snapshot overwrite before sync so we can detect what was moved. - QStringList beforeFiles; - { - QDirIterator it(modInfo->absolutePath(), QDir::Files | QDir::NoDotAndDotDot, - QDirIterator::Subdirectories); - while (it.hasNext()) { - it.next(); - beforeFiles << QDir(modInfo->absolutePath()).relativeFilePath(it.filePath()); - } - } - - SyncOverwriteDialog syncDialog(modInfo->absolutePath(), m_DirectoryStructure, - qApp->activeWindow()); - if (syncDialog.exec() == QDialog::Accepted) { - syncDialog.apply(QDir::fromNativeSeparators(m_Settings.paths().mods())); - - // Track files that were moved out of overwrite to mods. Files that existed - // before but are gone now were synced to a mod. Use profile priority order - // (highest-priority mod wins) so writes go to the correct mod when - // multiple mods contain the same path. - const QString modsDir = QDir::fromNativeSeparators(m_Settings.paths().mods()); - - // Mod names in ascending priority order (last = highest priority). - const QStringList modsByPriority = - m_ModList.allModsByProfilePriority(m_CurrentProfile.get()); - - for (const auto& relPath : beforeFiles) { - const QString owFile = modInfo->absolutePath() + "/" + relPath; - if (!QFile::exists(owFile)) { - QString bestModPath; - for (const auto& modName : modsByPriority) { - const QString modPath = modsDir + "/" + modName; - if (QFile::exists(modPath + "/" + relPath)) { - bestModPath = modPath; - // Don't break — keep going to find the highest-priority match. - } - } - if (!bestModPath.isEmpty()) { - trackOverwriteMove(relPath, bestModPath); - } - } - } - - modInfo->diskContentModified(); - refreshDirectoryStructure(); - } -} - -QString OrganizerCore::oldMO1HookDll() const -{ - if (auto extender = gameFeatures().gameFeature()) { - QString hookdll = - QDir::toNativeSeparators(managedGame()->dataDirectory().absoluteFilePath( - extender->PluginPath() + "/hook.dll")); - if (QFile(hookdll).exists()) - return hookdll; - } - return {}; -} - -std::vector OrganizerCore::activeProblems() const -{ - std::vector problems; - const auto& hookdll = oldMO1HookDll(); - if (!hookdll.isEmpty()) { - // This warning will now be shown every time the problems are checked, which is a - // bit of a "log spam". But since this is a sever error which will most likely make - // the game crash/freeze/etc. and is very hard to diagnose, this "log spam" will - // make it easier for the user to notice the warning. - log::warn("hook.dll found in game folder: {}", hookdll); - problems.push_back(PROBLEM_MO1SCRIPTEXTENDERWORKAROUND); - } - return problems; -} - -QString OrganizerCore::shortDescription(unsigned int key) const -{ - switch (key) { - case PROBLEM_MO1SCRIPTEXTENDERWORKAROUND: { - return tr( - "MO1 \"Script Extender\" load mechanism has left hook.dll in your game folder"); - } break; - default: { - return tr("Description missing"); - } break; - } -} - -QString OrganizerCore::fullDescription(unsigned int key) const -{ - switch (key) { - case PROBLEM_MO1SCRIPTEXTENDERWORKAROUND: { - return tr("hook.dll has been found in your game folder (right " - "click to copy the full path). " - "This is most likely a leftover of setting the ModOrganizer 1 load " - "mechanism to \"Script Extender\", " - "in which case you must remove this file either by changing the load " - "mechanism in ModOrganizer 1 or " - "manually removing the file, otherwise the game is likely to crash and " - "burn.") - .arg(oldMO1HookDll()); - break; - } - default: { - return tr("Description missing"); - } break; - } -} - -bool OrganizerCore::hasGuidedFix(unsigned int) const -{ - return false; -} - -void OrganizerCore::startGuidedFix(unsigned int) const {} - -bool OrganizerCore::saveCurrentLists() -{ - if (m_DirectoryUpdate) { - log::warn("not saving lists during directory update"); - return false; - } - - try { - savePluginList(); - if (m_UserInterface != nullptr) { - m_UserInterface->archivesWriter().write(); - } - } catch (const std::exception& e) { - reportError(tr("failed to save load order: %1").arg(e.what())); - } - - return true; -} - -void OrganizerCore::savePluginList() -{ - onNextRefresh( - [this]() { - m_PluginList.saveTo(m_CurrentProfile->getLockedOrderFileName()); - m_PluginList.saveLoadOrder(*m_DirectoryStructure); - }, - RefreshCallbackGroup::CORE, RefreshCallbackMode::RUN_NOW_IF_POSSIBLE); -} - -void OrganizerCore::saveCurrentProfile() -{ - if (m_CurrentProfile == nullptr) { - return; - } - - m_CurrentProfile->writeModlist(); - m_CurrentProfile->createTweakedIniFile(); - saveCurrentLists(); - storeSettings(); -} - -ProcessRunner OrganizerCore::processRunner() -{ - return ProcessRunner(*this, m_UserInterface); -} - -bool OrganizerCore::checkGameRegistryKey() -{ - // Map of game short names to their registry key info. - // Format: { shortName, { subKey, valueName } } - static const QMap> gameRegistryKeys = { - {"Enderal", {"Software\\SureAI\\Enderal", "Install_Path"}}, - {"EnderalSE", {"Software\\SureAI\\EnderalSE", "Install_Path"}}, - {"Fallout3", {"Software\\Bethesda Softworks\\Fallout3", "installed path"}}, - {"Fallout4", {"Software\\Bethesda Softworks\\Fallout4", "installed path"}}, - {"Fallout4VR", {"Software\\Bethesda Softworks\\Fallout 4 VR", "installed path"}}, - {"FalloutNV", {"Software\\Bethesda Softworks\\FalloutNV", "installed path"}}, - {"Morrowind", {"Software\\Bethesda Softworks\\Morrowind", "installed path"}}, - {"Oblivion", {"Software\\Bethesda Softworks\\Oblivion", "installed path"}}, - {"Skyrim", {"Software\\Bethesda Softworks\\Skyrim", "installed path"}}, - {"SkyrimSE", {"Software\\Bethesda Softworks\\Skyrim Special Edition", "installed path"}}, - {"SkyrimVR", {"Software\\Bethesda Softworks\\Skyrim VR", "installed path"}}, - {"TTW", {"Software\\Bethesda Softworks\\FalloutNV", "installed path"}}, - }; - - const auto* game = managedGame(); - if (!game) return true; - - const QString shortName = game->gameShortName(); - auto it = gameRegistryKeys.find(shortName); - if (it == gameRegistryKeys.end()) { - return true; // unknown game, nothing to check - } - - auto cfg = FluorineConfig::load(); - if (!cfg || cfg->prefix_path.isEmpty()) { - return true; // no prefix configured - } - - WinePrefix const prefix(cfg->prefix_path); - if (!prefix.isValid()) { - return true; // prefix doesn't exist yet - } - - const QString& subKey = it.value().first; - const QString& valueName = it.value().second; - - // The game directory MO2 is configured to use — convert to Wine path - const QString gameDir = game->gameDirectory().canonicalPath(); - if (gameDir.isEmpty()) { - return true; - } - - // Convert Linux path to Wine Z: path for comparison. - // Trailing backslash required — game launchers expect it (matches Steam's format). - QString winePath = "Z:" + QString(gameDir).replace("/", "\\"); - if (!winePath.endsWith('\\')) - winePath += '\\'; - - // Read the current registry value (check both normal and Wow6432Node) - QString registryPath = prefix.readHklmValue(subKey, valueName); - if (registryPath.isEmpty()) { - const QString wow64Key = "Software\\Wow6432Node\\" + subKey.mid(9); - registryPath = prefix.readHklmValue(wow64Key, valueName); - } - - // Normalize trailing separators before comparing — Steam writes paths with - // a trailing backslash, so the registry value may differ only in that. - auto stripTrailingSep = [](QString s) { - while (s.endsWith('\\') || s.endsWith('/')) - s.chop(1); - return s; - }; - - if (registryPath.isEmpty() || - stripTrailingSep(registryPath).compare(stripTrailingSep(winePath), - Qt::CaseInsensitive) != 0) { - const QString displayRegPath = registryPath.isEmpty() - ? tr("") : registryPath; - - QWidget* parent = nullptr; - if (m_UserInterface) { - parent = m_UserInterface->mainWindow(); - } - - const auto answer = QMessageBox::question( - parent, - tr("Registry key does not match"), - tr("The game's installation path in the Wine registry does not match " - "the managed game path.\n\n" - "Registry Game Path:\n\t%1\n" - "Managed Game Path:\n\t%2\n\n" - "Change the path in the registry to match the managed game path?") - .arg(displayRegPath, winePath), - QMessageBox::Yes | QMessageBox::No | QMessageBox::Cancel, - QMessageBox::Yes); - - if (answer == QMessageBox::Yes) { - if (!prefix.writeHklmValue(subKey, valueName, winePath)) { - log::error("Failed to update game registry key"); - } - // Also update the Wow6432Node copy (32-bit registry view) - const QString wow64Key = "Software\\Wow6432Node\\" + subKey.mid(9); // skip "Software\\" - prefix.writeHklmValue(wow64Key, valueName, winePath); - } else if (answer == QMessageBox::Cancel) { - return false; // cancel launch - } - } - - return true; -} - -bool OrganizerCore::beforeRun( - const QFileInfo& binary, const QDir& cwd, const QString& arguments, - const QString& profileName, const QString& customOverwrite, - const QList& forcedLibraries, - QString* saveBindMountSource, QString* saveBindMountTarget) -{ - saveCurrentProfile(); - if (saveBindMountSource) saveBindMountSource->clear(); - if (saveBindMountTarget) saveBindMountTarget->clear(); - - // need to wait until directory structure is ready - if (m_DirectoryUpdate) { - QEventLoop loop; - connect(this, &OrganizerCore::directoryStructureReady, &loop, &QEventLoop::quit, - Qt::ConnectionType::QueuedConnection); - loop.exec(); - } - - // need to make sure all data is saved before we start the application - if (m_CurrentProfile != nullptr) { - m_CurrentProfile->writeModlistNow(true); - } - - if (!m_AboutToRun(binary.absoluteFilePath(), cwd, arguments)) { - log::debug("start of \"{}\" cancelled by plugin", binary.absoluteFilePath()); - return false; - } - - // Check the game's registry key in the Wine prefix and fix if needed. - if (!checkGameRegistryKey()) { - return false; // user cancelled - } - - // VFS Root Builder: read per-instance setting and configure. - { - bool vfsRootBuilder = false; - if (const auto* s = Settings::maybeInstance()) { - const QSettings instanceIni(s->filename(), QSettings::IniFormat); - vfsRootBuilder = instanceIni.value("fluorine/vfs_root_builder", true).toBool(); - } - const QString storageDir = - QDir(QDir::fromNativeSeparators(basePath())).filePath("rootbuilder"); - m_USVFS.setRootBuilderEnabled(vfsRootBuilder, storageDir.toStdString()); - } - - try { - m_USVFS.updateMapping(fileMapping(profileName, customOverwrite)); - m_USVFS.updateForcedLibraries(forcedLibraries); - } catch (const FuseConnectorException& e) { - log::error("VFS mount failed: {}", e.what()); - return false; - } catch (const std::exception& e) { - QWidget* w = nullptr; - if (m_UserInterface) { - w = m_UserInterface->mainWindow(); - } - QMessageBox::warning(w, tr("Error"), e.what()); - return false; - } - - // Deploy plugins.txt and loadorder.txt to the Wine prefix before launch. - if (m_CurrentProfile != nullptr) { - const QString prefixPathStr = resolveWinePrefixPath(m_Settings, managedGame()); - - if (!prefixPathStr.isEmpty()) { - WinePrefix const prefix(prefixPathStr); - if (prefix.isValid()) { - const QString dataDirName = resolveWineDataDirName(managedGame()); - const QString appDataLocal = prefix.appdataLocal(); - const QString pluginsTargetDir = - QDir(appDataLocal).filePath(dataDirName); - const QString documentsDir = - resolvePrefixGameDocumentsDir(prefix, dataDirName); - log::info("Wine prefix paths: AppData/Local plugins dir='{}', " - "Documents/My Games INI dir='{}'", - pluginsTargetDir, documentsDir); - const auto localSavesFeature = gameFeatures().gameFeature(); - const QString absoluteSaveDir = - resolveAbsoluteSaveDir(prefix, managedGame(), - localSavesFeature.get(), m_CurrentProfile); - log::info("Wine prefix save target: '{}'", absoluteSaveDir); - - // If the prefix's plugin-list file is newer than the profile's - // (e.g. LOOT ran outside MO2 and edited it), sync back first so - // external edits aren't clobbered by the deploy below. - { - WinePrefix::PluginListMechanism preMech = - WinePrefix::PluginListMechanism::None; - switch (managedGame()->loadOrderMechanism()) { - case IPluginGame::LoadOrderMechanism::PluginsTxt: - preMech = WinePrefix::PluginListMechanism::PluginsTxt; - break; - case IPluginGame::LoadOrderMechanism::FileTime: - preMech = WinePrefix::PluginListMechanism::FileTime; - break; - case IPluginGame::LoadOrderMechanism::None: - break; - } - if (preMech != WinePrefix::PluginListMechanism::None) { - const QString profilePluginsPath = - m_CurrentProfile->getPluginsFileName(); - const QDateTime prefixMTime = - prefix.prefixPluginsMTime(dataDirName); - const QDateTime profileMTime = - QFileInfo(profilePluginsPath).lastModified(); - if (prefixMTime.isValid() && - (!profileMTime.isValid() || prefixMTime > profileMTime)) { - log::info("Prefix plugins.txt newer than profile " - "(prefix={}, profile={}), syncing back before deploy", - prefixMTime.toString(Qt::ISODate), - profileMTime.toString(Qt::ISODate)); - if (prefix.syncPluginsBack(profilePluginsPath, dataDirName, - preMech)) { - refreshESPList(true); - } else { - log::warn("Pre-deploy sync-back failed; proceeding with " - "deploy — external edits may be lost"); - } - } - } - } - - // Read plugin lines from profile's plugins.txt - QFile pluginsFile(m_CurrentProfile->getPluginsFileName()); - if (pluginsFile.open(QIODevice::ReadOnly | QIODevice::Text)) { - QStringList plugins; - QTextStream stream(&pluginsFile); - while (!stream.atEnd()) { - const QString line = stream.readLine().trimmed(); - if (!line.isEmpty() && !line.startsWith('#')) { - plugins.append(line); - } - } - pluginsFile.close(); - - if (!plugins.isEmpty()) { - WinePrefix::PluginListMechanism wineMech = - WinePrefix::PluginListMechanism::None; - switch (managedGame()->loadOrderMechanism()) { - case IPluginGame::LoadOrderMechanism::PluginsTxt: - wineMech = WinePrefix::PluginListMechanism::PluginsTxt; - break; - case IPluginGame::LoadOrderMechanism::FileTime: - wineMech = WinePrefix::PluginListMechanism::FileTime; - break; - case IPluginGame::LoadOrderMechanism::None: - wineMech = WinePrefix::PluginListMechanism::None; - break; - } - const QString pluginListFile = - wineMech == WinePrefix::PluginListMechanism::PluginsTxt - ? QString("Plugins.txt") - : QString("plugins.txt"); - log::info("Deploying plugin list to '{}'", - QDir(pluginsTargetDir).filePath(pluginListFile)); - if (prefix.deployPlugins(plugins, dataDirName, wineMech)) { - log::debug("Deployed {} plugins to prefix '{}' (dataDirName='{}')", - plugins.size(), prefixPathStr, dataDirName); - } else { - log::error("Failed to deploy {} plugins to prefix '{}' " - "(dataDirName='{}')", - plugins.size(), prefixPathStr, dataDirName); - } - } else { - log::warn("Profile plugins.txt is empty or contains only comments, " - "skipping plugin deployment"); - } - } - - if (m_CurrentProfile->localSettingsEnabled()) { - const QString targetIniBase = - resolvePrefixGameDocumentsDir(prefix, dataDirName); - int deployedIniCount = 0; - for (const QString& iniFile : managedGame()->iniFiles()) { - const QString sourceIni = - m_CurrentProfile->absoluteIniFilePath(iniFile); - const QString targetIni = - QDir(targetIniBase).filePath(QFileInfo(iniFile).fileName()); - log::info("INI deploy target: '{}' -> '{}' (exists={})", sourceIni, - targetIni, QFileInfo::exists(sourceIni)); - if (QFileInfo::exists(sourceIni) && - prefix.deployProfileIni(sourceIni, targetIni)) { - ++deployedIniCount; - log::debug("Deployed profile INI '{}' -> '{}'", sourceIni, targetIni); - } - } - if (deployedIniCount > 0) { - log::debug("Deployed {} profile INI files to prefix '{}'", deployedIniCount, - prefixPathStr); - } - } else { - log::debug("Profile local settings not enabled, skipping INI deployment. " - "documentsDirectory='{}'", - managedGame()->documentsDirectory().absolutePath()); - } - - if (m_CurrentProfile->localSavesEnabled()) { - const QString profileSavesDir = - QDir(m_CurrentProfile->absolutePath()).filePath("saves"); - - const bool useBindMount = - saveBindMountSource && saveBindMountTarget && - ProtonLauncher::unprivilegedBindMountSupported(); - - if (useBindMount) { - // Clean up any stale symlink from previous symlink-based runs: - // mount --bind won't traverse a symlink as the target. - if (QFileInfo(absoluteSaveDir).isSymLink()) { - QFile::remove(absoluteSaveDir); - } - const QFileInfo leafInfo(absoluteSaveDir); - const QString lowerSaveDir = - QDir(leafInfo.dir().absolutePath()) - .filePath(leafInfo.fileName().toLower()); - if (lowerSaveDir != absoluteSaveDir && - QFileInfo(lowerSaveDir).isSymLink()) { - QFile::remove(lowerSaveDir); - } - - // Both endpoints must exist before the kernel can bind them. - QDir().mkpath(profileSavesDir); - QDir().mkpath(absoluteSaveDir); - - *saveBindMountSource = profileSavesDir; - *saveBindMountTarget = absoluteSaveDir; - log::info("Save bind mount: '{}' -> '{}'", profileSavesDir, - absoluteSaveDir); - } else { - log::info("Save deploy target: '{}' -> '{}'", profileSavesDir, - absoluteSaveDir); - if (!prefix.deployProfileSaves(profileSavesDir, absoluteSaveDir, - true)) { - log::warn("Failed to deploy profile saves from '{}' to " - "prefix '{}'", - profileSavesDir, prefixPathStr); - } - } - - // Ensure the prefix INI points to __MO_Saves so the game reads - // profile-specific saves, even when localSettingsEnabled() is off. - const QStringList iniFiles = managedGame()->iniFiles(); - if (!iniFiles.isEmpty()) { - const QString prefixIni = - QDir(resolvePrefixGameDocumentsDir(prefix, dataDirName)) - .filePath(QFileInfo(iniFiles.first()).fileName()); - MOBase::WriteRegistryValue("General", "sLocalSavePath", - "__MO_Saves\\", prefixIni); - MOBase::WriteRegistryValue("General", "bUseMyGamesDirectory", - "1", prefixIni); - log::debug("Patched prefix INI '{}': sLocalSavePath=__MO_Saves\\", - prefixIni); - } - } else { - // Local saves disabled — restore default sLocalSavePath in prefix INI - const QStringList iniFiles = managedGame()->iniFiles(); - if (!iniFiles.isEmpty()) { - const QString prefixIni = - QDir(resolvePrefixGameDocumentsDir(prefix, dataDirName)) - .filePath(QFileInfo(iniFiles.first()).fileName()); - MOBase::WriteRegistryValue("General", "sLocalSavePath", - "Saves\\", prefixIni); - log::debug("Restored prefix INI '{}': sLocalSavePath=Saves\\", - prefixIni); - } - } - } else { - log::warn("Wine prefix at '{}' is not valid (no drive_c)", prefixPathStr); - } - } else { - log::debug("No Wine prefix configured, skipping plugin deployment"); - } - } - - return true; -} - -void OrganizerCore::afterRun(const QFileInfo& binary, DWORD exitCode) -{ - const bool fileTimeLoadOrder = - managedGame()->loadOrderMechanism() == - IPluginGame::LoadOrderMechanism::FileTime; - - // Unmount the FUSE VFS now that the application has exited. unmount() - // flushes the staging directory (moves new/changed files to overwrite) - // and tears down the FUSE session. This mirrors Windows behaviour where - // USVFS is only active while a hooked process is running. - m_USVFS.unmount(); - - // Restore write permissions on the game directory. In rare cases - // (crashes, unclean Wine shutdown) file permissions can be changed to - // read-only, preventing subsequent launches from working. - { - const auto t0 = std::chrono::steady_clock::now(); - const QString gameDir = managedGame()->gameDirectory().absolutePath(); - namespace fs = std::filesystem; - std::error_code ec; - for (auto it = fs::recursive_directory_iterator( - gameDir.toStdString(), fs::directory_options::skip_permission_denied); - it != fs::recursive_directory_iterator(); ++it) { - fs::permissions(it->path(), - fs::perms::owner_read | fs::perms::owner_write | fs::perms::owner_exec, - fs::perm_options::add, ec); - } - const auto ms = std::chrono::duration_cast( - std::chrono::steady_clock::now() - t0).count(); - std::fprintf(stderr, "[VFS] restored game directory permissions in %lldms\n", - static_cast(ms)); - } - - if (m_CurrentProfile != nullptr) { - const QString prefixPathStr = resolveWinePrefixPath(m_Settings, managedGame()); - if (!prefixPathStr.isEmpty()) { - WinePrefix const prefix(prefixPathStr); - if (prefix.isValid()) { - const QString dataDirName = resolveWineDataDirName(managedGame()); - const auto localSavesFeature = gameFeatures().gameFeature(); - const QString absoluteSaveDir = - resolveAbsoluteSaveDir(prefix, managedGame(), - localSavesFeature.get(), m_CurrentProfile); - - if (m_CurrentProfile->localSavesEnabled()) { - const QString profileSavesDir = - QDir(m_CurrentProfile->absolutePath()).filePath("saves"); - - // Bind-mount path: writes already landed live in the profile via - // the per-launch mount namespace, which teardown automatically - // when the game exited. Nothing to sync and nothing to undeploy. - // The symlink path below only runs on kernels without - // unprivileged user namespaces. - const bool usedBindMount = - !QFileInfo(absoluteSaveDir).isSymLink() && - ProtonLauncher::unprivilegedBindMountSupported(); - if (!usedBindMount) { - log::info("Save sync target: '{}' <- '{}'", profileSavesDir, - absoluteSaveDir); - if (!prefix.syncSavesBack(profileSavesDir, absoluteSaveDir)) { - log::warn("Failed to sync saves back from prefix '{}' to '{}'", - prefixPathStr, profileSavesDir); - } - - // Remove the __MO_Saves symlinks and revert the prefix INI so a - // vanilla launch outside MO2 uses the default Saves dir. - prefix.undeployProfileSaves(absoluteSaveDir); - } - const QStringList iniFiles = managedGame()->iniFiles(); - if (!iniFiles.isEmpty()) { - const QString prefixIni = - QDir(resolvePrefixGameDocumentsDir(prefix, dataDirName)) - .filePath(QFileInfo(iniFiles.first()).fileName()); - MOBase::WriteRegistryValue("General", "sLocalSavePath", - "Saves\\", prefixIni); - log::debug("Restored prefix INI '{}': sLocalSavePath=Saves\\", - prefixIni); - } - } - - if (m_CurrentProfile->localSettingsEnabled()) { - const QString targetIniBase = - resolvePrefixGameDocumentsDir(prefix, dataDirName); - QList> iniMappings; - for (const QString& iniFile : managedGame()->iniFiles()) { - const QString profileIni = - m_CurrentProfile->absoluteIniFilePath(iniFile); - const QString targetIni = - QDir(targetIniBase).filePath(QFileInfo(iniFile).fileName()); - iniMappings.append({profileIni, targetIni}); - log::info("INI sync target: '{}' <- '{}'", profileIni, targetIni); - } - - if (!iniMappings.isEmpty() && - !prefix.syncProfileInisBack(iniMappings)) { - log::warn("Failed to sync profile INIs back from prefix '{}'", - prefixPathStr); - } - } - - // Sync the game's plugin-list file back from the prefix. LOOT and - // similar tools edit the deployed copy in AppData/Local// — - // without this sync, their changes are lost when refreshESPList - // rereads the untouched profile copy and savePluginList clobbers - // them with the old in-memory order. loadorder.txt is MO2-internal - // and not written to or read from the prefix. - WinePrefix::PluginListMechanism wineMech = - WinePrefix::PluginListMechanism::None; - switch (managedGame()->loadOrderMechanism()) { - case IPluginGame::LoadOrderMechanism::PluginsTxt: - wineMech = WinePrefix::PluginListMechanism::PluginsTxt; - break; - case IPluginGame::LoadOrderMechanism::FileTime: - wineMech = WinePrefix::PluginListMechanism::FileTime; - break; - case IPluginGame::LoadOrderMechanism::None: - wineMech = WinePrefix::PluginListMechanism::None; - break; - } - const QString profilePluginsPath = m_CurrentProfile->getPluginsFileName(); - if (!prefix.syncPluginsBack(profilePluginsPath, dataDirName, wineMech)) { - log::warn("Failed to sync plugins.txt back from prefix '{}'", - prefixPathStr); - } - } - } - } - - // FileTime-based games (Skyrim LE, FO3, FNV) derive the load order from - // plugin file mtimes rather than loadorder.txt. Drop the profile copy so - // refreshESPList falls back to file times — LOOT updates those directly - // on the .esp files via FUSE setattr. - if (fileTimeLoadOrder) { - log::debug("removing loadorder.txt (FileTime load order mechanism)"); - QFile::remove(m_CurrentProfile->getLoadOrderFileName()); - } - - // Refresh directory structure after VFS is unmounted so the refresher - // reads the real (vanilla) data directory plus individual mod directories, - // matching Windows USVFS behaviour. - refreshDirectoryStructure(); - - refreshESPList(true); - savePluginList(); - cycleDiagnostics(); - - // These callbacks should not fiddle with directory structure and ESPs. - m_FinishedRun(binary.absoluteFilePath(), exitCode); -} - -ProcessRunner::Results OrganizerCore::waitForAllUSVFSProcesses(UILocker::Reasons reason) -{ - // The Win32 path called waitForAllUSVFSProcessesWithLock(), which iterated - // every USVFS-hooked process via the job-handle bookkeeping. The Linux - // FUSE-based VFS doesn't have a process job so there's nothing to wait - // for here — ProcessRunner::run() already handles the per-launch wait. - Q_UNUSED(reason); - return ProcessRunner::Completed; -} - -std::vector OrganizerCore::fileMapping(const QString& profileName, - const QString& customOverwrite) -{ - // need to wait until directory structure is ready - if (m_DirectoryUpdate) { - QEventLoop loop; - connect(this, &OrganizerCore::directoryStructureReady, &loop, &QEventLoop::quit, - Qt::ConnectionType::QueuedConnection); - loop.exec(); - } - - IPluginGame* game = qApp->property("managed_game").value(); - Profile profile(QDir(m_Settings.paths().profiles() + "/" + profileName), game, - gameFeatures()); - - MappingType result; - - auto dataMaps = game->getModMappings(); - - bool overwriteActive = false; - - for (const auto& mod : profile.getActiveMods()) { - if (std::get<0>(mod).compare("overwrite", Qt::CaseInsensitive) == 0) { - continue; - } - - unsigned int const modIndex = ModInfo::getIndex(std::get<0>(mod)); - ModInfo::Ptr const modPtr = ModInfo::getByIndex(modIndex); - - bool const createTarget = customOverwrite == std::get<0>(mod); - QDir const modDir = QDir(std::get<1>(mod)); - - overwriteActive |= createTarget; - - if (modPtr->isRegular()) { - for (auto dataMap : dataMaps.asKeyValueRange()) { - auto mapDir = QDir(modDir.absoluteFilePath(dataMap.first)); - if (mapDir.exists()) { - for (const auto& dir : dataMap.second) { - result.insert(result.end(), - {mapDir.absolutePath(), dir, true, createTarget}); - } - } - } - } - } - - if (!overwriteActive && !customOverwrite.isEmpty()) { - throw MyException( - tr("The designated write target \"%1\" is not enabled.").arg(customOverwrite)); - } - - if (m_CurrentProfile->localSavesEnabled()) { - auto localSaves = gameFeatures().gameFeature(); - if (localSaves != nullptr) { - MappingType saveMap = - localSaves->mappings(currentProfile()->absolutePath() + "/saves"); - result.reserve(result.size() + saveMap.size()); - result.insert(result.end(), saveMap.begin(), saveMap.end()); - } else { - log::warn("local save games not supported by this game plugin"); - } - } - - QDir const overwriteDir(m_Settings.paths().overwrite()); - for (auto dataMap : dataMaps.asKeyValueRange()) { - auto overwriteSubpath = overwriteDir.absoluteFilePath(dataMap.first); - if (QDir(overwriteSubpath).exists()) { - for (const auto& dir : dataMap.second) { - result.insert(result.end(), - {overwriteSubpath, dir, true, customOverwrite.isEmpty()}); - } - } - } - - for (MOBase::IPluginFileMapper* mapper : - m_PluginContainer->plugins()) { - IPlugin* plugin = dynamic_cast(mapper); - if (m_PluginContainer->isEnabled(plugin)) { - MappingType pluginMap = mapper->mappings(); - result.reserve(result.size() + pluginMap.size()); - result.insert(result.end(), pluginMap.begin(), pluginMap.end()); - } - } - - return result; -} - -std::vector OrganizerCore::fileMapping(const QString& dataPath, - const QString& relPath, - const DirectoryEntry* base, - const DirectoryEntry* directoryEntry, - int createDestination) -{ - std::vector result; - - for (const FileEntryPtr& current : directoryEntry->getFiles()) { - bool isArchive = false; - int const origin = current->getOrigin(isArchive); - if (isArchive || (origin == 0)) { - continue; - } - - QString const originPath = QString::fromStdWString(base->getOriginByID(origin).getPath()); - QString const fileName = QString::fromStdWString(current->getName()); - // QString fileName = ToQString(current->getName()); - QString const source = originPath + relPath + fileName; - QString const target = dataPath + relPath + fileName; - if (source != target) { - result.push_back({source, target, false, false}); - } - } - - // recurse into subdirectories - for (const auto& d : directoryEntry->getSubDirectories()) { - int const origin = d->anyOrigin(); - - QString const originPath = QString::fromStdWString(base->getOriginByID(origin).getPath()); - QString const dirName = QString::fromStdWString(d->getName()); - QString const source = originPath + relPath + dirName; - QString const target = dataPath + relPath + dirName; - - bool const writeDestination = (base == directoryEntry) && (origin == createDestination); - - result.push_back({source, target, true, writeDestination}); - std::vector subRes = - fileMapping(dataPath, relPath + dirName + "/", base, d, createDestination); - result.insert(result.end(), subRes.begin(), subRes.end()); - } - return result; -} +#include "organizercore.h" +#include "categoriesdialog.h" +#include "credentialsdialog.h" +#include "fluorineupdater.h" +#include "delayedfilewriter.h" +#include "directoryrefresher.h" +#include "env.h" +#include "envfs.h" +#include "envmodule.h" +#include "filedialogmemory.h" +#include "guessedvalue.h" +#include "imodinterface.h" +#include "imoinfo.h" +#include "instancemanager.h" +#include "iplugingame.h" +#include "iuserinterface.h" +#include "messagedialog.h" +#include "modlistsortproxy.h" +#include "modrepositoryfileinfo.h" +#include "nexusinterface.h" +#include "nxmaccessmanager.h" +#include "plugincontainer.h" +#include "previewdialog.h" +#include "profile.h" +#include "shared/appconfig.h" +#include "shared/directoryentry.h" +#include "shared/fileentry.h" +#include "shared/filesorigin.h" +#include "shared/util.h" +#include "spawn.h" +#include "syncoverwritedialog.h" +#include "virtualfiletree.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "fluorineconfig.h" +#include "protonlauncher.h" +#include "wineprefix.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include // for qUtf8Printable, etc + +#include +#include +#include // for memset, wcsrchr + +#include +#include +#include +#include +#include +#include //for wstring +#include +#include +#include + +#include + +#include "organizerproxy.h" + +using namespace MOShared; +using namespace MOBase; + +static env::CoreDumpTypes g_coreDumpType = env::CoreDumpTypes::Mini; + +template +QStringList toStringList(InputIterator current, InputIterator end) +{ + QStringList result; + for (; current != end; ++current) { + result.append(*current); + } + return result; +} + +QString resolveWinePrefixPath(const Settings& settings, + const IPluginGame* managedGame) +{ + Q_UNUSED(managedGame); + + if (auto cfg = FluorineConfig::load(); cfg.has_value() && cfg->prefixExists()) { + return cfg->prefix_path.trimmed(); + } + + // Same precedence rule as spawn.cpp's resolvePrefixPath: explicit + // fluorine/prefix_path wins over the legacy Settings/* keys, which may + // have been auto-populated with an external manager's prefix (Heroic, + // Bottles). Without this, switching instances or rebuilding the Fluorine + // config can silently drop us onto the wrong prefix (issue #52). + const QSettings instanceSettings(settings.filename(), QSettings::IniFormat); + const QString explicitPath = + instanceSettings.value("fluorine/prefix_path").toString().trimmed(); + if (!explicitPath.isEmpty()) { + return explicitPath; + } + for (const auto& key : {"Settings/proton_prefix_path", "Settings/prefix_path", + "Proton/prefix_path"}) { + const QString value = instanceSettings.value(key).toString().trimmed(); + if (!value.isEmpty()) { + return value; + } + } + + return {}; +} + +QString resolveWineDataDirName(const IPluginGame* managedGame) +{ + if (managedGame == nullptr) { + return {}; + } + + // Primary: the My Games subfolder name matches the AppData/Local folder + // for almost every Bethesda game. + const QDir docsDir = managedGame->documentsDirectory(); + if (docsDir.exists()) { + const QString docsLeaf = docsDir.dirName().trimmed(); + if (!docsLeaf.isEmpty() && docsLeaf != QStringLiteral(".")) { + return docsLeaf; + } + } + + // Fallback: gameShortName is used by the base Gamebryo mappings() for + // the AppData/Local folder and matches for most games. + const QString shortName = managedGame->gameShortName(); + if (!shortName.isEmpty()) { + log::warn("resolveWineDataDirName: documentsDirectory() is empty or " + "invalid, falling back to gameShortName '{}'", + shortName); + return shortName; + } + + log::warn("resolveWineDataDirName: both documentsDirectory() and " + "gameShortName() are empty, falling back to gameName '{}'", + managedGame->gameName()); + return managedGame->gameName(); +} + +QString resolvePrefixGameDocumentsDir(const WinePrefix& prefix, + const QString& dataDirName) +{ + return QDir(prefix.myGamesPath()).filePath(dataDirName); +} + +QString resolveAbsoluteSaveDir(const WinePrefix& prefix, + const IPluginGame* managedGame, + MOBase::LocalSavegames* localSaves, + std::shared_ptr profile) +{ + if (profile == nullptr || managedGame == nullptr) { + const QString dataDirName = resolveWineDataDirName(managedGame); + return QDir(prefix.myGamesPath()).filePath(dataDirName + "/Saves"); + } + + const QString profileSaveDir = + QDir(profile->absolutePath()).filePath("saves"); + + // Strategy 1: Use LocalSavegames::mappings() if available. + // Extract the user-relative portion of the destination (after drive_c/users//) + // and reconstruct it under our prefix's userProfilePath(). + if (localSaves != nullptr) { + const MappingType mappings = localSaves->mappings(QDir(profileSaveDir)); + for (const auto& mapping : mappings) { + if (!mapping.isDirectory) { + continue; + } + + const QString source = QDir::cleanPath(mapping.source); + const QString destination = QDir::cleanPath(mapping.destination); + if (source != QDir::cleanPath(profileSaveDir)) { + continue; + } + + // Extract the user-relative path (after drive_c/users//) + static const QRegularExpression userDirRe( + "drive_c/users/[^/]+/(.+)", QRegularExpression::CaseInsensitiveOption); + const auto match = userDirRe.match(destination); + if (match.hasMatch()) { + const QString userRelative = match.captured(1); + const QString resolved = + QDir(prefix.userProfilePath()).filePath(userRelative); + log::debug("resolveAbsoluteSaveDir: from mappings -> '{}'", resolved); + return resolved; + } + } + } + + // Strategy 2: Use managedGame->savesDirectory() + { + const QDir savesDir = managedGame->savesDirectory(); + const QString savesPath = QDir::cleanPath(savesDir.absolutePath()); + static const QRegularExpression userDirRe( + "drive_c/users/[^/]+/(.+)", QRegularExpression::CaseInsensitiveOption); + const auto match = userDirRe.match(savesPath); + if (match.hasMatch()) { + const QString userRelative = match.captured(1); + const QString resolved = + QDir(prefix.userProfilePath()).filePath(userRelative); + log::debug("resolveAbsoluteSaveDir: from savesDirectory -> '{}'", resolved); + return resolved; + } + } + + // Fallback: Documents/My Games//Saves (old Bethesda behavior) + const QString dataDirName = resolveWineDataDirName(managedGame); + const QString fallback = + QDir(prefix.myGamesPath()).filePath(dataDirName + "/Saves"); + log::debug("resolveAbsoluteSaveDir: fallback -> '{}'", fallback); + return fallback; +} + +OrganizerCore::OrganizerCore(Settings& settings) + : + m_CurrentProfile(nullptr), m_Settings(settings), + m_Updater(&NexusInterface::instance()), m_ModList(m_PluginContainer, this), + m_PluginList(*this), + m_DirectoryRefresher(new DirectoryRefresher(this, settings.refreshThreadCount())), + m_DirectoryStructure(new DirectoryEntry(L"data", nullptr, 0)), + m_VirtualFileTree([this]() { + return VirtualFileTree::makeTree(m_DirectoryStructure); + }), + m_DownloadManager(&NexusInterface::instance(), this), m_DirectoryUpdate(false), + + m_PluginListsWriter(std::bind(&OrganizerCore::savePluginList, this)) +{ + env::setHandleCloserThreadCount(settings.refreshThreadCount()); + m_DownloadManager.setOutputDirectory(m_Settings.paths().downloads(), false); + + NexusInterface::instance().setCacheDirectory(m_Settings.paths().cache()); + + m_InstallationManager.setModsDirectory(m_Settings.paths().mods()); + m_InstallationManager.setDownloadDirectory(m_Settings.paths().downloads()); + + connect(&m_DownloadManager, SIGNAL(downloadSpeed(QString, int)), this, + SLOT(downloadSpeed(QString, int))); + connect(m_DirectoryRefresher.get(), &DirectoryRefresher::refreshed, this, + &OrganizerCore::onDirectoryRefreshed); + + connect(&m_ModList, SIGNAL(removeOrigin(QString)), this, SLOT(removeOrigin(QString))); + connect(&m_ModList, &ModList::modStatesChanged, [=, this] { + currentProfile()->writeModlist(); + }); + connect(&m_ModList, &ModList::modPrioritiesChanged, [this](auto&& indexes) { + modPrioritiesChanged(indexes); + }); + + connect(NexusInterface::instance().getAccessManager(), + SIGNAL(validateSuccessful(bool)), this, SLOT(loginSuccessful(bool))); + connect(NexusInterface::instance().getAccessManager(), + SIGNAL(validateFailed(QString)), this, SLOT(loginFailed(QString))); + + // This seems awfully imperative + connect(this, SIGNAL(managedGameChanged(MOBase::IPluginGame const*)), &m_Settings, + SLOT(managedGameChanged(MOBase::IPluginGame const*))); + connect(this, SIGNAL(managedGameChanged(MOBase::IPluginGame const*)), + &m_DownloadManager, SLOT(managedGameChanged(MOBase::IPluginGame const*))); + connect(this, SIGNAL(managedGameChanged(MOBase::IPluginGame const*)), &m_PluginList, + SLOT(managedGameChanged(MOBase::IPluginGame const*))); + + connect(&m_PluginList, &PluginList::writePluginsList, &m_PluginListsWriter, + &DelayedFileWriterBase::write); + + // make directory refresher run in a separate thread + m_RefresherThread.start(); + m_DirectoryRefresher->moveToThread(&m_RefresherThread); + + connect(&settings.plugins(), &PluginSettings::pluginSettingChanged, + [this](auto const&... args) { + m_PluginSettingChanged(args...); + }); +} + +OrganizerCore::~OrganizerCore() +{ + m_RefresherThread.exit(); + m_RefresherThread.wait(); + + if (m_StructureDeleter.joinable()) { + m_StructureDeleter.join(); + } + + saveCurrentProfile(); + + // profile has to be cleaned up before the modinfo-buffer is cleared + m_CurrentProfile.reset(); + + ModInfo::clear(); + m_ModList.setProfile(nullptr); + // NexusInterface::instance()->cleanup(); + + delete m_DirectoryStructure; +} + +void OrganizerCore::storeSettings() +{ + if (m_CurrentProfile != nullptr) { + m_Settings.game().setSelectedProfileName(m_CurrentProfile->name()); + } + + m_ExecutablesList.store(m_Settings); + + FileDialogMemory::save(m_Settings); + + const auto result = m_Settings.sync(); + + if (result != QSettings::NoError) { + QString reason; + + if (result == QSettings::AccessError) { + reason = tr("File is write protected"); + } else if (result == QSettings::FormatError) { + reason = tr("Invalid file format (probably a bug)"); + } else { + reason = tr("Unknown error %1").arg(result); + } + + QMessageBox::critical( + qApp->activeWindow(), tr("Failed to write settings"), + tr("An error occurred trying to write back MO settings to %1: %2") + .arg(m_Settings.filename(), reason)); + } +} + +void OrganizerCore::updateExecutablesList() +{ + if (m_PluginContainer == nullptr) { + log::error("can't update executables list now"); + return; + } + + m_ExecutablesList.load(managedGame(), m_Settings); +} + +void OrganizerCore::updateModInfoFromDisc() +{ + const QString modsPath = m_Settings.paths().mods(); + log::debug("updateModInfoFromDisc: base='{}', mods='{}'", + m_Settings.paths().base(), modsPath); + ModInfo::updateFromDisc(modsPath, *this, + m_Settings.interface().displayForeign(), + m_Settings.refreshThreadCount()); +} + +void OrganizerCore::setUserInterface(IUserInterface* ui) +{ + storeSettings(); + + m_UserInterface = ui; + + QWidget* w = nullptr; + if (m_UserInterface) { + w = m_UserInterface->mainWindow(); + } + + m_InstallationManager.setParentWidget(w); + m_Updater.setUserInterface(w); + m_UILocker.setUserInterface(w); + m_DownloadManager.setParentWidget(w); + + checkForUpdates(); +} + +void OrganizerCore::checkForUpdates() +{ + // this currently wouldn't work reliably if the ui isn't initialized yet to + // display the result + if (m_UserInterface != nullptr) { + m_Updater.testForUpdate(m_Settings); + checkForFluorineUpdates(); + } +} + +void OrganizerCore::checkForFluorineUpdates() +{ + // Set up the Fluorine self-update checker lazily so repeated calls don't + // leak QNetworkAccessManager instances. The member is forward-declared in + // the header (pointer-only); the include lives here to keep the header + // lightweight for its many consumers. + if (m_FluorineUpdater == nullptr) { + m_FluorineUpdater = new FluorineUpdater(this); + + connect(m_FluorineUpdater, &FluorineUpdater::updateAvailable, this, + [](const FluorineUpdater::ReleaseInfo& info) { + const QString channel = + FluorineUpdater::channelToString(info.channel); + MOBase::log::info( + "Fluorine update available ({}): {} at {}", + channel, + info.tagName.isEmpty() ? info.name : info.tagName, + info.htmlUrl); + }); + connect(m_FluorineUpdater, &FluorineUpdater::upToDate, this, + [](const FluorineUpdater::ReleaseInfo& info) { + MOBase::log::debug("Fluorine is up to date ({})", + FluorineUpdater::channelToString(info.channel)); + }); + connect(m_FluorineUpdater, &FluorineUpdater::checkFailed, this, + [](const QString& reason) { + MOBase::log::debug("Fluorine update check failed: {}", reason); + }); + } + + const FluorineUpdater::Channel channel = FluorineUpdater::channelFromString( + m_Settings.fluorineUpdateChannel(), FluorineUpdater::buildChannel()); + m_FluorineUpdater->checkForUpdates(channel); +} + +void OrganizerCore::connectPlugins(PluginContainer* container) +{ + m_PluginContainer = container; + m_Updater.setPluginContainer(m_PluginContainer); + m_InstallationManager.setPluginContainer(m_PluginContainer); + m_DownloadManager.setPluginContainer(m_PluginContainer); + m_ModList.setPluginContainer(m_PluginContainer); + + if (!m_GameName.isEmpty()) { + m_GamePlugin = m_PluginContainer->game(m_GameName); + emit managedGameChanged(m_GamePlugin); + } + + connect(m_PluginContainer, &PluginContainer::pluginEnabled, [&](IPlugin* plugin) { + m_PluginEnabled(plugin); + }); + connect(m_PluginContainer, &PluginContainer::pluginDisabled, [&](IPlugin* plugin) { + m_PluginDisabled(plugin); + }); + + connect(&m_PluginContainer->gameFeatures(), &GameFeatures::modDataContentUpdated, + [this](ModDataContent const* contentFeature) { + if (contentFeature) { + m_Contents = ModDataContentHolder(contentFeature->getAllContents()); + } else { + m_Contents = ModDataContentHolder(); + } + }); +} + +void OrganizerCore::setManagedGame(MOBase::IPluginGame* game) +{ + m_GameName = game->gameName(); + m_GamePlugin = game; + qApp->setProperty("managed_game", QVariant::fromValue(m_GamePlugin)); + emit managedGameChanged(m_GamePlugin); +} + +Settings& OrganizerCore::settings() +{ + return m_Settings; +} + +bool OrganizerCore::nexusApi(bool retry) +{ + auto* accessManager = NexusInterface::instance().getAccessManager(); + + if ((accessManager->validateAttempted() || accessManager->validated()) && !retry) { + // previous attempt, maybe even successful + return false; + } else { + QString apiKey; + if (GlobalSettings::nexusApiKey(apiKey)) { + // credentials stored or user entered them manually + log::debug("attempt to verify nexus api key"); + accessManager->apiCheck(apiKey); + return true; + } else { + // no credentials stored and user didn't enter them + accessManager->refuseValidation(); + return false; + } + } +} + +void OrganizerCore::startMOUpdate() +{ + if (nexusApi()) { + m_PostLoginTasks.append([&]() { + m_Updater.startUpdate(); + }); + } else { + m_Updater.startUpdate(); + } +} + +void OrganizerCore::downloadRequestedNXM(const QString& url) +{ + log::debug("download requested: {}", url); + if (nexusApi()) { + m_PendingDownloads.append(url); + } else { + m_DownloadManager.addNXMDownload(url); + } +} + +void OrganizerCore::userInterfaceInitialized() +{ + m_UserInterfaceInitialized(m_UserInterface->mainWindow()); +} + +void OrganizerCore::profileCreated(MOBase::IProfile* profile) +{ + m_ProfileCreated(profile); +} + +void OrganizerCore::profileRenamed(MOBase::IProfile* profile, QString const& oldName, + QString const& newName) +{ + m_ProfileRenamed(profile, oldName, newName); +} + +void OrganizerCore::profileRemoved(QString const& profileName) +{ + m_ProfileRemoved(profileName); +} + +void OrganizerCore::downloadRequested(QNetworkReply* reply, QString gameName, int modID, + const QString& fileName) +{ + try { + if (m_DownloadManager.addDownload(reply, QStringList(), fileName, gameName, modID, + 0, new ModRepositoryFileInfo(gameName, modID))) { + MessageDialog::showMessage(tr("Download started"), qApp->activeWindow()); + } + } catch (const std::exception& e) { + MessageDialog::showMessage(tr("Download failed"), qApp->activeWindow()); + log::error("exception starting download: {}", e.what()); + } +} + +void OrganizerCore::removeOrigin(const QString& name) +{ + const auto wname = ToWString(name); + if (m_DirectoryStructure->originExists(wname)) { + FilesOrigin& origin = m_DirectoryStructure->getOriginByName(wname); + origin.enable(false); + } + refreshLists(); +} + +void OrganizerCore::downloadSpeed(const QString& serverName, int bytesPerSecond) +{ + m_Settings.network().setDownloadSpeed(serverName, bytesPerSecond); +} + +InstallationManager* OrganizerCore::installationManager() +{ + return &m_InstallationManager; +} + +bool OrganizerCore::createDirectory(const QString& path) +{ + if (!QDir(path).exists() && !QDir().mkpath(path)) { + QMessageBox::critical(nullptr, QObject::tr("Error"), + QObject::tr("Failed to create \"%1\". Your user " + "account probably lacks permission.") + .arg(QDir::toNativeSeparators(path))); + return false; + } else { + return true; + } +} + +bool OrganizerCore::checkPathSymlinks() +{ + const bool hasSymlink = (QFileInfo(m_Settings.paths().profiles()).isSymLink() || + QFileInfo(m_Settings.paths().mods()).isSymLink() || + QFileInfo(m_Settings.paths().overwrite()).isSymLink()); + + if (hasSymlink) { + log::warn( + "{}", + QObject::tr( + "One of the configured MO2 directories (profiles, mods, or overwrite) " + "is on a path containing a symbolic (or other) link. This is likely to " + "be incompatible with MO2's virtual filesystem.")); + + return false; + } + + return true; +} + +bool OrganizerCore::bootstrap() +{ + const auto dirs = {m_Settings.paths().profiles(), m_Settings.paths().mods(), + m_Settings.paths().downloads(), m_Settings.paths().overwrite(), + QString::fromStdWString(getGlobalCoreDumpPath())}; + + for (auto&& dir : dirs) { + if (!createDirectory(dir)) { + return false; + } + } + + if (!checkPathSymlinks()) { + return false; + } + + if (!cycleDiagnostics()) { + return false; + } + + // log if there are any dmp files + const auto hasCrashDumps = !QDir(QString::fromStdWString(getGlobalCoreDumpPath())) + .entryList({"*.dmp"}, QDir::Files) + .empty(); + + if (hasCrashDumps) { + log::debug("there are crash dumps in '{}'", + QString::fromStdWString(getGlobalCoreDumpPath())); + } + + return true; +} + +void OrganizerCore::createDefaultProfile() +{ + QString const profilesPath = settings().paths().profiles(); + if (QDir(profilesPath).entryList(QDir::AllDirs | QDir::NoDotAndDotDot).empty()) { + Profile newProf(QString::fromStdWString(AppConfig::defaultProfileName()), + managedGame(), gameFeatures(), false); + + m_ProfileCreated(&newProf); + } +} + +void OrganizerCore::createOverwriteDirectories() +{ + QString const overwritePath = settings().paths().overwrite(); + for (const auto& modDirectory : managedGame()->getModMappings().keys()) { + if (!modDirectory.isEmpty()) { + QDir(overwritePath).mkdir(modDirectory); + } + } +} + +void OrganizerCore::prepareVFS() +{ + // Read the load order and pass it to the FUSE VFS so plugin files get + // incrementing timestamps matching their position. This prevents LOOT + // from reporting "ambiguous load order". + { + std::vector loadOrder; + QFile loFile(m_CurrentProfile->getLoadOrderFileName()); + if (!loFile.exists()) { + loFile.setFileName(m_CurrentProfile->getPluginsFileName()); + } + if (loFile.open(QIODevice::ReadOnly | QIODevice::Text)) { + QTextStream in(&loFile); + while (!in.atEnd()) { + QString line = in.readLine().trimmed(); + if (line.isEmpty() || line.startsWith('#')) { + continue; + } + if (line.startsWith('*')) { + line = line.mid(1); + } + loadOrder.push_back(line.toStdString()); + } + } + m_USVFS.setPluginLoadOrder(loadOrder); + } + + // Set up tracked writes file (per-profile, next to the overwrite folder) + { + QString const owPath = settings().paths().overwrite(); + QDir const owDir(owPath); + QString trackPath = owDir.absoluteFilePath("../tracked_writes.json"); + trackPath = QDir::cleanPath(trackPath); + std::fprintf(stderr, "[VFS] prepareVFS: owPath='%s' trackPath='%s'\n", + owPath.toStdString().c_str(), trackPath.toStdString().c_str()); + m_USVFS.setTrackingFilePath(trackPath.toStdString()); + } + + // VFS Root Builder: read per-instance setting and configure. + { + bool vfsRootBuilder = false; + if (const auto* s = Settings::maybeInstance()) { + const QSettings instanceIni(s->filename(), QSettings::IniFormat); + vfsRootBuilder = instanceIni.value("fluorine/vfs_root_builder", true).toBool(); + } + const QString storageDir = + QDir(QDir::fromNativeSeparators(basePath())).filePath("rootbuilder"); + m_USVFS.setRootBuilderEnabled(vfsRootBuilder, storageDir.toStdString()); + } + + m_USVFS.updateMapping(fileMapping(m_CurrentProfile->name(), QString())); +} + +void OrganizerCore::unmountVFS() +{ + m_USVFS.unmount(); +} + +void OrganizerCore::trackOverwriteMove(const QString& relativePath, + const QString& modFolderPath) +{ + auto tw = m_USVFS.trackedWrites(); + if (tw) { + tw->track(relativePath.toStdString(), modFolderPath.toStdString()); + } +} + +void OrganizerCore::discardVFSStagingOnUnmount() +{ + m_USVFS.discardStagingOnUnmount(); +} + +void OrganizerCore::updateVFSParams(log::Levels logLevel, + env::CoreDumpTypes coreDumpType, + const QString& crashDumpsPath, + std::chrono::seconds spawnDelay, + QString executableBlacklist, + const QStringList& skipFileSuffixes, + const QStringList& skipDirectories) +{ + setGlobalCoreDumpType(coreDumpType); + + m_USVFS.updateParams(logLevel, coreDumpType, crashDumpsPath, spawnDelay, + executableBlacklist, skipFileSuffixes, skipDirectories); +} + +void OrganizerCore::setLogLevel(log::Levels level) +{ + m_Settings.diagnostics().setLogLevel(level); + + updateVFSParams( + m_Settings.diagnostics().logLevel(), m_Settings.diagnostics().coreDumpType(), + QString::fromStdWString(getGlobalCoreDumpPath()), + m_Settings.diagnostics().spawnDelay(), m_Settings.executablesBlacklist(), + m_Settings.skipFileSuffixes(), m_Settings.skipDirectories()); + + log::getDefault().setLevel(m_Settings.diagnostics().logLevel()); +} + +bool OrganizerCore::cycleDiagnostics() +{ + const auto maxDumps = settings().diagnostics().maxCoreDumps(); + const auto path = QString::fromStdWString(getGlobalCoreDumpPath()); + + if (maxDumps > 0) { + removeOldFiles(path, "*.dmp", maxDumps, QDir::Time | QDir::Reversed); + } + + return true; +} + +env::CoreDumpTypes OrganizerCore::getGlobalCoreDumpType() +{ + return g_coreDumpType; +} + +void OrganizerCore::setGlobalCoreDumpType(env::CoreDumpTypes type) +{ + g_coreDumpType = type; +} + +std::wstring OrganizerCore::getGlobalCoreDumpPath() +{ + if (qApp) { + const auto dp = qApp->property("dataPath"); + if (!dp.isNull()) { + return dp.toString().toStdWString() + L"/" + AppConfig::dumpsDir(); + } + } + + return {}; +} + +void OrganizerCore::setCurrentProfile(const QString& profileName) +{ + if ((m_CurrentProfile != nullptr) && (profileName == m_CurrentProfile->name())) { + return; + } + + log::debug("selecting profile '{}'", profileName); + + QDir const profileBaseDir(settings().paths().profiles()); + + const auto subdirs = profileBaseDir.entryList(QDir::AllDirs | QDir::NoDotAndDotDot); + + QString profileDir; + + // the profile name may not have the correct case, which breaks other parts + // of the ui like the profile combobox, which walks directories on its own + // + // find the real name with the correct case by walking the directories + for (auto&& dirName : subdirs) { + if (QString::compare(dirName, profileName, Qt::CaseInsensitive) == 0) { + profileDir = profileBaseDir.absoluteFilePath(dirName); + break; + } + } + + if (profileDir.isEmpty()) { + log::error("profile '{}' does not exist", profileName); + + // selected profile doesn't exist. Ensure there is at least one profile, + // then pick any one + createDefaultProfile(); + + profileDir = profileBaseDir.absoluteFilePath( + profileBaseDir.entryList(QDir::AllDirs | QDir::NoDotAndDotDot).at(0)); + + log::error("picked profile '{}' instead", QDir(profileDir).dirName()); + + reportError(tr("The selected profile '%1' does not exist. The profile '%2' will be " + "used instead") + .arg(profileName) + .arg(QDir(profileDir).dirName())); + } + + // Keep the old profile to emit signal-changed: + auto oldProfile = std::move(m_CurrentProfile); + + m_CurrentProfile = + std::make_unique(QDir(profileDir), managedGame(), gameFeatures()); + + m_ModList.setProfile(m_CurrentProfile.get()); + + if (m_CurrentProfile->invalidationActive(nullptr)) { + m_CurrentProfile->activateInvalidation(); + } else { + m_CurrentProfile->deactivateInvalidation(); + } + + m_Settings.game().setSelectedProfileName(m_CurrentProfile->name()); + + connect(m_CurrentProfile.get(), qOverload(&Profile::modStatusChanged), + [this](auto&& index) { + modStatusChanged(index); + }); + connect(m_CurrentProfile.get(), qOverload>(&Profile::modStatusChanged), + [this](auto&& indexes) { + modStatusChanged(indexes); + }); + refreshDirectoryStructure(); + + m_CurrentProfile->debugDump(); + + emit profileChanged(oldProfile.get(), m_CurrentProfile.get()); + m_ProfileChanged(oldProfile.get(), m_CurrentProfile.get()); +} + +QStringList OrganizerCore::profileNames() const +{ + QDir const profilesDir(m_Settings.paths().profiles()); + return profilesDir.entryList(QDir::AllDirs | QDir::NoDotAndDotDot); +} + +std::shared_ptr +OrganizerCore::getProfile(const QString& profileName) const +{ + QDir profileDir(m_Settings.paths().profiles()); + profileDir.cd(profileName); + if (!profileDir.exists()) { + return nullptr; + } + + return std::make_shared(profileDir, managedGame(), gameFeatures()); +} + +MOBase::IModRepositoryBridge* OrganizerCore::createNexusBridge() const +{ + return new NexusBridge(m_PluginContainer); +} + +QString OrganizerCore::profileName() const +{ + if (m_CurrentProfile != nullptr) { + return m_CurrentProfile->name(); + } else { + return ""; + } +} + +QString OrganizerCore::profilePath() const +{ + if (m_CurrentProfile != nullptr) { + return m_CurrentProfile->absolutePath(); + } else { + return ""; + } +} + +QString OrganizerCore::downloadsPath() const +{ + return QDir::fromNativeSeparators(m_Settings.paths().downloads()); +} + +QString OrganizerCore::overwritePath() const +{ + return QDir::fromNativeSeparators(m_Settings.paths().overwrite()); +} + +QString OrganizerCore::basePath() const +{ + return QDir::fromNativeSeparators(m_Settings.paths().base()); +} + +QString OrganizerCore::modsPath() const +{ + return QDir::fromNativeSeparators(m_Settings.paths().mods()); +} + +MOBase::Version OrganizerCore::version() const +{ + return m_Updater.getVersion(); +} + +MOBase::IPluginGame* OrganizerCore::getGame(const QString& name) const +{ + for (IPluginGame* game : m_PluginContainer->plugins()) { + if (game != nullptr && + game->gameShortName().compare(name, Qt::CaseInsensitive) == 0) + return game; + } + return nullptr; +} + +MOBase::IModInterface* OrganizerCore::createMod(GuessedValue& name) +{ + auto result = m_InstallationManager.testOverwrite(name); + if (!result) { + return nullptr; + } + + m_InstallationManager.setModsDirectory(m_Settings.paths().mods()); + + QString const targetDirectory = + QDir::fromNativeSeparators(m_Settings.paths().mods()).append("/").append(name); + + QSettings settingsFile(targetDirectory + "/meta.ini", QSettings::IniFormat); + + if (!result.merged()) { + settingsFile.setValue("modid", 0); + settingsFile.setValue("version", ""); + settingsFile.setValue("newestVersion", ""); + settingsFile.setValue("category", 0); + settingsFile.setValue("installationFile", ""); + + settingsFile.remove("installedFiles"); + settingsFile.beginWriteArray("installedFiles", 0); + settingsFile.endArray(); + } + + // shouldn't this use the existing mod in case of a merge? also, this does not refresh + // the indices in the ModInfo structure + return ModInfo::createFrom(QDir(targetDirectory), *this).data(); +} + +void OrganizerCore::modDataChanged(MOBase::IModInterface*) +{ + refresh(false); +} + +QVariant OrganizerCore::pluginSetting(const QString& pluginName, + const QString& key) const +{ + return m_Settings.plugins().setting(pluginName, key); +} + +void OrganizerCore::setPluginSetting(const QString& pluginName, const QString& key, + const QVariant& value) +{ + m_Settings.plugins().setSetting(pluginName, key, value); +} + +QVariant OrganizerCore::persistent(const QString& pluginName, const QString& key, + const QVariant& def) const +{ + return m_Settings.plugins().persistent(pluginName, key, def); +} + +void OrganizerCore::setPersistent(const QString& pluginName, const QString& key, + const QVariant& value, bool sync) +{ + m_Settings.plugins().setPersistent(pluginName, key, value, sync); +} + +QString OrganizerCore::pluginDataPath() +{ + // The plugins/ directory may contain symlinks into a read-only bundled + // directory (e.g. /app/ in Flatpak). Place plugin data in a separate + // writable directory so mkdir() never hits a read-only filesystem. + return AppConfig::basePath() + "/plugin_data"; +} + +MOBase::IModInterface* OrganizerCore::installMod(const QString& archivePath, + int priority, bool reinstallation, + ModInfo::Ptr currentMod, + const QString& initModName) +{ + return installArchive(archivePath, reinstallation ? -1 : priority, reinstallation, + currentMod, initModName) + .get(); +} + +std::pair +OrganizerCore::doInstall(const QString& archivePath, GuessedValue modName, + ModInfo::Ptr currentMod, int priority, bool reinstallation) +{ + if (m_CurrentProfile == nullptr) { + return {-1, nullptr}; + } + + if (m_InstallationManager.isRunning()) { + QMessageBox::information(qApp->activeWindow(), tr("Installation cancelled"), + tr("Another installation is currently in progress."), + QMessageBox::Ok); + return {-1, nullptr}; + } + + bool const hasIniTweaks = false; + m_CurrentProfile->writeModlistNow(); + m_InstallationManager.setModsDirectory(m_Settings.paths().mods()); + m_InstallationManager.notifyInstallationStart(archivePath, reinstallation, + currentMod); + auto result = m_InstallationManager.install(archivePath, modName, hasIniTweaks); + + if (result) { + MessageDialog::showMessage(tr("Installation successful"), qApp->activeWindow()); + + // we wait for the directory structure to be ready before notifying the mod list, + // this prevents issue with third-party plugins, e.g., if the installed mod is + // activated before the structure is ready + // + // we need to fetch modIndex() within the call back because the index is only + // valid after the call to refresh(), but we do not want to connect after + // refresh() + // + connect( + this, &OrganizerCore::directoryStructureReady, this, + [=, this] { + const int modIndex = ModInfo::getIndex(modName); + if (modIndex != UINT_MAX) { + const auto modInfo = ModInfo::getByIndex(modIndex); + m_ModList.notifyModInstalled(modInfo.get()); + } + }, + Qt::SingleShotConnection); + + refresh(); + + const auto modIndex = ModInfo::getIndex(modName); + ModInfo::Ptr modInfo = nullptr; + if (modIndex != UINT_MAX) { + modInfo = ModInfo::getByIndex(modIndex); + + if (priority != -1 && !result.mergedOrReplaced()) { + m_ModList.changeModPriority(modIndex, priority); + } + + if (hasIniTweaks && m_UserInterface != nullptr && + (QMessageBox::question(qApp->activeWindow(), tr("Configure Mod"), + tr("This mod contains ini tweaks. Do you " + "want to configure them now?"), + QMessageBox::Yes | QMessageBox::No) == + QMessageBox::Yes)) { + m_UserInterface->displayModInformation(modInfo, modIndex, + ModInfoTabIDs::IniFiles); + } + + m_InstallationManager.notifyInstallationEnd(result, modInfo); + } else { + reportError(tr("mod not found: %1").arg(qUtf8Printable(modName))); + } + emit modInstalled(modName); + return {modIndex, modInfo}; + } else { + if (result.result() == MOBase::IPluginInstaller::RESULT_CATEGORYREQUESTED) { + CategoriesDialog dialog(qApp->activeWindow()); + + if (dialog.exec() == QDialog::Accepted) { + dialog.commitChanges(); + refresh(); + } + } else { + m_InstallationManager.notifyInstallationEnd(result, nullptr); + if (m_InstallationManager.wasCancelled()) { + QMessageBox::information( + qApp->activeWindow(), tr("Extraction cancelled"), + tr("The installation was cancelled while extracting files. " + "If this was prior to a FOMOD setup, this warning may be ignored. " + "However, if this was during installation, the mod will likely be " + "missing " + "files."), + QMessageBox::Ok); + refresh(); + } + } + } + + return {-1, nullptr}; +} + +ModInfo::Ptr OrganizerCore::installDownload(int index, int priority) +{ + ScopedDisableDirWatcher const scopedDirwatcher(&m_DownloadManager); + + try { + QString const fileName = m_DownloadManager.getFilePath(index); + QString const gameName = m_DownloadManager.getGameName(index); + int const modID = m_DownloadManager.getModID(index); + int const fileID = m_DownloadManager.getFileInfo(index)->fileID; + ModInfo::Ptr currentMod = nullptr; + GuessedValue modName; + + // see if there already are mods with the specified mod id + if (modID > 0) { + std::vector modInfo = ModInfo::getByModID(gameName, modID); + for (auto iter = modInfo.begin(); iter != modInfo.end(); ++iter) { + std::vector flags = (*iter)->getFlags(); + if (std::find(flags.begin(), flags.end(), ModInfo::FLAG_BACKUP) == + flags.end()) { + modName.update((*iter)->name(), GUESS_PRESET); + currentMod = *iter; + (*iter)->saveMeta(); + } + } + } + + const auto [modIndex, modInfo] = + doInstall(fileName, modName, currentMod, priority, false); + + if (modInfo != nullptr) { + modInfo->addInstalledFile(modID, fileID); + m_DownloadManager.markInstalled(index); + if (settings().interface().hideDownloadsAfterInstallation()) { + m_DownloadManager.removeDownload(index, false); + } + } + + return modInfo; + } catch (const std::exception& e) { + log::error("installDownload exception: type={}, what='{}'", + typeid(e).name(), e.what()); + reportError(tr("Installation failed: %1").arg(e.what())); + } + + return nullptr; +} + +ModInfo::Ptr OrganizerCore::installArchive(const QString& archivePath, int priority, + bool reinstallation, ModInfo::Ptr currentMod, + const QString& initModName) +{ + GuessedValue modName; + if (!initModName.isEmpty()) { + modName.update(initModName, GUESS_USER); + } + const auto [modIndex, modInfo] = + doInstall(archivePath, modName, currentMod, priority, reinstallation); + if (m_CurrentProfile == nullptr) { + return nullptr; + } + + if (modInfo != nullptr) { + auto dlIdx = m_DownloadManager.indexByName(QFileInfo(archivePath).fileName()); + if (dlIdx != -1) { + int const modId = m_DownloadManager.getModID(dlIdx); + int const fileId = m_DownloadManager.getFileInfo(dlIdx)->fileID; + modInfo->addInstalledFile(modId, fileId); + } + m_DownloadManager.markInstalled(archivePath); + } + return modInfo; +} + +QString OrganizerCore::resolvePath(const QString& fileName) const +{ + if (m_DirectoryStructure == nullptr) { + return {}; + } + const FileEntryPtr file = + m_DirectoryStructure->searchFile(ToWString(fileName), nullptr); + if (file.get() != nullptr) { + return ToQString(file->getFullPath()); + } else { + return {}; + } +} + +QStringList OrganizerCore::listDirectories(const QString& directoryName) const +{ + QStringList result; + DirectoryEntry* dir = m_DirectoryStructure; + if (!directoryName.isEmpty()) + dir = dir->findSubDirectoryRecursive(ToWString(directoryName)); + if (dir != nullptr) { + for (const auto& d : dir->getSubDirectories()) { + result.append(ToQString(d->getName())); + } + } + return result; +} + +QStringList +OrganizerCore::findFiles(const QString& path, + const std::function& filter) const +{ + QStringList result; + DirectoryEntry* dir = m_DirectoryStructure; + if (!path.isEmpty() && path != ".") + dir = dir->findSubDirectoryRecursive(ToWString(path)); + if (dir != nullptr) { + std::vector const files = dir->getFiles(); + for (FileEntryPtr const& file : files) { + QString const fullPath = ToQString(file->getFullPath()); + if (filter(ToQString(file->getName()))) { + result.append(fullPath); + } + } + } + return result; +} + +QStringList OrganizerCore::getFileOrigins(const QString& fileName) const +{ + QStringList result; + const FileEntryPtr file = + m_DirectoryStructure->searchFile(ToWString(fileName), nullptr); + + if (file.get() != nullptr) { + result.append( + ToQString(m_DirectoryStructure->getOriginByID(file->getOrigin()).getName())); + foreach (const auto& i, file->getAlternatives()) { + result.append( + ToQString(m_DirectoryStructure->getOriginByID(i.originID()).getName())); + } + } + return result; +} + +QList OrganizerCore::findFileInfos( + const QString& path, + const std::function& filter) const +{ + QList result; + DirectoryEntry* dir = m_DirectoryStructure; + if (!path.isEmpty() && path != ".") + dir = dir->findSubDirectoryRecursive(ToWString(path)); + if (dir != nullptr) { + std::vector const files = dir->getFiles(); + for (const FileEntryPtr& file : files) { + IOrganizer::FileInfo info; + info.filePath = ToQString(file->getFullPath()); + bool fromArchive = false; + info.origins.append(ToQString( + m_DirectoryStructure->getOriginByID(file->getOrigin(fromArchive)).getName())); + info.archive = fromArchive ? ToQString(file->getArchive().name()) : ""; + for (const auto& idx : file->getAlternatives()) { + info.origins.append( + ToQString(m_DirectoryStructure->getOriginByID(idx.originID()).getName())); + } + + if (filter(info)) { + result.append(info); + } + } + } + return result; +} + +DownloadManager* OrganizerCore::downloadManager() +{ + return &m_DownloadManager; +} + +PluginList* OrganizerCore::pluginList() +{ + return &m_PluginList; +} + +ModList* OrganizerCore::modList() +{ + return &m_ModList; +} + +bool OrganizerCore::previewFileWithAlternatives(QWidget* parent, QString fileName, + int selectedOrigin) +{ + fileName = QDir::fromNativeSeparators(fileName); + + // what we have is an absolute path to the file in its actual location (for the + // primary origin) what we want is the path relative to the virtual data directory + + // we need to look in the virtual directory for the file to make sure the info is up + // to date. + + // check if the file comes from the actual data folder instead of a mod + QDir const gameDirectory = managedGame()->dataDirectory().absolutePath(); + QString const relativePath = gameDirectory.relativeFilePath(fileName); + QDir const dirRelativePath = gameDirectory.relativeFilePath(fileName); + + // if the file is on a different drive the dirRelativePath will actually be an + // absolute path so we make sure that is not the case + if (!dirRelativePath.isAbsolute() && !relativePath.startsWith("..")) { + fileName = relativePath; + } else { + // crude: we search for the next slash after the base mod directory to skip + // everything up to the data-relative directory + int offset = settings().paths().mods().size() + 1; + offset = fileName.indexOf("/", offset); + fileName = fileName.mid(offset + 1); + } + + const FileEntryPtr file = + directoryStructure()->searchFile(ToWString(fileName), nullptr); + + if (file.get() == nullptr) { + reportError(tr("file not found: %1").arg(qUtf8Printable(fileName))); + return false; + } + + // Standalone top-level window (no QObject parent) to decouple from the + // enclosing ModInfoDialog stack frame. ApplicationModal stacks it on + // top of any already-open modal dialog. WA_DeleteOnClose cleans up. + auto* preview = new PreviewDialog(fileName, nullptr); + preview->setAttribute(Qt::WA_DeleteOnClose); + preview->setWindowModality(Qt::ApplicationModal); + (void)parent; + + auto addFunc = [&](int originId, std::wstring archiveName = L"") { + FilesOrigin const& origin = directoryStructure()->getOriginByID(originId); + QString const filePath = + QDir::fromNativeSeparators(ToQString(origin.getPath())) + "/" + fileName; + if (QFile::exists(filePath)) { + // it's very possible the file doesn't exist, because it's inside an archive. we + // don't support that + QWidget* wid = m_PluginContainer->previewGenerator().genPreview(filePath); + if (wid == nullptr) { + reportError(tr("failed to generate preview for %1").arg(filePath)); + } else { + preview->addVariant(ToQString(origin.getName()), wid); + } + } else if (!archiveName.empty()) { + auto archiveFile = directoryStructure()->searchFile(archiveName); + if (archiveFile.get() != nullptr) { + try { + libbsarch::bs_archive archiveLoader; + archiveLoader.load_from_disk(archiveFile->getFullPath()); + libbsarch::memory_blob const fileData = + archiveLoader.extract_to_memory(fileName.toStdWString()); + QByteArray const convertedFileData((char*)(fileData.data), fileData.size); + QWidget* wid = m_PluginContainer->previewGenerator().genArchivePreview( + convertedFileData, filePath); + if (wid == nullptr) { + reportError(tr("failed to generate preview for %1").arg(filePath)); + } else { + preview->addVariant(ToQString(origin.getName()), wid); + } + } catch (std::exception& e) { + } + } + } + }; + + if (selectedOrigin == -1) { + // don't bother with the vector of origins, just add them as they come + addFunc(file->getOrigin(), file->isFromArchive() ? file->getArchive().name() : L""); + for (const auto& alt : file->getAlternatives()) { + addFunc(alt.originID(), alt.isFromArchive() ? alt.archive().name() : L""); + } + } else { + std::vector origins; + + // start with the primary origin + origins.push_back(file->getOrigin()); + + // add other origins, push to front if it's the selected one + for (const auto& alt : file->getAlternatives()) { + if (alt.originID() == selectedOrigin) { + origins.insert(origins.begin(), alt.originID()); + } else { + origins.push_back(alt.originID()); + } + } + + // can't be empty; either the primary origin was the selected one, or it + // was one of the alternatives, which got inserted in front + + if (origins[0] != selectedOrigin) { + // sanity check, this shouldn't happen unless the caller passed an + // incorrect id + + log::warn("selected preview origin {} not found in list of alternatives", + selectedOrigin); + } + + for (int const id : origins) { + addFunc(id); + } + } + + if (preview->numVariants() > 0) { + preview->show(); + preview->raise(); + preview->activateWindow(); + return true; + } else { + delete preview; + QMessageBox::information(parent, tr("Sorry"), + tr("Sorry, can't preview anything. This function " + "currently does not support extracting from bsas.")); + + return false; + } +} + +bool OrganizerCore::previewFile(QWidget* parent, const QString& originName, + const QString& path) +{ + if (!QFile::exists(path)) { + reportError(tr("File '%1' not found.").arg(path)); + return false; + } + + QWidget* wid = m_PluginContainer->previewGenerator().genPreview(path); + if (wid == nullptr) { + reportError(tr("Failed to generate preview for %1").arg(path)); + return false; + } + + // Standalone top-level window — no QObject parent so lifetime is + // decoupled from the enclosing ModInfoDialog (which is stack-allocated + // in modlistviewactions.cpp, so passing &dialog as parent makes preview + // a child of a stack object). ApplicationModal makes preview stack on + // top of the outer modal ModInfoDialog — newest modal wins. Without + // modality, ModInfoDialog blocks all input on the preview. + // WA_DeleteOnClose handles cleanup when user closes it. + auto* preview = new PreviewDialog(path, nullptr); + preview->setAttribute(Qt::WA_DeleteOnClose); + preview->setWindowModality(Qt::ApplicationModal); + preview->addVariant(originName, wid); + preview->show(); + preview->raise(); + preview->activateWindow(); + (void)parent; + + return true; +} + +bool OrganizerCore::previewFileData(QWidget* parent, const QString& fileName, + const QByteArray& fileData) +{ + if (fileData.isEmpty()) { + return false; + } + + const QString ext = QFileInfo(fileName).suffix().toLower(); + if (!m_PluginContainer->previewGenerator().previewSupported(ext, true)) { + return false; + } + + QWidget* wid = + m_PluginContainer->previewGenerator().genArchivePreview(fileData, fileName); + if (wid == nullptr) { + return false; + } + + // Use QDialog::open() instead of exec(). open() is async window-modal — + // it shows the dialog modal to its parent but returns immediately without + // starting a nested QEventLoop. Nesting exec() inside a caller that's + // itself running in QDialog::exec() (mod info filetree → preview_bsa file + // tree → nif preview) has been causing event-loop unwinding to softlock + // on close. WA_DeleteOnClose cleans up automatically. + auto* preview = new PreviewDialog(fileName, parent); + preview->setAttribute(Qt::WA_DeleteOnClose); + preview->addVariant(QFileInfo(fileName).fileName(), wid); + preview->open(); + return true; +} + +boost::signals2::connection OrganizerCore::onAboutToRun( + const std::function& func) +{ + return m_AboutToRun.connect(func); +} + +boost::signals2::connection OrganizerCore::onFinishedRun( + const std::function& func) +{ + return m_FinishedRun.connect(func); +} + +boost::signals2::connection +OrganizerCore::onUserInterfaceInitialized(std::function const& func) +{ + return m_UserInterfaceInitialized.connect(func); +} + +boost::signals2::connection +OrganizerCore::onProfileCreated(std::function const& func) +{ + return m_ProfileCreated.connect(func); +} + +boost::signals2::connection OrganizerCore::onProfileRenamed( + std::function const& func) +{ + return m_ProfileRenamed.connect(func); +} + +boost::signals2::connection +OrganizerCore::onProfileRemoved(std::function const& func) +{ + return m_ProfileRemoved.connect(func); +} + +boost::signals2::connection +OrganizerCore::onProfileChanged(std::function const& func) +{ + return m_ProfileChanged.connect(func); +} + +boost::signals2::connection OrganizerCore::onPluginSettingChanged( + std::function const& func) +{ + return m_PluginSettingChanged.connect(func); +} + +boost::signals2::connection +OrganizerCore::onPluginEnabled(std::function const& func) +{ + return m_PluginEnabled.connect(func); +} + +boost::signals2::connection +OrganizerCore::onPluginDisabled(std::function const& func) +{ + return m_PluginDisabled.connect(func); +} + +boost::signals2::connection +OrganizerCore::onNextRefresh(std::function const& func, + RefreshCallbackGroup group, RefreshCallbackMode mode) +{ + if (m_DirectoryUpdate || mode == RefreshCallbackMode::FORCE_WAIT_FOR_REFRESH) { + return m_OnNextRefreshCallbacks.connect(static_cast(group), func); + } else { + func(); + return {}; + } +} + +void OrganizerCore::refresh(bool saveChanges) +{ + // don't lose changes! + if (saveChanges) { + m_CurrentProfile->writeModlistNow(true); + } + + updateModInfoFromDisc(); + m_CurrentProfile->refreshModStatus(); + + m_ModList.notifyChange(-1); + + refreshDirectoryStructure(); + + emit refreshTriggered(); +} + +void OrganizerCore::refreshESPList(bool force) +{ + onNextRefresh( + [this, force] { + TimeThis const tt("OrganizerCore::refreshESPList()"); + + m_CurrentProfile->writeModlist(); + + // clear list + try { + m_PluginList.refresh(m_CurrentProfile->name(), *m_DirectoryStructure, + m_CurrentProfile->getLockedOrderFileName(), force); + } catch (const std::exception& e) { + reportError(tr("Failed to refresh list of esps: %1").arg(e.what())); + } + }, + RefreshCallbackGroup::CORE, RefreshCallbackMode::RUN_NOW_IF_POSSIBLE); +} + +void OrganizerCore::refreshBSAList() +{ + TimeThis const tt("OrganizerCore::refreshBSAList()"); + + auto archives = gameFeatures().gameFeature(); + + if (archives != nullptr) { + m_ArchivesInit = false; + + // default archives are the ones enabled outside MO. if the list can't be + // found (which might + // happen if ini files are missing) use hard-coded defaults (preferrably the + // same the game would use) + m_DefaultArchives = archives->archives(m_CurrentProfile.get()); + if (m_DefaultArchives.empty()) { + m_DefaultArchives = archives->vanillaArchives(); + } + + m_ActiveArchives.clear(); + + auto iter = enabledArchives(); + m_ActiveArchives = toStringList(iter.begin(), iter.end()); + if (m_ActiveArchives.isEmpty()) { + m_ActiveArchives = m_DefaultArchives; + } + + if (m_UserInterface != nullptr) { + m_UserInterface->updateBSAList(m_DefaultArchives, m_ActiveArchives); + m_UserInterface->archivesWriter().write(); + } + + m_ArchivesInit = true; + } +} + +void OrganizerCore::refreshLists() +{ + if ((m_CurrentProfile != nullptr) && m_DirectoryStructure->isPopulated()) { + refreshESPList(true); + refreshBSAList(); + } // no point in refreshing lists if no files have been added to the directory + // tree +} + +void OrganizerCore::updateModActiveState(int index, bool active) +{ + QList modsToUpdate; + modsToUpdate.append(index); + updateModsActiveState(modsToUpdate, active); +} + +void OrganizerCore::updateModsActiveState(const QList& modIndices, + bool active) +{ + int enabled = 0; + for (auto index : modIndices) { + ModInfo::Ptr const modInfo = ModInfo::getByIndex(index); + QDir const dir(modInfo->absolutePath()); + for (const QString& esm : dir.entryList(QStringList() << "*.esm", QDir::Files)) { + const FileEntryPtr file = m_DirectoryStructure->findFile(ToWString(esm)); + if (file.get() == nullptr) { + log::warn("failed to activate {}", esm); + continue; + } + + if (active != m_PluginList.isEnabled(esm) && file->getAlternatives().empty()) { + m_PluginList.blockSignals(true); + m_PluginList.enableESP(esm, active); + m_PluginList.blockSignals(false); + } + } + + for (const QString& esl : dir.entryList(QStringList() << "*.esl", QDir::Files)) { + const FileEntryPtr file = m_DirectoryStructure->findFile(ToWString(esl)); + if (file.get() == nullptr) { + log::warn("failed to activate {}", esl); + continue; + } + + if (active != m_PluginList.isEnabled(esl) && file->getAlternatives().empty()) { + m_PluginList.blockSignals(true); + m_PluginList.enableESP(esl, active); + m_PluginList.blockSignals(false); + ++enabled; + } + } + QStringList const esps = dir.entryList(QStringList() << "*.esp", QDir::Files); + for (const QString& esp : esps) { + const FileEntryPtr file = m_DirectoryStructure->findFile(ToWString(esp)); + if (file.get() == nullptr) { + log::warn("failed to activate {}", esp); + continue; + } + + if (active != m_PluginList.isEnabled(esp) && file->getAlternatives().empty()) { + m_PluginList.blockSignals(true); + m_PluginList.enableESP(esp, active); + m_PluginList.blockSignals(false); + ++enabled; + } + } + } + if (active && (enabled > 1)) { + MessageDialog::showMessage( + tr("Multiple esps/esls activated, please check that they don't conflict."), + qApp->activeWindow()); + } + m_PluginList.refreshLoadOrder(); + // immediately save affected lists + m_PluginListsWriter.writeImmediately(false); +} + +void OrganizerCore::updateModInDirectoryStructure(unsigned int index, + ModInfo::Ptr modInfo) +{ + QMap allModInfo; + allModInfo[index] = modInfo; + updateModsInDirectoryStructure(allModInfo); +} + +void OrganizerCore::updateModsInDirectoryStructure( + QMap modInfo) +{ + std::vector entries; + + for (auto idx : modInfo.keys()) { + QString path = modInfo[idx]->absolutePath(); + QString const modDataDir = managedGame()->modDataDirectory(); + path = modDataDir.isEmpty() ? path : path + "/" + modDataDir; + entries.push_back({modInfo[idx]->name(), + path, + modInfo[idx]->stealFiles(), + {}, + m_CurrentProfile->getModPriority(idx)}); + } + + m_DirectoryRefresher->addMultipleModsFilesToStructure(m_DirectoryStructure, entries); + + DirectoryRefresher::cleanStructure(m_DirectoryStructure); + // need to refresh plugin list now so we can activate esps + refreshESPList(true); + // activate all esps of the specified mod so the bsas get activated along with + // it + m_PluginList.blockSignals(true); + updateModsActiveState(modInfo.keys(), true); + m_PluginList.blockSignals(false); + // now we need to refresh the bsa list and save it so there is no confusion + // about what archives are available and active + refreshBSAList(); + if (m_UserInterface != nullptr) { + m_UserInterface->archivesWriter().writeImmediately(false); + } + + std::vector archives = enabledArchives(); + m_DirectoryRefresher->setMods(m_CurrentProfile->getActiveMods(), + std::set(archives.begin(), archives.end())); + + // finally also add files from bsas to the directory structure + for (auto idx : modInfo.keys()) { + QString path = modInfo[idx]->absolutePath(); + QString const modDataDir = managedGame()->modDataDirectory(); + path = modDataDir.isEmpty() ? path : path + "/" + modDataDir; + m_DirectoryRefresher->addModBSAToStructure( + m_DirectoryStructure, modInfo[idx]->name(), + m_CurrentProfile->getModPriority(idx), path, modInfo[idx]->archives()); + } +} + +void OrganizerCore::loggedInAction(QWidget* parent, std::function f) +{ + if (NexusInterface::instance().getAccessManager()->validated()) { + f(); + } else if (!m_Settings.network().offlineMode()) { + QString apiKey; + if (GlobalSettings::nexusApiKey(apiKey)) { + doAfterLogin([f] { + f(); + }); + NexusInterface::instance().getAccessManager()->apiCheck(apiKey); + } else { + MessageDialog::showMessage(tr("You need to be logged in with Nexus"), parent); + } + } +} + +void OrganizerCore::requestDownload(const QUrl& url, QNetworkReply* reply) +{ + if (!m_PluginContainer) { + return; + } + for (IPluginModPage* modPage : m_PluginContainer->plugins()) { + if (m_PluginContainer->isEnabled(modPage)) { + ModRepositoryFileInfo* fileInfo = new ModRepositoryFileInfo(); + if (modPage->handlesDownload(url, reply->url(), *fileInfo)) { + fileInfo->repository = modPage->name(); + m_DownloadManager.addDownload(reply, fileInfo); + return; + } + } + } + + // no mod found that could handle the download. Is it a nexus mod? + if (url.host() == "www.nexusmods.com") { + QString gameName = ""; + int modID = 0; + int fileID = 0; + QRegularExpression const nameExp(R"(www\.nexusmods\.com/(\a+)/)"); + auto match = nameExp.match(url.toString()); + if (match.hasMatch()) { + gameName = match.captured(1); + } + QRegularExpression const modExp("mods/(\\d+)"); + match = modExp.match(url.toString()); + if (match.hasMatch()) { + modID = match.captured(1).toInt(); + } + QRegularExpression const fileExp("fid=(\\d+)"); + match = fileExp.match(url.toString()); + if (match.hasMatch()) { + fileID = match.captured(1).toInt(); + } + m_DownloadManager.addDownload(reply, + new ModRepositoryFileInfo(gameName, modID, fileID)); + } else { + if (QMessageBox::question(qApp->activeWindow(), tr("Download?"), + tr("A download has been started but no installed " + "page plugin recognizes it.\n" + "If you download anyway no information (i.e. " + "version) will be associated with the " + "download.\n" + "Continue?"), + QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { + m_DownloadManager.addDownload(reply, new ModRepositoryFileInfo()); + } + } +} + +PluginContainer& OrganizerCore::pluginContainer() const +{ + return *m_PluginContainer; +} + +GameFeatures& OrganizerCore::gameFeatures() const +{ + return pluginContainer().gameFeatures(); +} + +IPluginGame const* OrganizerCore::managedGame() const +{ + return m_GamePlugin; +} + +IOrganizer const* OrganizerCore::managedGameOrganizer() const +{ + return m_PluginContainer->requirements(m_GamePlugin).m_Organizer; +} + +std::vector OrganizerCore::enabledArchives() +{ + std::vector result; + if (settings().archiveParsing()) { + QFile archiveFile(m_CurrentProfile->getArchivesFileName()); + if (archiveFile.open(QIODevice::ReadOnly)) { + while (!archiveFile.atEnd()) { + result.push_back(QString::fromUtf8(archiveFile.readLine()).trimmed()); + } + archiveFile.close(); + } + } + return result; +} + +void OrganizerCore::refreshDirectoryStructure() +{ + if (m_DirectoryUpdate) { + log::debug("can't refresh, already in progress"); + return; + } + + log::debug("refreshing structure"); + m_DirectoryUpdate = true; + + m_CurrentProfile->writeModlistNow(true); + const auto activeModList = m_CurrentProfile->getActiveMods(); + const auto archives = enabledArchives(); + + m_DirectoryRefresher->setMods(activeModList, + std::set(archives.begin(), archives.end())); + + // runs refresh() in a thread + QTimer::singleShot(0, m_DirectoryRefresher.get(), &DirectoryRefresher::refresh); +} + +void OrganizerCore::onDirectoryRefreshed() +{ + log::debug("directory refreshed, finishing up"); + TimeThis const tt("OrganizerCore::onDirectoryRefreshed()"); + + DirectoryEntry* newStructure = m_DirectoryRefresher->stealDirectoryStructure(); + Q_ASSERT(newStructure != m_DirectoryStructure); + + if (newStructure == nullptr) { + // TODO: don't know why this happens, this slot seems to get called twice + // with only one emit + return; + } + + std::swap(m_DirectoryStructure, newStructure); + m_VirtualFileTree.invalidate(); + + if (m_StructureDeleter.joinable()) { + m_StructureDeleter.join(); + } + + m_StructureDeleter = MOShared::startSafeThread([=] { + log::debug("structure deleter thread start"); + delete newStructure; + log::debug("structure deleter thread done"); + }); + + log::debug("clearing caches"); + for (int i = 0; i < m_ModList.rowCount(); ++i) { + ModInfo::Ptr const modInfo = ModInfo::getByIndex(i); + modInfo->clearCaches(); + } + + // needs to be done before post refresh tasks + m_DirectoryUpdate = false; + + log::debug("running post refresh tasks"); + m_OnNextRefreshCallbacks(); + m_OnNextRefreshCallbacks.disconnect_all_slots(); + + if (m_CurrentProfile != nullptr) { + log::debug("refreshing lists"); + refreshLists(); + } + + emit directoryStructureReady(); + + log::debug("refresh done"); +} + +void OrganizerCore::clearCaches(std::vector const& indices) const +{ + const auto insert = [](auto& dest, const auto& from) { + dest.insert(from.begin(), from.end()); + }; + std::set allIndices; + for (const auto index : indices) { + ModInfo::Ptr const modInfo = ModInfo::getByIndex(index); + + if (m_CurrentProfile->modEnabled(index)) { + // if the mod is enabled, we need to first clear its cache so that + // getModOverwrite(), ..., returns the newly conflicting mods (in case + // the mod just got enabled) + modInfo->clearCaches(); + insert(allIndices, modInfo->getModOverwrite()); + insert(allIndices, modInfo->getModOverwritten()); + insert(allIndices, modInfo->getModArchiveOverwrite()); + insert(allIndices, modInfo->getModArchiveOverwritten()); + insert(allIndices, modInfo->getModArchiveLooseOverwrite()); + insert(allIndices, modInfo->getModArchiveLooseOverwritten()); + } else { + // if the mod is disabled, we need to first fetch the conflicting + // mods, and then clear the cache + insert(allIndices, modInfo->getModOverwrite()); + insert(allIndices, modInfo->getModOverwritten()); + insert(allIndices, modInfo->getModArchiveOverwrite()); + insert(allIndices, modInfo->getModArchiveOverwritten()); + insert(allIndices, modInfo->getModArchiveLooseOverwrite()); + insert(allIndices, modInfo->getModArchiveLooseOverwritten()); + modInfo->clearCaches(); + } + } + + for (const auto& index : allIndices) { + ModInfo::getByIndex(index)->clearCaches(); + } +} + +void OrganizerCore::modPrioritiesChanged(const QModelIndexList& indices) +{ + for (unsigned int i = 0; i < currentProfile()->numMods(); ++i) { + int const priority = currentProfile()->getModPriority(i); + if (currentProfile()->modEnabled(i)) { + ModInfo::Ptr const modInfo = ModInfo::getByIndex(i); + const auto name = MOBase::ToWString(modInfo->internalName()); + // priorities in the directory structure are one higher because data is 0 + if (directoryStructure()->originExists(name)) { + directoryStructure()->getOriginByName(name).setPriority(priority + 1); + } + } + } + refreshBSAList(); + currentProfile()->writeModlist(); + directoryStructure()->getFileRegister()->sortOrigins(); + + std::vector vindices; + + for (const auto& idx : indices) { + vindices.push_back(idx.data(ModList::IndexRole).toInt()); + } + + clearCaches(vindices); +} + +void OrganizerCore::modStatusChanged(unsigned int index) +{ + try { + ModInfo::Ptr const modInfo = ModInfo::getByIndex(index); + if (m_CurrentProfile->modEnabled(index)) { + updateModInDirectoryStructure(index, modInfo); + } else { + updateModActiveState(index, false); + if (m_DirectoryStructure->originExists(ToWString(modInfo->name()))) { + FilesOrigin& origin = + m_DirectoryStructure->getOriginByName(ToWString(modInfo->name())); + origin.enable(false); + } + if (m_UserInterface != nullptr) { + m_UserInterface->archivesWriter().write(); + } + } + + for (unsigned int i = 0; i < m_CurrentProfile->numMods(); ++i) { + ModInfo::Ptr const modInfo = ModInfo::getByIndex(i); + int const priority = m_CurrentProfile->getModPriority(i); + if (m_DirectoryStructure->originExists(ToWString(modInfo->name()))) { + // priorities in the directory structure are one higher because data is + // 0 + m_DirectoryStructure->getOriginByName(ToWString(modInfo->name())) + .setPriority(priority + 1); + } + } + m_DirectoryStructure->getFileRegister()->sortOrigins(); + + refreshLists(); + clearCaches({index}); + m_ModList.notifyModStateChanged({index}); + + } catch (const std::exception& e) { + reportError(tr("failed to update mod list: %1").arg(e.what())); + } +} + +void OrganizerCore::modStatusChanged(QList index) +{ + try { + QMap modsToEnable; + QMap modsToDisable; + std::vector vindices; + for (auto idx : index) { + if (m_CurrentProfile->modEnabled(idx)) { + modsToEnable[idx] = ModInfo::getByIndex(idx); + } else { + modsToDisable[idx] = ModInfo::getByIndex(idx); + } + vindices.push_back(idx); + } + if (!modsToEnable.isEmpty()) { + updateModsInDirectoryStructure(modsToEnable); + } + if (!modsToDisable.isEmpty()) { + updateModsActiveState(modsToDisable.keys(), false); + for (auto idx : modsToDisable.keys()) { + if (m_DirectoryStructure->originExists(ToWString(modsToDisable[idx]->name()))) { + FilesOrigin& origin = m_DirectoryStructure->getOriginByName( + ToWString(modsToDisable[idx]->name())); + origin.enable(false); + } + } + if (m_UserInterface != nullptr) { + m_UserInterface->archivesWriter().write(); + } + } + + for (unsigned int i = 0; i < m_CurrentProfile->numMods(); ++i) { + ModInfo::Ptr const modInfo = ModInfo::getByIndex(i); + int const priority = m_CurrentProfile->getModPriority(i); + if (m_DirectoryStructure->originExists(ToWString(modInfo->name()))) { + // priorities in the directory structure are one higher because data is + // 0 + m_DirectoryStructure->getOriginByName(ToWString(modInfo->name())) + .setPriority(priority + 1); + } + } + m_DirectoryStructure->getFileRegister()->sortOrigins(); + + refreshLists(); + clearCaches(vindices); + m_ModList.notifyModStateChanged(index); + + } catch (const std::exception& e) { + reportError(tr("failed to update mod list: %1").arg(e.what())); + } +} + +void OrganizerCore::loginSuccessful(bool necessary) +{ + if (necessary) { + MessageDialog::showMessage(tr("login successful"), qApp->activeWindow()); + } + for (const QString& url : m_PendingDownloads) { + downloadRequestedNXM(url); + } + m_PendingDownloads.clear(); + for (const auto& task : m_PostLoginTasks) { + task(); + } + + m_PostLoginTasks.clear(); + NexusInterface::instance().loginCompleted(); +} + +void OrganizerCore::loginSuccessfulUpdate(bool necessary) +{ + if (necessary) { + MessageDialog::showMessage(tr("login successful"), qApp->activeWindow()); + } + m_Updater.startUpdate(); +} + +void OrganizerCore::loginFailed(const QString& message) +{ + qCritical().nospace().noquote() << "Nexus API validation failed: " << message; + + if (QMessageBox::question(qApp->activeWindow(), tr("Login failed"), + tr("Login failed, try again?")) == QMessageBox::Yes) { + if (nexusApi(true)) { + return; + } + } + + if (!m_PendingDownloads.isEmpty()) { + MessageDialog::showMessage( + tr("login failed: %1. Download will not be associated with an account") + .arg(message), + qApp->activeWindow()); + for (const QString& url : m_PendingDownloads) { + downloadRequestedNXM(url); + } + m_PendingDownloads.clear(); + } else { + MessageDialog::showMessage(tr("login failed: %1").arg(message), + qApp->activeWindow()); + m_PostLoginTasks.clear(); + } + NexusInterface::instance().loginCompleted(); +} + +void OrganizerCore::loginFailedUpdate(const QString& message) +{ + MessageDialog::showMessage( + tr("login failed: %1. You need to log-in with Nexus to update MO.").arg(message), + qApp->activeWindow()); +} + +void OrganizerCore::syncOverwrite() +{ + ModInfo::Ptr const modInfo = ModInfo::getOverwrite(); + + // Snapshot overwrite before sync so we can detect what was moved. + QStringList beforeFiles; + { + QDirIterator it(modInfo->absolutePath(), QDir::Files | QDir::NoDotAndDotDot, + QDirIterator::Subdirectories); + while (it.hasNext()) { + it.next(); + beforeFiles << QDir(modInfo->absolutePath()).relativeFilePath(it.filePath()); + } + } + + SyncOverwriteDialog syncDialog(modInfo->absolutePath(), m_DirectoryStructure, + qApp->activeWindow()); + if (syncDialog.exec() == QDialog::Accepted) { + syncDialog.apply(QDir::fromNativeSeparators(m_Settings.paths().mods())); + + // Track files that were moved out of overwrite to mods. Files that existed + // before but are gone now were synced to a mod. Use profile priority order + // (highest-priority mod wins) so writes go to the correct mod when + // multiple mods contain the same path. + const QString modsDir = QDir::fromNativeSeparators(m_Settings.paths().mods()); + + // Mod names in ascending priority order (last = highest priority). + const QStringList modsByPriority = + m_ModList.allModsByProfilePriority(m_CurrentProfile.get()); + + for (const auto& relPath : beforeFiles) { + const QString owFile = modInfo->absolutePath() + "/" + relPath; + if (!QFile::exists(owFile)) { + QString bestModPath; + for (const auto& modName : modsByPriority) { + const QString modPath = modsDir + "/" + modName; + if (QFile::exists(modPath + "/" + relPath)) { + bestModPath = modPath; + // Don't break — keep going to find the highest-priority match. + } + } + if (!bestModPath.isEmpty()) { + trackOverwriteMove(relPath, bestModPath); + } + } + } + + modInfo->diskContentModified(); + refreshDirectoryStructure(); + } +} + +QString OrganizerCore::oldMO1HookDll() const +{ + if (auto extender = gameFeatures().gameFeature()) { + QString hookdll = + QDir::toNativeSeparators(managedGame()->dataDirectory().absoluteFilePath( + extender->PluginPath() + "/hook.dll")); + if (QFile(hookdll).exists()) + return hookdll; + } + return {}; +} + +std::vector OrganizerCore::activeProblems() const +{ + std::vector problems; + const auto& hookdll = oldMO1HookDll(); + if (!hookdll.isEmpty()) { + // This warning will now be shown every time the problems are checked, which is a + // bit of a "log spam". But since this is a sever error which will most likely make + // the game crash/freeze/etc. and is very hard to diagnose, this "log spam" will + // make it easier for the user to notice the warning. + log::warn("hook.dll found in game folder: {}", hookdll); + problems.push_back(PROBLEM_MO1SCRIPTEXTENDERWORKAROUND); + } + return problems; +} + +QString OrganizerCore::shortDescription(unsigned int key) const +{ + switch (key) { + case PROBLEM_MO1SCRIPTEXTENDERWORKAROUND: { + return tr( + "MO1 \"Script Extender\" load mechanism has left hook.dll in your game folder"); + } break; + default: { + return tr("Description missing"); + } break; + } +} + +QString OrganizerCore::fullDescription(unsigned int key) const +{ + switch (key) { + case PROBLEM_MO1SCRIPTEXTENDERWORKAROUND: { + return tr("hook.dll has been found in your game folder (right " + "click to copy the full path). " + "This is most likely a leftover of setting the ModOrganizer 1 load " + "mechanism to \"Script Extender\", " + "in which case you must remove this file either by changing the load " + "mechanism in ModOrganizer 1 or " + "manually removing the file, otherwise the game is likely to crash and " + "burn.") + .arg(oldMO1HookDll()); + break; + } + default: { + return tr("Description missing"); + } break; + } +} + +bool OrganizerCore::hasGuidedFix(unsigned int) const +{ + return false; +} + +void OrganizerCore::startGuidedFix(unsigned int) const {} + +bool OrganizerCore::saveCurrentLists() +{ + if (m_DirectoryUpdate) { + log::warn("not saving lists during directory update"); + return false; + } + + try { + savePluginList(); + if (m_UserInterface != nullptr) { + m_UserInterface->archivesWriter().write(); + } + } catch (const std::exception& e) { + reportError(tr("failed to save load order: %1").arg(e.what())); + } + + return true; +} + +void OrganizerCore::savePluginList() +{ + onNextRefresh( + [this]() { + m_PluginList.saveTo(m_CurrentProfile->getLockedOrderFileName()); + m_PluginList.saveLoadOrder(*m_DirectoryStructure); + }, + RefreshCallbackGroup::CORE, RefreshCallbackMode::RUN_NOW_IF_POSSIBLE); +} + +void OrganizerCore::saveCurrentProfile() +{ + if (m_CurrentProfile == nullptr) { + return; + } + + m_CurrentProfile->writeModlist(); + m_CurrentProfile->createTweakedIniFile(); + saveCurrentLists(); + storeSettings(); +} + +ProcessRunner OrganizerCore::processRunner() +{ + return ProcessRunner(*this, m_UserInterface); +} + +bool OrganizerCore::checkGameRegistryKey() +{ + // Map of game short names to their registry key info. + // Format: { shortName, { subKey, valueName } } + static const QMap> gameRegistryKeys = { + {"Enderal", {"Software\\SureAI\\Enderal", "Install_Path"}}, + {"EnderalSE", {"Software\\SureAI\\EnderalSE", "Install_Path"}}, + {"Fallout3", {"Software\\Bethesda Softworks\\Fallout3", "installed path"}}, + {"Fallout4", {"Software\\Bethesda Softworks\\Fallout4", "installed path"}}, + {"Fallout4VR", {"Software\\Bethesda Softworks\\Fallout 4 VR", "installed path"}}, + {"FalloutNV", {"Software\\Bethesda Softworks\\FalloutNV", "installed path"}}, + {"Morrowind", {"Software\\Bethesda Softworks\\Morrowind", "installed path"}}, + {"Oblivion", {"Software\\Bethesda Softworks\\Oblivion", "installed path"}}, + {"Skyrim", {"Software\\Bethesda Softworks\\Skyrim", "installed path"}}, + {"SkyrimSE", {"Software\\Bethesda Softworks\\Skyrim Special Edition", "installed path"}}, + {"SkyrimVR", {"Software\\Bethesda Softworks\\Skyrim VR", "installed path"}}, + {"TTW", {"Software\\Bethesda Softworks\\FalloutNV", "installed path"}}, + }; + + const auto* game = managedGame(); + if (!game) return true; + + const QString shortName = game->gameShortName(); + auto it = gameRegistryKeys.find(shortName); + if (it == gameRegistryKeys.end()) { + return true; // unknown game, nothing to check + } + + auto cfg = FluorineConfig::load(); + if (!cfg || cfg->prefix_path.isEmpty()) { + return true; // no prefix configured + } + + WinePrefix const prefix(cfg->prefix_path); + if (!prefix.isValid()) { + return true; // prefix doesn't exist yet + } + + const QString& subKey = it.value().first; + const QString& valueName = it.value().second; + + // The game directory MO2 is configured to use — convert to Wine path + const QString gameDir = game->gameDirectory().canonicalPath(); + if (gameDir.isEmpty()) { + return true; + } + + // Convert Linux path to Wine Z: path for comparison. + // Trailing backslash required — game launchers expect it (matches Steam's format). + QString winePath = "Z:" + QString(gameDir).replace("/", "\\"); + if (!winePath.endsWith('\\')) + winePath += '\\'; + + // Read the current registry value (check both normal and Wow6432Node) + QString registryPath = prefix.readHklmValue(subKey, valueName); + if (registryPath.isEmpty()) { + const QString wow64Key = "Software\\Wow6432Node\\" + subKey.mid(9); + registryPath = prefix.readHklmValue(wow64Key, valueName); + } + + // Normalize trailing separators before comparing — Steam writes paths with + // a trailing backslash, so the registry value may differ only in that. + auto stripTrailingSep = [](QString s) { + while (s.endsWith('\\') || s.endsWith('/')) + s.chop(1); + return s; + }; + + if (registryPath.isEmpty() || + stripTrailingSep(registryPath).compare(stripTrailingSep(winePath), + Qt::CaseInsensitive) != 0) { + const QString displayRegPath = registryPath.isEmpty() + ? tr("") : registryPath; + + QWidget* parent = nullptr; + if (m_UserInterface) { + parent = m_UserInterface->mainWindow(); + } + + const auto answer = QMessageBox::question( + parent, + tr("Registry key does not match"), + tr("The game's installation path in the Wine registry does not match " + "the managed game path.\n\n" + "Registry Game Path:\n\t%1\n" + "Managed Game Path:\n\t%2\n\n" + "Change the path in the registry to match the managed game path?") + .arg(displayRegPath, winePath), + QMessageBox::Yes | QMessageBox::No | QMessageBox::Cancel, + QMessageBox::Yes); + + if (answer == QMessageBox::Yes) { + if (!prefix.writeHklmValue(subKey, valueName, winePath)) { + log::error("Failed to update game registry key"); + } + // Also update the Wow6432Node copy (32-bit registry view) + const QString wow64Key = "Software\\Wow6432Node\\" + subKey.mid(9); // skip "Software\\" + prefix.writeHklmValue(wow64Key, valueName, winePath); + } else if (answer == QMessageBox::Cancel) { + return false; // cancel launch + } + } + + return true; +} + +bool OrganizerCore::beforeRun( + const QFileInfo& binary, const QDir& cwd, const QString& arguments, + const QString& profileName, const QString& customOverwrite, + const QList& forcedLibraries, + QString* saveBindMountSource, QString* saveBindMountTarget) +{ + saveCurrentProfile(); + if (saveBindMountSource) saveBindMountSource->clear(); + if (saveBindMountTarget) saveBindMountTarget->clear(); + + // need to wait until directory structure is ready + if (m_DirectoryUpdate) { + QEventLoop loop; + connect(this, &OrganizerCore::directoryStructureReady, &loop, &QEventLoop::quit, + Qt::ConnectionType::QueuedConnection); + loop.exec(); + } + + // need to make sure all data is saved before we start the application + if (m_CurrentProfile != nullptr) { + m_CurrentProfile->writeModlistNow(true); + } + + if (!m_AboutToRun(binary.absoluteFilePath(), cwd, arguments)) { + log::debug("start of \"{}\" cancelled by plugin", binary.absoluteFilePath()); + return false; + } + + // Check the game's registry key in the Wine prefix and fix if needed. + if (!checkGameRegistryKey()) { + return false; // user cancelled + } + + // VFS Root Builder: read per-instance setting and configure. + { + bool vfsRootBuilder = false; + if (const auto* s = Settings::maybeInstance()) { + const QSettings instanceIni(s->filename(), QSettings::IniFormat); + vfsRootBuilder = instanceIni.value("fluorine/vfs_root_builder", true).toBool(); + } + const QString storageDir = + QDir(QDir::fromNativeSeparators(basePath())).filePath("rootbuilder"); + m_USVFS.setRootBuilderEnabled(vfsRootBuilder, storageDir.toStdString()); + } + + try { + m_USVFS.updateMapping(fileMapping(profileName, customOverwrite)); + m_USVFS.updateForcedLibraries(forcedLibraries); + } catch (const FuseConnectorException& e) { + log::error("VFS mount failed: {}", e.what()); + return false; + } catch (const std::exception& e) { + QWidget* w = nullptr; + if (m_UserInterface) { + w = m_UserInterface->mainWindow(); + } + QMessageBox::warning(w, tr("Error"), e.what()); + return false; + } + + // Deploy plugins.txt and loadorder.txt to the Wine prefix before launch. + if (m_CurrentProfile != nullptr) { + const QString prefixPathStr = resolveWinePrefixPath(m_Settings, managedGame()); + + if (!prefixPathStr.isEmpty()) { + WinePrefix const prefix(prefixPathStr); + if (prefix.isValid()) { + const QString dataDirName = resolveWineDataDirName(managedGame()); + const QString appDataLocal = prefix.appdataLocal(); + const QString pluginsTargetDir = + QDir(appDataLocal).filePath(dataDirName); + const QString documentsDir = + resolvePrefixGameDocumentsDir(prefix, dataDirName); + log::info("Wine prefix paths: AppData/Local plugins dir='{}', " + "Documents/My Games INI dir='{}'", + pluginsTargetDir, documentsDir); + const auto localSavesFeature = gameFeatures().gameFeature(); + const QString absoluteSaveDir = + resolveAbsoluteSaveDir(prefix, managedGame(), + localSavesFeature.get(), m_CurrentProfile); + log::info("Wine prefix save target: '{}'", absoluteSaveDir); + + // If the prefix's plugin-list file is newer than the profile's + // (e.g. LOOT ran outside MO2 and edited it), sync back first so + // external edits aren't clobbered by the deploy below. + { + WinePrefix::PluginListMechanism preMech = + WinePrefix::PluginListMechanism::None; + switch (managedGame()->loadOrderMechanism()) { + case IPluginGame::LoadOrderMechanism::PluginsTxt: + preMech = WinePrefix::PluginListMechanism::PluginsTxt; + break; + case IPluginGame::LoadOrderMechanism::FileTime: + preMech = WinePrefix::PluginListMechanism::FileTime; + break; + case IPluginGame::LoadOrderMechanism::None: + break; + } + if (preMech != WinePrefix::PluginListMechanism::None) { + const QString profilePluginsPath = + m_CurrentProfile->getPluginsFileName(); + const QDateTime prefixMTime = + prefix.prefixPluginsMTime(dataDirName); + const QDateTime profileMTime = + QFileInfo(profilePluginsPath).lastModified(); + if (prefixMTime.isValid() && + (!profileMTime.isValid() || prefixMTime > profileMTime)) { + log::info("Prefix plugins.txt newer than profile " + "(prefix={}, profile={}), syncing back before deploy", + prefixMTime.toString(Qt::ISODate), + profileMTime.toString(Qt::ISODate)); + if (prefix.syncPluginsBack(profilePluginsPath, dataDirName, + preMech)) { + refreshESPList(true); + } else { + log::warn("Pre-deploy sync-back failed; proceeding with " + "deploy — external edits may be lost"); + } + } + } + } + + // Read plugin lines from profile's plugins.txt + QFile pluginsFile(m_CurrentProfile->getPluginsFileName()); + if (pluginsFile.open(QIODevice::ReadOnly | QIODevice::Text)) { + QStringList plugins; + QTextStream stream(&pluginsFile); + while (!stream.atEnd()) { + const QString line = stream.readLine().trimmed(); + if (!line.isEmpty() && !line.startsWith('#')) { + plugins.append(line); + } + } + pluginsFile.close(); + + if (!plugins.isEmpty()) { + WinePrefix::PluginListMechanism wineMech = + WinePrefix::PluginListMechanism::None; + switch (managedGame()->loadOrderMechanism()) { + case IPluginGame::LoadOrderMechanism::PluginsTxt: + wineMech = WinePrefix::PluginListMechanism::PluginsTxt; + break; + case IPluginGame::LoadOrderMechanism::FileTime: + wineMech = WinePrefix::PluginListMechanism::FileTime; + break; + case IPluginGame::LoadOrderMechanism::None: + wineMech = WinePrefix::PluginListMechanism::None; + break; + } + const QString pluginListFile = + wineMech == WinePrefix::PluginListMechanism::PluginsTxt + ? QString("Plugins.txt") + : QString("plugins.txt"); + log::info("Deploying plugin list to '{}'", + QDir(pluginsTargetDir).filePath(pluginListFile)); + if (prefix.deployPlugins(plugins, dataDirName, wineMech)) { + log::debug("Deployed {} plugins to prefix '{}' (dataDirName='{}')", + plugins.size(), prefixPathStr, dataDirName); + } else { + log::error("Failed to deploy {} plugins to prefix '{}' " + "(dataDirName='{}')", + plugins.size(), prefixPathStr, dataDirName); + } + } else { + log::warn("Profile plugins.txt is empty or contains only comments, " + "skipping plugin deployment"); + } + } + + if (m_CurrentProfile->localSettingsEnabled()) { + const QString targetIniBase = + resolvePrefixGameDocumentsDir(prefix, dataDirName); + int deployedIniCount = 0; + for (const QString& iniFile : managedGame()->iniFiles()) { + const QString sourceIni = + m_CurrentProfile->absoluteIniFilePath(iniFile); + const QString targetIni = + QDir(targetIniBase).filePath(QFileInfo(iniFile).fileName()); + log::info("INI deploy target: '{}' -> '{}' (exists={})", sourceIni, + targetIni, QFileInfo::exists(sourceIni)); + if (QFileInfo::exists(sourceIni) && + prefix.deployProfileIni(sourceIni, targetIni)) { + ++deployedIniCount; + log::debug("Deployed profile INI '{}' -> '{}'", sourceIni, targetIni); + } + } + if (deployedIniCount > 0) { + log::debug("Deployed {} profile INI files to prefix '{}'", deployedIniCount, + prefixPathStr); + } + } else { + log::debug("Profile local settings not enabled, skipping INI deployment. " + "documentsDirectory='{}'", + managedGame()->documentsDirectory().absolutePath()); + } + + if (m_CurrentProfile->localSavesEnabled()) { + const QString profileSavesDir = + QDir(m_CurrentProfile->absolutePath()).filePath("saves"); + + const bool useBindMount = + saveBindMountSource && saveBindMountTarget && + ProtonLauncher::unprivilegedBindMountSupported(); + + if (useBindMount) { + // Clean up any stale symlink from previous symlink-based runs: + // mount --bind won't traverse a symlink as the target. + if (QFileInfo(absoluteSaveDir).isSymLink()) { + QFile::remove(absoluteSaveDir); + } + const QFileInfo leafInfo(absoluteSaveDir); + const QString lowerSaveDir = + QDir(leafInfo.dir().absolutePath()) + .filePath(leafInfo.fileName().toLower()); + if (lowerSaveDir != absoluteSaveDir && + QFileInfo(lowerSaveDir).isSymLink()) { + QFile::remove(lowerSaveDir); + } + + // Both endpoints must exist before the kernel can bind them. + QDir().mkpath(profileSavesDir); + QDir().mkpath(absoluteSaveDir); + + *saveBindMountSource = profileSavesDir; + *saveBindMountTarget = absoluteSaveDir; + log::info("Save bind mount: '{}' -> '{}'", profileSavesDir, + absoluteSaveDir); + } else { + log::info("Save deploy target: '{}' -> '{}'", profileSavesDir, + absoluteSaveDir); + if (!prefix.deployProfileSaves(profileSavesDir, absoluteSaveDir, + true)) { + log::warn("Failed to deploy profile saves from '{}' to " + "prefix '{}'", + profileSavesDir, prefixPathStr); + } + } + + // Ensure the prefix INI points to __MO_Saves so the game reads + // profile-specific saves, even when localSettingsEnabled() is off. + const QStringList iniFiles = managedGame()->iniFiles(); + if (!iniFiles.isEmpty()) { + const QString prefixIni = + QDir(resolvePrefixGameDocumentsDir(prefix, dataDirName)) + .filePath(QFileInfo(iniFiles.first()).fileName()); + MOBase::WriteRegistryValue("General", "sLocalSavePath", + "__MO_Saves\\", prefixIni); + MOBase::WriteRegistryValue("General", "bUseMyGamesDirectory", + "1", prefixIni); + log::debug("Patched prefix INI '{}': sLocalSavePath=__MO_Saves\\", + prefixIni); + } + } else { + // Local saves disabled — restore default sLocalSavePath in prefix INI + const QStringList iniFiles = managedGame()->iniFiles(); + if (!iniFiles.isEmpty()) { + const QString prefixIni = + QDir(resolvePrefixGameDocumentsDir(prefix, dataDirName)) + .filePath(QFileInfo(iniFiles.first()).fileName()); + MOBase::WriteRegistryValue("General", "sLocalSavePath", + "Saves\\", prefixIni); + log::debug("Restored prefix INI '{}': sLocalSavePath=Saves\\", + prefixIni); + } + } + } else { + log::warn("Wine prefix at '{}' is not valid (no drive_c)", prefixPathStr); + } + } else { + log::debug("No Wine prefix configured, skipping plugin deployment"); + } + } + + return true; +} + +void OrganizerCore::afterRun(const QFileInfo& binary, DWORD exitCode) +{ + const bool fileTimeLoadOrder = + managedGame()->loadOrderMechanism() == + IPluginGame::LoadOrderMechanism::FileTime; + + // Unmount the FUSE VFS now that the application has exited. unmount() + // flushes the staging directory (moves new/changed files to overwrite) + // and tears down the FUSE session. This mirrors Windows behaviour where + // USVFS is only active while a hooked process is running. + m_USVFS.unmount(); + + // Restore write permissions on the game directory. In rare cases + // (crashes, unclean Wine shutdown) file permissions can be changed to + // read-only, preventing subsequent launches from working. + { + const auto t0 = std::chrono::steady_clock::now(); + const QString gameDir = managedGame()->gameDirectory().absolutePath(); + namespace fs = std::filesystem; + std::error_code ec; + for (auto it = fs::recursive_directory_iterator( + gameDir.toStdString(), fs::directory_options::skip_permission_denied); + it != fs::recursive_directory_iterator(); ++it) { + fs::permissions(it->path(), + fs::perms::owner_read | fs::perms::owner_write | fs::perms::owner_exec, + fs::perm_options::add, ec); + } + const auto ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - t0).count(); + std::fprintf(stderr, "[VFS] restored game directory permissions in %lldms\n", + static_cast(ms)); + } + + if (m_CurrentProfile != nullptr) { + const QString prefixPathStr = resolveWinePrefixPath(m_Settings, managedGame()); + if (!prefixPathStr.isEmpty()) { + WinePrefix const prefix(prefixPathStr); + if (prefix.isValid()) { + const QString dataDirName = resolveWineDataDirName(managedGame()); + const auto localSavesFeature = gameFeatures().gameFeature(); + const QString absoluteSaveDir = + resolveAbsoluteSaveDir(prefix, managedGame(), + localSavesFeature.get(), m_CurrentProfile); + + if (m_CurrentProfile->localSavesEnabled()) { + const QString profileSavesDir = + QDir(m_CurrentProfile->absolutePath()).filePath("saves"); + + // Bind-mount path: writes already landed live in the profile via + // the per-launch mount namespace, which teardown automatically + // when the game exited. Nothing to sync and nothing to undeploy. + // The symlink path below only runs on kernels without + // unprivileged user namespaces. + const bool usedBindMount = + !QFileInfo(absoluteSaveDir).isSymLink() && + ProtonLauncher::unprivilegedBindMountSupported(); + if (!usedBindMount) { + log::info("Save sync target: '{}' <- '{}'", profileSavesDir, + absoluteSaveDir); + if (!prefix.syncSavesBack(profileSavesDir, absoluteSaveDir)) { + log::warn("Failed to sync saves back from prefix '{}' to '{}'", + prefixPathStr, profileSavesDir); + } + + // Remove the __MO_Saves symlinks and revert the prefix INI so a + // vanilla launch outside MO2 uses the default Saves dir. + prefix.undeployProfileSaves(absoluteSaveDir); + } + const QStringList iniFiles = managedGame()->iniFiles(); + if (!iniFiles.isEmpty()) { + const QString prefixIni = + QDir(resolvePrefixGameDocumentsDir(prefix, dataDirName)) + .filePath(QFileInfo(iniFiles.first()).fileName()); + MOBase::WriteRegistryValue("General", "sLocalSavePath", + "Saves\\", prefixIni); + log::debug("Restored prefix INI '{}': sLocalSavePath=Saves\\", + prefixIni); + } + } + + if (m_CurrentProfile->localSettingsEnabled()) { + const QString targetIniBase = + resolvePrefixGameDocumentsDir(prefix, dataDirName); + QList> iniMappings; + for (const QString& iniFile : managedGame()->iniFiles()) { + const QString profileIni = + m_CurrentProfile->absoluteIniFilePath(iniFile); + const QString targetIni = + QDir(targetIniBase).filePath(QFileInfo(iniFile).fileName()); + iniMappings.append({profileIni, targetIni}); + log::info("INI sync target: '{}' <- '{}'", profileIni, targetIni); + } + + if (!iniMappings.isEmpty() && + !prefix.syncProfileInisBack(iniMappings)) { + log::warn("Failed to sync profile INIs back from prefix '{}'", + prefixPathStr); + } + } + + // Sync the game's plugin-list file back from the prefix. LOOT and + // similar tools edit the deployed copy in AppData/Local// — + // without this sync, their changes are lost when refreshESPList + // rereads the untouched profile copy and savePluginList clobbers + // them with the old in-memory order. loadorder.txt is MO2-internal + // and not written to or read from the prefix. + WinePrefix::PluginListMechanism wineMech = + WinePrefix::PluginListMechanism::None; + switch (managedGame()->loadOrderMechanism()) { + case IPluginGame::LoadOrderMechanism::PluginsTxt: + wineMech = WinePrefix::PluginListMechanism::PluginsTxt; + break; + case IPluginGame::LoadOrderMechanism::FileTime: + wineMech = WinePrefix::PluginListMechanism::FileTime; + break; + case IPluginGame::LoadOrderMechanism::None: + wineMech = WinePrefix::PluginListMechanism::None; + break; + } + const QString profilePluginsPath = m_CurrentProfile->getPluginsFileName(); + if (!prefix.syncPluginsBack(profilePluginsPath, dataDirName, wineMech)) { + log::warn("Failed to sync plugins.txt back from prefix '{}'", + prefixPathStr); + } + } + } + } + + // FileTime-based games (Skyrim LE, FO3, FNV) derive the load order from + // plugin file mtimes rather than loadorder.txt. Drop the profile copy so + // refreshESPList falls back to file times — LOOT updates those directly + // on the .esp files via FUSE setattr. + if (fileTimeLoadOrder) { + log::debug("removing loadorder.txt (FileTime load order mechanism)"); + QFile::remove(m_CurrentProfile->getLoadOrderFileName()); + } + + // Refresh directory structure after VFS is unmounted so the refresher + // reads the real (vanilla) data directory plus individual mod directories, + // matching Windows USVFS behaviour. + refreshDirectoryStructure(); + + refreshESPList(true); + savePluginList(); + cycleDiagnostics(); + + // These callbacks should not fiddle with directory structure and ESPs. + m_FinishedRun(binary.absoluteFilePath(), exitCode); +} + +ProcessRunner::Results OrganizerCore::waitForAllUSVFSProcesses(UILocker::Reasons reason) +{ + // The Win32 path called waitForAllUSVFSProcessesWithLock(), which iterated + // every USVFS-hooked process via the job-handle bookkeeping. The Linux + // FUSE-based VFS doesn't have a process job so there's nothing to wait + // for here — ProcessRunner::run() already handles the per-launch wait. + Q_UNUSED(reason); + return ProcessRunner::Completed; +} + +std::vector OrganizerCore::fileMapping(const QString& profileName, + const QString& customOverwrite) +{ + // need to wait until directory structure is ready + if (m_DirectoryUpdate) { + QEventLoop loop; + connect(this, &OrganizerCore::directoryStructureReady, &loop, &QEventLoop::quit, + Qt::ConnectionType::QueuedConnection); + loop.exec(); + } + + IPluginGame* game = qApp->property("managed_game").value(); + Profile profile(QDir(m_Settings.paths().profiles() + "/" + profileName), game, + gameFeatures()); + + MappingType result; + + auto dataMaps = game->getModMappings(); + + bool overwriteActive = false; + + for (const auto& mod : profile.getActiveMods()) { + if (std::get<0>(mod).compare("overwrite", Qt::CaseInsensitive) == 0) { + continue; + } + + unsigned int const modIndex = ModInfo::getIndex(std::get<0>(mod)); + ModInfo::Ptr const modPtr = ModInfo::getByIndex(modIndex); + + bool const createTarget = customOverwrite == std::get<0>(mod); + QDir const modDir = QDir(std::get<1>(mod)); + + overwriteActive |= createTarget; + + if (modPtr->isRegular()) { + for (auto dataMap : dataMaps.asKeyValueRange()) { + auto mapDir = QDir(modDir.absoluteFilePath(dataMap.first)); + if (mapDir.exists()) { + for (const auto& dir : dataMap.second) { + result.insert(result.end(), + {mapDir.absolutePath(), dir, true, createTarget}); + } + } + } + } + } + + if (!overwriteActive && !customOverwrite.isEmpty()) { + throw MyException( + tr("The designated write target \"%1\" is not enabled.").arg(customOverwrite)); + } + + if (m_CurrentProfile->localSavesEnabled()) { + auto localSaves = gameFeatures().gameFeature(); + if (localSaves != nullptr) { + MappingType saveMap = + localSaves->mappings(currentProfile()->absolutePath() + "/saves"); + result.reserve(result.size() + saveMap.size()); + result.insert(result.end(), saveMap.begin(), saveMap.end()); + } else { + log::warn("local save games not supported by this game plugin"); + } + } + + QDir const overwriteDir(m_Settings.paths().overwrite()); + for (auto dataMap : dataMaps.asKeyValueRange()) { + auto overwriteSubpath = overwriteDir.absoluteFilePath(dataMap.first); + if (QDir(overwriteSubpath).exists()) { + for (const auto& dir : dataMap.second) { + result.insert(result.end(), + {overwriteSubpath, dir, true, customOverwrite.isEmpty()}); + } + } + } + + for (MOBase::IPluginFileMapper* mapper : + m_PluginContainer->plugins()) { + IPlugin* plugin = dynamic_cast(mapper); + if (m_PluginContainer->isEnabled(plugin)) { + MappingType pluginMap = mapper->mappings(); + result.reserve(result.size() + pluginMap.size()); + result.insert(result.end(), pluginMap.begin(), pluginMap.end()); + } + } + + return result; +} + +std::vector OrganizerCore::fileMapping(const QString& dataPath, + const QString& relPath, + const DirectoryEntry* base, + const DirectoryEntry* directoryEntry, + int createDestination) +{ + std::vector result; + + for (const FileEntryPtr& current : directoryEntry->getFiles()) { + bool isArchive = false; + int const origin = current->getOrigin(isArchive); + if (isArchive || (origin == 0)) { + continue; + } + + QString const originPath = QString::fromStdWString(base->getOriginByID(origin).getPath()); + QString const fileName = QString::fromStdWString(current->getName()); + // QString fileName = ToQString(current->getName()); + QString const source = originPath + relPath + fileName; + QString const target = dataPath + relPath + fileName; + if (source != target) { + result.push_back({source, target, false, false}); + } + } + + // recurse into subdirectories + for (const auto& d : directoryEntry->getSubDirectories()) { + int const origin = d->anyOrigin(); + + QString const originPath = QString::fromStdWString(base->getOriginByID(origin).getPath()); + QString const dirName = QString::fromStdWString(d->getName()); + QString const source = originPath + relPath + dirName; + QString const target = dataPath + relPath + dirName; + + bool const writeDestination = (base == directoryEntry) && (origin == createDestination); + + result.push_back({source, target, true, writeDestination}); + std::vector subRes = + fileMapping(dataPath, relPath + dirName + "/", base, d, createDestination); + result.insert(result.end(), subRes.begin(), subRes.end()); + } + return result; +} diff --git a/src/src/organizerproxy.cpp b/src/src/organizerproxy.cpp index 66aaf62..989695a 100644 --- a/src/src/organizerproxy.cpp +++ b/src/src/organizerproxy.cpp @@ -1,513 +1,514 @@ -#include "organizerproxy.h" - -#include "downloadmanagerproxy.h" -#include "executableslistproxy.h" -#include "gamefeaturesproxy.h" -#include "glob_matching.h" -#include "instancemanager.h" -#include "modlistproxy.h" -#include "organizercore.h" -#include "plugincontainer.h" -#include "pluginlistproxy.h" -#include "proxyutils.h" -#include "settings.h" -#include "shared/appconfig.h" -#include "shared/util.h" - -#include -#include - -#include - -using namespace MOBase; -using namespace MOShared; - -OrganizerProxy::OrganizerProxy(OrganizerCore* organizer, - PluginContainer* pluginContainer, - MOBase::IPlugin* plugin) - : m_Proxied(organizer), m_PluginContainer(pluginContainer), m_Plugin(plugin), - m_DownloadManagerProxy( - std::make_unique(this, organizer->downloadManager())), - m_ModListProxy(std::make_unique(this, organizer->modList())), - m_ExecutablesListProxy( - std::make_unique(organizer->executablesList())), - m_PluginListProxy( - std::make_unique(this, organizer->pluginList())), - m_GameFeaturesProxy( - std::make_unique(this, pluginContainer->gameFeatures())) -{} - -OrganizerProxy::~OrganizerProxy() -{ - disconnectSignals(); -} - -void OrganizerProxy::connectSignals() -{ - m_Connections.push_back( - m_Proxied->onAboutToRun(callSignalIfPluginActive(this, m_AboutToRun, true))); - m_Connections.push_back( - m_Proxied->onFinishedRun(callSignalIfPluginActive(this, m_FinishedRun))); - m_Connections.push_back( - m_Proxied->onProfileCreated(callSignalIfPluginActive(this, m_ProfileCreated))); - m_Connections.push_back( - m_Proxied->onProfileRenamed(callSignalIfPluginActive(this, m_ProfileRenamed))); - m_Connections.push_back( - m_Proxied->onProfileRemoved(callSignalIfPluginActive(this, m_ProfileRemoved))); - m_Connections.push_back( - m_Proxied->onProfileChanged(callSignalIfPluginActive(this, m_ProfileChanged))); - - m_Connections.push_back(m_Proxied->onUserInterfaceInitialized( - callSignalAlways(m_UserInterfaceInitialized))); - m_Connections.push_back( - m_Proxied->onPluginSettingChanged(callSignalAlways(m_PluginSettingChanged))); - m_Connections.push_back( - m_Proxied->onPluginEnabled(callSignalAlways(m_PluginEnabled))); - m_Connections.push_back( - m_Proxied->onPluginDisabled(callSignalAlways(m_PluginDisabled))); - - // Connect the child proxies. - m_DownloadManagerProxy->connectSignals(); - m_ModListProxy->connectSignals(); - m_PluginListProxy->connectSignals(); -} - -void OrganizerProxy::disconnectSignals() -{ - // Disconnect the child proxies. - m_DownloadManagerProxy->disconnectSignals(); - m_ModListProxy->disconnectSignals(); - m_PluginListProxy->disconnectSignals(); - - for (auto& conn : m_Connections) { - conn.disconnect(); - } - m_Connections.clear(); -} - -IModRepositoryBridge* OrganizerProxy::createNexusBridge() const -{ - return new NexusBridge(m_PluginContainer, m_Plugin->name()); -} - -QString OrganizerProxy::instanceName() const -{ - return InstanceManager::singleton().currentInstance()->displayName(); -} - -QString OrganizerProxy::profileName() const -{ - return m_Proxied->profileName(); -} - -QString OrganizerProxy::profilePath() const -{ - return m_Proxied->profilePath(); -} - -QString OrganizerProxy::downloadsPath() const -{ - return m_Proxied->downloadsPath(); -} - -QString OrganizerProxy::overwritePath() const -{ - return m_Proxied->overwritePath(); -} - -QString OrganizerProxy::basePath() const -{ - return m_Proxied->basePath(); -} - -QString OrganizerProxy::modsPath() const -{ - return m_Proxied->modsPath(); -} - -Version OrganizerProxy::version() const -{ - return m_Proxied->version(); -} - -VersionInfo OrganizerProxy::appVersion() const -{ - const auto version = m_Proxied->version(); - const int major = version.major(), minor = version.minor(), - subminor = version.patch(); - int subsubminor = 0; - VersionInfo::ReleaseType infoReleaseType = VersionInfo::RELEASE_FINAL; - - // make a copy - auto prereleases = version.preReleases(); - - if (!prereleases.empty()) { - // check if the first pre-release entry is a number - if (prereleases.front().index() == 0) { - subsubminor = std::get(prereleases.front()); - prereleases.erase(prereleases.begin()); - } - - if (!prereleases.empty()) { - const auto releaseType = std::get(prereleases.front()); - switch (releaseType) { - case Version::Development: - infoReleaseType = VersionInfo::RELEASE_PREALPHA; - break; - case Version::Alpha: - infoReleaseType = VersionInfo::RELEASE_ALPHA; - break; - case Version::Beta: - infoReleaseType = VersionInfo::RELEASE_BETA; - break; - case Version::ReleaseCandidate: - infoReleaseType = VersionInfo::RELEASE_CANDIDATE; - break; - default: - infoReleaseType = VersionInfo::RELEASE_PREALPHA; - } - } - - // there is no way to differentiate two pre-releases? - } - - return {major, minor, subminor, subsubminor, infoReleaseType}; -} - -IPluginGame* OrganizerProxy::getGame(const QString& gameName) const -{ - return m_Proxied->getGame(gameName); -} - -IModInterface* OrganizerProxy::createMod(MOBase::GuessedValue& name) -{ - return m_Proxied->createMod(name); -} - -void OrganizerProxy::modDataChanged(IModInterface* mod) -{ - m_Proxied->modDataChanged(mod); -} - -bool OrganizerProxy::isPluginEnabled(QString const& pluginName) const -{ - return m_PluginContainer->isEnabled(pluginName); -} - -bool OrganizerProxy::isPluginEnabled(IPlugin* plugin) const -{ - return m_PluginContainer->isEnabled(plugin); -} - -QVariant OrganizerProxy::pluginSetting(const QString& pluginName, - const QString& key) const -{ - return m_Proxied->pluginSetting(pluginName, key); -} - -void OrganizerProxy::setPluginSetting(const QString& pluginName, const QString& key, - const QVariant& value) -{ - m_Proxied->setPluginSetting(pluginName, key, value); -} - -QVariant OrganizerProxy::persistent(const QString& pluginName, const QString& key, - const QVariant& def) const -{ - return m_Proxied->persistent(pluginName, key, def); -} - -void OrganizerProxy::setPersistent(const QString& pluginName, const QString& key, - const QVariant& value, bool sync) -{ - m_Proxied->setPersistent(pluginName, key, value, sync); -} - -QString OrganizerProxy::pluginDataPath() const -{ - return OrganizerCore::pluginDataPath(); -} - -HANDLE OrganizerProxy::startApplication(const QString& exe, const QStringList& args, - const QString& cwd, const QString& profile, - const QString& overwrite, bool ignoreOverwrite) -{ - log::debug("a plugin has requested to start an application:\n" - " . executable: '{}'\n" - " . args: '{}'\n" - " . cwd: '{}'\n" - " . profile: '{}'\n" - " . overwrite: '{}'\n" - " . ignore overwrite: {}", - exe, args.join(" "), cwd, profile, overwrite, ignoreOverwrite); - - auto runner = m_Proxied->processRunner(); - - // don't wait for completion - runner.setFromFileOrExecutable(exe, args, cwd, profile, overwrite, ignoreOverwrite) - .run(); - - // the plugin is in charge of closing the handle, unless waitForApplication() - // is called on it - return runner.stealProcessHandle().release(); -} - -bool OrganizerProxy::waitForApplication(HANDLE handle, bool refresh, - LPDWORD exitCode) const -{ - // The plugin API hands us an opaque HANDLE — on Linux the underlying value - // is a pid_t we packed via reinterpret_cast(pid). - const pid_t pid = static_cast(reinterpret_cast(handle)); - - log::debug("a plugin wants to wait for an application to complete, pid {}", pid); - - auto runner = m_Proxied->processRunner(); - - ProcessRunner::WaitFlags waitFlags = ProcessRunner::ForceWait; - - if (refresh) { - waitFlags |= ProcessRunner::TriggerRefresh | ProcessRunner::WaitForRefresh; - } - - const auto r = runner.setWaitForCompletion(waitFlags, UILocker::OutputRequired) - .attachToProcess(pid); - - if (exitCode) { - *exitCode = runner.exitCode(); - } - - switch (r) { - case ProcessRunner::Completed: - return true; - - case ProcessRunner::Cancelled: // fall-through - case ProcessRunner::ForceUnlocked: - // this is always an error because the application should have run to - // completion - return false; - - case ProcessRunner::Error: // fall-through - default: - return false; - } -} - -void OrganizerProxy::refresh(bool saveChanges) -{ - m_Proxied->refresh(saveChanges); -} - -IModInterface* OrganizerProxy::installMod(const QString& fileName, - const QString& nameSuggestion) -{ - return m_Proxied->installMod(fileName, -1, false, nullptr, nameSuggestion); -} - -QString OrganizerProxy::resolvePath(const QString& fileName) const -{ - return m_Proxied->resolvePath(fileName); -} - -QStringList OrganizerProxy::listDirectories(const QString& directoryName) const -{ - return m_Proxied->listDirectories(directoryName); -} - -QStringList -OrganizerProxy::findFiles(const QString& path, - const std::function& filter) const -{ - return m_Proxied->findFiles(path, filter); -} - -QStringList OrganizerProxy::findFiles(const QString& path, - const QStringList& globFilters) const -{ - QList> patterns; - for (auto& gfilter : globFilters) { - patterns.append(GlobPattern(gfilter)); - } - return findFiles(path, [&patterns](const QString& filename) { - for (auto& p : patterns) { - if (p.match(filename)) { - return true; - } - } - return false; - }); -} - -QStringList OrganizerProxy::getFileOrigins(const QString& fileName) const -{ - return m_Proxied->getFileOrigins(fileName); -} - -QList OrganizerProxy::findFileInfos( - const QString& path, - const std::function& filter) const -{ - return m_Proxied->findFileInfos(path, filter); -} - -std::shared_ptr OrganizerProxy::virtualFileTree() const -{ - return m_Proxied->m_VirtualFileTree.value(); -} - -MOBase::IDownloadManager* OrganizerProxy::downloadManager() const -{ - return m_DownloadManagerProxy.get(); -} - -MOBase::IPluginList* OrganizerProxy::pluginList() const -{ - return m_PluginListProxy.get(); -} - -MOBase::IModList* OrganizerProxy::modList() const -{ - return m_ModListProxy.get(); -} - -MOBase::IExecutablesList* OrganizerProxy::executablesList() const -{ - return m_ExecutablesListProxy.get(); -} - -MOBase::IGameFeatures* OrganizerProxy::gameFeatures() const -{ - return m_GameFeaturesProxy.get(); -} - -bool OrganizerProxy::previewFileData(QWidget* parent, const QString& fileName, - const QByteArray& fileData) -{ - return m_Proxied->previewFileData(parent, fileName, fileData); -} - -std::shared_ptr OrganizerProxy::profile() const -{ - return m_Proxied->currentProfile(); -} - -QStringList OrganizerProxy::profileNames() const -{ - return m_Proxied->profileNames(); -} - -std::shared_ptr -OrganizerProxy::getProfile(const QString& name) const -{ - return m_Proxied->getProfile(name); -} - -MOBase::IPluginGame const* OrganizerProxy::managedGame() const -{ - return m_Proxied->managedGame(); -} - -// CALLBACKS - -bool OrganizerProxy::onAboutToRun(const std::function& func) -{ - return m_Proxied - ->onAboutToRun(MOShared::callIfPluginActive( - this, - [func](const QString& binary, const QDir&, const QString&) { - return func(binary); - }, - true)) - .connected(); -} - -bool OrganizerProxy::onAboutToRun( - const std::function& func) -{ - return m_Proxied->onAboutToRun(MOShared::callIfPluginActive(this, func, true)) - .connected(); -} - -bool OrganizerProxy::onFinishedRun( - const std::function& func) -{ - return m_Proxied->onFinishedRun(MOShared::callIfPluginActive(this, func)).connected(); -} - -bool OrganizerProxy::onUserInterfaceInitialized( - std::function const& func) -{ - // Always call this one to allow plugin to initialize themselves even when not active: - return m_UserInterfaceInitialized.connect(func).connected(); -} - -bool OrganizerProxy::onNextRefresh(const std::function& func, - bool immediateIfPossible) -{ - using enum OrganizerCore::RefreshCallbackMode; - return m_Proxied - ->onNextRefresh(MOShared::callIfPluginActive(this, func), - OrganizerCore::RefreshCallbackGroup::EXTERNAL, - immediateIfPossible ? RUN_NOW_IF_POSSIBLE - : FORCE_WAIT_FOR_REFRESH) - .connected(); -} - -bool OrganizerProxy::onProfileCreated(std::function const& func) -{ - return m_ProfileCreated.connect(func).connected(); -} - -bool OrganizerProxy::onProfileRenamed( - std::function const& func) -{ - return m_ProfileRenamed.connect(func).connected(); -} - -bool OrganizerProxy::onProfileRemoved(std::function const& func) -{ - return m_ProfileRemoved.connect(func).connected(); -} - -bool OrganizerProxy::onProfileChanged( - std::function const& func) -{ - return m_ProfileChanged.connect(func).connected(); -} -// Always call these one, otherwise plugin cannot detect they are being enabled / -// disabled: -bool OrganizerProxy::onPluginSettingChanged( - std::function const& func) -{ - return m_PluginSettingChanged.connect(func).connected(); -} - -bool OrganizerProxy::onPluginEnabled(std::function const& func) -{ - return m_PluginEnabled.connect(func).connected(); -} - -bool OrganizerProxy::onPluginEnabled(const QString& pluginName, - std::function const& func) -{ - return onPluginEnabled([=](const IPlugin* plugin) { - if (plugin->name().compare(pluginName, Qt::CaseInsensitive) == 0) { - func(); - } - }); -} - -bool OrganizerProxy::onPluginDisabled(std::function const& func) -{ - return m_PluginDisabled.connect(func).connected(); -} - -bool OrganizerProxy::onPluginDisabled(const QString& pluginName, - std::function const& func) -{ - return onPluginDisabled([=](const IPlugin* plugin) { - if (plugin->name().compare(pluginName, Qt::CaseInsensitive) == 0) { - func(); - } - }); -} +#include "organizerproxy.h" + +#include "downloadmanagerproxy.h" +#include "executableslistproxy.h" +#include "gamefeaturesproxy.h" +#include "glob_matching.h" +#include "instancemanager.h" +#include "modlistproxy.h" +#include "organizercore.h" +#include "plugincontainer.h" +#include "pluginlistproxy.h" +#include "proxyutils.h" +#include "settings.h" +#include "shared/appconfig.h" +#include "shared/util.h" + +#include +#include + +#include + +using namespace MOBase; +using namespace MOShared; + +OrganizerProxy::OrganizerProxy(OrganizerCore* organizer, + PluginContainer* pluginContainer, + MOBase::IPlugin* plugin) + : m_Proxied(organizer), m_PluginContainer(pluginContainer), m_Plugin(plugin), + m_DownloadManagerProxy( + std::make_unique(this, organizer->downloadManager())), + m_ModListProxy(std::make_unique(this, organizer->modList())), + m_ExecutablesListProxy( + std::make_unique(organizer->executablesList())), + m_PluginListProxy( + std::make_unique(this, organizer->pluginList())), + m_GameFeaturesProxy( + std::make_unique(this, pluginContainer->gameFeatures())) +{} + +OrganizerProxy::~OrganizerProxy() +{ + disconnectSignals(); +} + +void OrganizerProxy::connectSignals() +{ + m_Connections.push_back( + m_Proxied->onAboutToRun(callSignalIfPluginActive(this, m_AboutToRun, true))); + m_Connections.push_back( + m_Proxied->onFinishedRun(callSignalIfPluginActive(this, m_FinishedRun))); + m_Connections.push_back( + m_Proxied->onProfileCreated(callSignalIfPluginActive(this, m_ProfileCreated))); + m_Connections.push_back( + m_Proxied->onProfileRenamed(callSignalIfPluginActive(this, m_ProfileRenamed))); + m_Connections.push_back( + m_Proxied->onProfileRemoved(callSignalIfPluginActive(this, m_ProfileRemoved))); + m_Connections.push_back( + m_Proxied->onProfileChanged(callSignalIfPluginActive(this, m_ProfileChanged))); + + m_Connections.push_back(m_Proxied->onUserInterfaceInitialized( + callSignalAlways(m_UserInterfaceInitialized))); + m_Connections.push_back( + m_Proxied->onPluginSettingChanged(callSignalAlways(m_PluginSettingChanged))); + m_Connections.push_back( + m_Proxied->onPluginEnabled(callSignalAlways(m_PluginEnabled))); + m_Connections.push_back( + m_Proxied->onPluginDisabled(callSignalAlways(m_PluginDisabled))); + + // Connect the child proxies. + m_DownloadManagerProxy->connectSignals(); + m_ModListProxy->connectSignals(); + m_PluginListProxy->connectSignals(); +} + +void OrganizerProxy::disconnectSignals() +{ + // Disconnect the child proxies. + m_DownloadManagerProxy->disconnectSignals(); + m_ModListProxy->disconnectSignals(); + m_PluginListProxy->disconnectSignals(); + + for (auto& conn : m_Connections) { + conn.disconnect(); + } + m_Connections.clear(); +} + +IModRepositoryBridge* OrganizerProxy::createNexusBridge() const +{ + return new NexusBridge(m_PluginContainer, m_Plugin->name()); +} + +QString OrganizerProxy::instanceName() const +{ + return InstanceManager::singleton().currentInstance()->displayName(); +} + +QString OrganizerProxy::profileName() const +{ + return m_Proxied->profileName(); +} + +QString OrganizerProxy::profilePath() const +{ + return m_Proxied->profilePath(); +} + +QString OrganizerProxy::downloadsPath() const +{ + return m_Proxied->downloadsPath(); +} + +QString OrganizerProxy::overwritePath() const +{ + return m_Proxied->overwritePath(); +} + +QString OrganizerProxy::basePath() const +{ + return m_Proxied->basePath(); +} + +QString OrganizerProxy::modsPath() const +{ + return m_Proxied->modsPath(); +} + +Version OrganizerProxy::version() const +{ + return m_Proxied->version(); +} + +VersionInfo OrganizerProxy::appVersion() const +{ + const auto version = m_Proxied->version(); + const int major = version.major(); + const int minor = version.minor(); + const int subminor = version.patch(); + int subsubminor = 0; + VersionInfo::ReleaseType infoReleaseType = VersionInfo::RELEASE_FINAL; + + // make a copy + auto prereleases = version.preReleases(); + + if (!prereleases.empty()) { + // check if the first pre-release entry is a number + if (prereleases.front().index() == 0) { + subsubminor = std::get(prereleases.front()); + prereleases.erase(prereleases.begin()); + } + + if (!prereleases.empty()) { + const auto releaseType = std::get(prereleases.front()); + switch (releaseType) { + case Version::Development: + infoReleaseType = VersionInfo::RELEASE_PREALPHA; + break; + case Version::Alpha: + infoReleaseType = VersionInfo::RELEASE_ALPHA; + break; + case Version::Beta: + infoReleaseType = VersionInfo::RELEASE_BETA; + break; + case Version::ReleaseCandidate: + infoReleaseType = VersionInfo::RELEASE_CANDIDATE; + break; + default: + infoReleaseType = VersionInfo::RELEASE_PREALPHA; + } + } + + // there is no way to differentiate two pre-releases? + } + + return {major, minor, subminor, subsubminor, infoReleaseType}; +} + +IPluginGame* OrganizerProxy::getGame(const QString& gameName) const +{ + return m_Proxied->getGame(gameName); +} + +IModInterface* OrganizerProxy::createMod(MOBase::GuessedValue& name) +{ + return m_Proxied->createMod(name); +} + +void OrganizerProxy::modDataChanged(IModInterface* mod) +{ + m_Proxied->modDataChanged(mod); +} + +bool OrganizerProxy::isPluginEnabled(QString const& pluginName) const +{ + return m_PluginContainer->isEnabled(pluginName); +} + +bool OrganizerProxy::isPluginEnabled(IPlugin* plugin) const +{ + return m_PluginContainer->isEnabled(plugin); +} + +QVariant OrganizerProxy::pluginSetting(const QString& pluginName, + const QString& key) const +{ + return m_Proxied->pluginSetting(pluginName, key); +} + +void OrganizerProxy::setPluginSetting(const QString& pluginName, const QString& key, + const QVariant& value) +{ + m_Proxied->setPluginSetting(pluginName, key, value); +} + +QVariant OrganizerProxy::persistent(const QString& pluginName, const QString& key, + const QVariant& def) const +{ + return m_Proxied->persistent(pluginName, key, def); +} + +void OrganizerProxy::setPersistent(const QString& pluginName, const QString& key, + const QVariant& value, bool sync) +{ + m_Proxied->setPersistent(pluginName, key, value, sync); +} + +QString OrganizerProxy::pluginDataPath() const +{ + return OrganizerCore::pluginDataPath(); +} + +HANDLE OrganizerProxy::startApplication(const QString& exe, const QStringList& args, + const QString& cwd, const QString& profile, + const QString& overwrite, bool ignoreOverwrite) +{ + log::debug("a plugin has requested to start an application:\n" + " . executable: '{}'\n" + " . args: '{}'\n" + " . cwd: '{}'\n" + " . profile: '{}'\n" + " . overwrite: '{}'\n" + " . ignore overwrite: {}", + exe, args.join(" "), cwd, profile, overwrite, ignoreOverwrite); + + auto runner = m_Proxied->processRunner(); + + // don't wait for completion + runner.setFromFileOrExecutable(exe, args, cwd, profile, overwrite, ignoreOverwrite) + .run(); + + // the plugin is in charge of closing the handle, unless waitForApplication() + // is called on it + return runner.stealProcessHandle().release(); +} + +bool OrganizerProxy::waitForApplication(HANDLE handle, bool refresh, + LPDWORD exitCode) const +{ + // The plugin API hands us an opaque HANDLE — on Linux the underlying value + // is a pid_t we packed via reinterpret_cast(pid). + const pid_t pid = static_cast(reinterpret_cast(handle)); + + log::debug("a plugin wants to wait for an application to complete, pid {}", pid); + + auto runner = m_Proxied->processRunner(); + + ProcessRunner::WaitFlags waitFlags = ProcessRunner::ForceWait; + + if (refresh) { + waitFlags |= ProcessRunner::TriggerRefresh | ProcessRunner::WaitForRefresh; + } + + const auto r = runner.setWaitForCompletion(waitFlags, UILocker::OutputRequired) + .attachToProcess(pid); + + if (exitCode) { + *exitCode = runner.exitCode(); + } + + switch (r) { + case ProcessRunner::Completed: + return true; + + case ProcessRunner::Cancelled: // fall-through + case ProcessRunner::ForceUnlocked: + // this is always an error because the application should have run to + // completion + return false; + + case ProcessRunner::Error: // fall-through + default: + return false; + } +} + +void OrganizerProxy::refresh(bool saveChanges) +{ + m_Proxied->refresh(saveChanges); +} + +IModInterface* OrganizerProxy::installMod(const QString& fileName, + const QString& nameSuggestion) +{ + return m_Proxied->installMod(fileName, -1, false, nullptr, nameSuggestion); +} + +QString OrganizerProxy::resolvePath(const QString& fileName) const +{ + return m_Proxied->resolvePath(fileName); +} + +QStringList OrganizerProxy::listDirectories(const QString& directoryName) const +{ + return m_Proxied->listDirectories(directoryName); +} + +QStringList +OrganizerProxy::findFiles(const QString& path, + const std::function& filter) const +{ + return m_Proxied->findFiles(path, filter); +} + +QStringList OrganizerProxy::findFiles(const QString& path, + const QStringList& globFilters) const +{ + QList> patterns; + for (const auto& gfilter : globFilters) { + patterns.append(GlobPattern(gfilter)); + } + return findFiles(path, [&patterns](const QString& filename) { + for (auto& p : patterns) { + if (p.match(filename)) { + return true; + } + } + return false; + }); +} + +QStringList OrganizerProxy::getFileOrigins(const QString& fileName) const +{ + return m_Proxied->getFileOrigins(fileName); +} + +QList OrganizerProxy::findFileInfos( + const QString& path, + const std::function& filter) const +{ + return m_Proxied->findFileInfos(path, filter); +} + +std::shared_ptr OrganizerProxy::virtualFileTree() const +{ + return m_Proxied->m_VirtualFileTree.value(); +} + +MOBase::IDownloadManager* OrganizerProxy::downloadManager() const +{ + return m_DownloadManagerProxy.get(); +} + +MOBase::IPluginList* OrganizerProxy::pluginList() const +{ + return m_PluginListProxy.get(); +} + +MOBase::IModList* OrganizerProxy::modList() const +{ + return m_ModListProxy.get(); +} + +MOBase::IExecutablesList* OrganizerProxy::executablesList() const +{ + return m_ExecutablesListProxy.get(); +} + +MOBase::IGameFeatures* OrganizerProxy::gameFeatures() const +{ + return m_GameFeaturesProxy.get(); +} + +bool OrganizerProxy::previewFileData(QWidget* parent, const QString& fileName, + const QByteArray& fileData) +{ + return m_Proxied->previewFileData(parent, fileName, fileData); +} + +std::shared_ptr OrganizerProxy::profile() const +{ + return m_Proxied->currentProfile(); +} + +QStringList OrganizerProxy::profileNames() const +{ + return m_Proxied->profileNames(); +} + +std::shared_ptr +OrganizerProxy::getProfile(const QString& name) const +{ + return m_Proxied->getProfile(name); +} + +MOBase::IPluginGame const* OrganizerProxy::managedGame() const +{ + return m_Proxied->managedGame(); +} + +// CALLBACKS + +bool OrganizerProxy::onAboutToRun(const std::function& func) +{ + return m_Proxied + ->onAboutToRun(MOShared::callIfPluginActive( + this, + [func](const QString& binary, const QDir&, const QString&) { + return func(binary); + }, + true)) + .connected(); +} + +bool OrganizerProxy::onAboutToRun( + const std::function& func) +{ + return m_Proxied->onAboutToRun(MOShared::callIfPluginActive(this, func, true)) + .connected(); +} + +bool OrganizerProxy::onFinishedRun( + const std::function& func) +{ + return m_Proxied->onFinishedRun(MOShared::callIfPluginActive(this, func)).connected(); +} + +bool OrganizerProxy::onUserInterfaceInitialized( + std::function const& func) +{ + // Always call this one to allow plugin to initialize themselves even when not active: + return m_UserInterfaceInitialized.connect(func).connected(); +} + +bool OrganizerProxy::onNextRefresh(const std::function& func, + bool immediateIfPossible) +{ + using enum OrganizerCore::RefreshCallbackMode; + return m_Proxied + ->onNextRefresh(MOShared::callIfPluginActive(this, func), + OrganizerCore::RefreshCallbackGroup::EXTERNAL, + immediateIfPossible ? RUN_NOW_IF_POSSIBLE + : FORCE_WAIT_FOR_REFRESH) + .connected(); +} + +bool OrganizerProxy::onProfileCreated(std::function const& func) +{ + return m_ProfileCreated.connect(func).connected(); +} + +bool OrganizerProxy::onProfileRenamed( + std::function const& func) +{ + return m_ProfileRenamed.connect(func).connected(); +} + +bool OrganizerProxy::onProfileRemoved(std::function const& func) +{ + return m_ProfileRemoved.connect(func).connected(); +} + +bool OrganizerProxy::onProfileChanged( + std::function const& func) +{ + return m_ProfileChanged.connect(func).connected(); +} +// Always call these one, otherwise plugin cannot detect they are being enabled / +// disabled: +bool OrganizerProxy::onPluginSettingChanged( + std::function const& func) +{ + return m_PluginSettingChanged.connect(func).connected(); +} + +bool OrganizerProxy::onPluginEnabled(std::function const& func) +{ + return m_PluginEnabled.connect(func).connected(); +} + +bool OrganizerProxy::onPluginEnabled(const QString& pluginName, + std::function const& func) +{ + return onPluginEnabled([=](const IPlugin* plugin) { + if (plugin->name().compare(pluginName, Qt::CaseInsensitive) == 0) { + func(); + } + }); +} + +bool OrganizerProxy::onPluginDisabled(std::function const& func) +{ + return m_PluginDisabled.connect(func).connected(); +} + +bool OrganizerProxy::onPluginDisabled(const QString& pluginName, + std::function const& func) +{ + return onPluginDisabled([=](const IPlugin* plugin) { + if (plugin->name().compare(pluginName, Qt::CaseInsensitive) == 0) { + func(); + } + }); +} diff --git a/src/src/plugincontainer.cpp b/src/src/plugincontainer.cpp index 4c73c74..2e2ac45 100644 --- a/src/src/plugincontainer.cpp +++ b/src/src/plugincontainer.cpp @@ -1,1416 +1,1418 @@ -#include "plugincontainer.h" -#include "iuserinterface.h" -#include "organizercore.h" -#include "organizerproxy.h" -#include "report.h" -#include "shared/appconfig.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -using namespace MOBase; -using namespace MOShared; - -namespace bf = boost::fusion; - -static void printPluginDiagToStderr(const QString&) -{ -} - -// Welcome to the wonderful world of MO2 plugin management! -// -// We'll start by the C++ side. -// -// There are 9 types of MO2 plugins, two of which cannot be standalone: IPluginDiagnose -// and IPluginFileMapper. This means that you can have a class implementing IPluginGame, -// IPluginDiagnose and IPluginFileMapper. It is not possible for a class to implement -// two full plugin types (e.g. IPluginPreview and IPluginTool). -// -// Plugins are fetch as QObject initially and must be "qobject-casted" to the right -// type. -// -// Plugins are stored in the PluginContainer class in various C++ containers: there is a -// vector that stores all the plugin as QObject, multiple vectors that stores the plugin -// of each types, a map to find IPlugin object from their names or from IPluginDiagnose -// or IFileMapper (since these do not inherit IPlugin, they cannot be downcasted). -// -// Requirements for plugins are stored in m_Requirements: -// - IPluginGame cannot be enabled by user. A game plugin is considered enable only if -// it is -// the one corresponding to the currently managed games. -// - If a plugin has a master plugin (IPlugin::master()), it cannot be enabled/disabled -// by users, -// and will follow the enabled/disabled state of its parent. -// - Each plugin has an "enabled" setting stored in persistence. If the setting does -// not exist, -// the plugin's enabledByDefault is used instead. -// - A plugin is considered disabled if the setting is false. -// - If the setting is true, a plugin is considered disabled if one of its -// requirements is not met. -// - Users cannot enable a plugin if one of its requirements is not met. -// -// Now let's move to the Proxy side... Or the as of now, the Python side. -// -// Proxied plugins are much more annoying because they can implement all interfaces, and -// are given to MO2 as separate plugins... A Python class implementing IPluginGame and -// IPluginDiagnose will be seen by MO2 as two separate QObject, and they will all have -// the same name. -// -// When a proxied plugin is registered, a few things must be taken care of: -// - There can only be one plugin mapped to a name in the PluginContainer class, so we -// keep the -// plugin corresponding to the most relevant class (see PluginTypeOrder), e.g. if the -// class inherits both IPluginGame and IPluginFileMapper, we map the name to the C++ -// QObject corresponding to the IPluginGame. -// - When a proxied plugin implements multiple interfaces, the IPlugin corresponding to -// the most -// important interface is set as the parent (hidden) of the other IPlugin through -// PluginRequirements. This way, the plugin are managed together (enabled/disabled -// state). The "fake" children plugins will not be returned by -// PluginRequirements::children(). -// - Since each interface corresponds to a different QObject, we need to take care not -// to call -// IPlugin::init() on each QObject, but only on the first one. -// -// All the proxied plugins are linked to the proxy plugin by PluginRequirements. If the -// proxy plugin is disabled, the proxied plugins are not even loaded so not visible in -// the plugin management tab. - -template -struct PluginTypeName; - -template <> -struct PluginTypeName -{ - static QString value() { return QT_TR_NOOP("Plugin"); } -}; -template <> -struct PluginTypeName -{ - static QString value() { return QT_TR_NOOP("Diagnose"); } -}; -template <> -struct PluginTypeName -{ - static QString value() { return QT_TR_NOOP("Game"); } -}; -template <> -struct PluginTypeName -{ - static QString value() { return QT_TR_NOOP("Installer"); } -}; -template <> -struct PluginTypeName -{ - static QString value() { return QT_TR_NOOP("Mod Page"); } -}; -template <> -struct PluginTypeName -{ - static QString value() { return QT_TR_NOOP("Preview"); } -}; -template <> -struct PluginTypeName -{ - static QString value() { return QT_TR_NOOP("Tool"); } -}; -template <> -struct PluginTypeName -{ - static QString value() { return QT_TR_NOOP("Proxy"); } -}; -template <> -struct PluginTypeName -{ - static QString value() { return QT_TR_NOOP("File Mapper"); } -}; - -QStringList PluginContainer::pluginInterfaces() -{ - // Find all the names: - QStringList names; - boost::mp11::mp_for_each([&names](const auto* p) { - using plugin_type = std::decay_t; - auto name = PluginTypeName::value(); - if (!name.isEmpty()) { - names.append(name); - } - }); - - return names; -} - -// PluginRequirementProxy - -const std::set PluginRequirements::s_CorePlugins{"INI Bakery"}; - -PluginRequirements::PluginRequirements(PluginContainer* pluginContainer, - MOBase::IPlugin* plugin, OrganizerProxy* proxy, - MOBase::IPluginProxy* pluginProxy) - : m_PluginContainer(pluginContainer), m_Plugin(plugin), m_PluginProxy(pluginProxy), - m_Organizer(proxy) -{ - // There are a lots of things we cannot set here (e.g. m_Master) because we do not - // know the order plugins are loaded. -} - -void PluginRequirements::fetchRequirements() -{ - m_Requirements = m_Plugin->requirements(); -} - -IPluginProxy* PluginRequirements::proxy() const -{ - return m_PluginProxy; -} - -std::vector PluginRequirements::proxied() const -{ - std::vector children; - if (dynamic_cast(m_Plugin)) { - for (auto* obj : m_PluginContainer->plugins()) { - auto* plugin = qobject_cast(obj); - if (plugin && m_PluginContainer->requirements(plugin).proxy() == m_Plugin) { - children.push_back(plugin); - } - } - } - return children; -} - -IPlugin* PluginRequirements::master() const -{ - // If we have a m_Master, it was forced and thus override the default master(). - if (m_Master) { - return m_Master; - } - - if (m_Plugin->master().isEmpty()) { - return nullptr; - } - - return m_PluginContainer->plugin(m_Plugin->master()); -} - -void PluginRequirements::setMaster(IPlugin* master) -{ - m_Master = master; -} - -std::vector PluginRequirements::children() const -{ - std::vector children; - for (auto* obj : m_PluginContainer->plugins()) { - auto* plugin = qobject_cast(obj); - - // Not checking master() but requirements().master() due to "hidden" - // masters. - // If the master has the same name as the plugin, this is a "hidden" - // master, we do not add it here. - if (plugin && m_PluginContainer->requirements(plugin).master() == m_Plugin && - plugin->name() != m_Plugin->name()) { - children.push_back(plugin); - } - } - return children; -} - -std::vector PluginRequirements::problems() const -{ - std::vector result; - for (auto& requirement : m_Requirements) { - if (auto p = requirement->check(m_Organizer)) { - result.push_back(*p); - } - } - return result; -} - -bool PluginRequirements::canEnable() const -{ - return problems().empty(); -} - -bool PluginRequirements::isCorePlugin() const -{ - // Let's consider game plugins as "core": - if (m_PluginContainer->implementInterface(m_Plugin)) { - return true; - } - - return s_CorePlugins.contains(m_Plugin->name()); -} - -bool PluginRequirements::hasRequirements() const -{ - return !m_Requirements.empty(); -} - -QStringList PluginRequirements::requiredGames() const -{ - // We look for a "GameDependencyRequirement" - There can be only one since otherwise - // it'd mean that the plugin requires two games at once. - for (auto& requirement : m_Requirements) { - if (auto* gdep = - dynamic_cast(requirement.get())) { - return gdep->gameNames(); - } - } - - return {}; -} - -std::vector PluginRequirements::requiredFor() const -{ - std::vector required; - std::set visited; - requiredFor(required, visited); - return required; -} - -void PluginRequirements::requiredFor(std::vector& required, - std::set& visited) const -{ - // Handle cyclic dependencies. - if (visited.contains(m_Plugin)) { - return; - } - visited.insert(m_Plugin); - - for (auto& [plugin, requirements] : m_PluginContainer->m_Requirements) { - - // If the plugin is not enabled, discard: - if (!m_PluginContainer->isEnabled(plugin)) { - continue; - } - - // Check the requirements: - for (auto& requirement : requirements.m_Requirements) { - - // We check for plugin dependency. Game dependency are not checked this way. - if (auto* pdep = - dynamic_cast(requirement.get())) { - - // Check if at least one of the plugin in the requirements is enabled (except - // this one): - bool oneEnabled = false; - for (auto& pluginName : pdep->pluginNames()) { - if (pluginName != m_Plugin->name() && - m_PluginContainer->isEnabled(pluginName)) { - oneEnabled = true; - break; - } - } - - // No plugin enabled found, so the plugin requires this plugin: - if (!oneEnabled) { - required.push_back(plugin); - requirements.requiredFor(required, visited); - break; - } - } - } - } -} - -// PluginContainer - -PluginContainer::PluginContainer(OrganizerCore* organizer) - : m_Organizer(organizer), - m_GameFeatures(std::make_unique(organizer, this)), - m_PreviewGenerator(*this) -{} - -PluginContainer::~PluginContainer() -{ - m_Organizer = nullptr; - unloadPlugins(); -} - -void PluginContainer::startPlugins(IUserInterface* userInterface) -{ - m_UserInterface = userInterface; - startPluginsImpl(plugins()); -} - -QStringList PluginContainer::implementedInterfaces(IPlugin* plugin) const -{ - // We need a QObject to be able to qobject_cast<> to the plugin types: - QObject* oPlugin = as_qobject(plugin); - - if (!oPlugin) { - return {}; - } - - return implementedInterfaces(oPlugin); -} - -QStringList PluginContainer::implementedInterfaces(QObject* oPlugin) -{ - // Find all the names: - QStringList names; - boost::mp11::mp_for_each([oPlugin, &names](const auto* p) { - using plugin_type = std::decay_t; - if (qobject_cast(oPlugin)) { - auto name = PluginTypeName::value(); - if (!name.isEmpty()) { - names.append(name); - } - } - }); - - // If the plugin implements at least one interface other than IPlugin, remove IPlugin: - if (names.size() > 1) { - names.removeAll(PluginTypeName::value()); - } - - return names; -} - -QString PluginContainer::topImplementedInterface(IPlugin* plugin) const -{ - auto interfaces = implementedInterfaces(plugin); - return interfaces.isEmpty() ? "" : interfaces[0]; -} - -bool PluginContainer::isBetterInterface(QObject* lhs, QObject* rhs) -{ - int count = 0, lhsIdx = -1, rhsIdx = -1; - boost::mp11::mp_for_each([&](const auto* p) { - using plugin_type = std::decay_t; - if (lhsIdx < 0 && qobject_cast(lhs)) { - lhsIdx = count; - } - if (rhsIdx < 0 && qobject_cast(rhs)) { - rhsIdx = count; - } - ++count; - }); - return lhsIdx < rhsIdx; -} - -QStringList PluginContainer::mergedProxyList(IPluginProxy* proxy) const -{ - const QString bundled = - m_BundledPluginPath.isEmpty() ? AppConfig::pluginsPath() : m_BundledPluginPath; - const QString instance = - m_PluginPath.isEmpty() ? AppConfig::pluginsPath() : m_PluginPath; - - QMap merged; - // Instance plugins first (lower priority) - if (instance != bundled) { - for (const auto& p : proxy->pluginList(instance)) - merged[QFileInfo(p).fileName()] = p; - } - // Bundled plugins overwrite (higher priority) - for (const auto& p : proxy->pluginList(bundled)) - merged[QFileInfo(p).fileName()] = p; - - return merged.values(); -} - -QStringList PluginContainer::pluginFileNames() const -{ - QStringList result; - for (QPluginLoader* loader : m_PluginLoaders) { - result.append(loader->fileName()); - } - std::vector proxyList = bf::at_key(m_Plugins); - for (IPluginProxy* proxy : proxyList) { - result.append(mergedProxyList(proxy)); - } - return result; -} - -QObject* PluginContainer::as_qobject(MOBase::IPlugin* plugin) const -{ - // Find the correspond QObject - Can this be done safely with a cast? - auto& objects = bf::at_key(m_Plugins); - auto it = - std::find_if(std::begin(objects), std::end(objects), [plugin](QObject* obj) { - return qobject_cast(obj) == plugin; - }); - - if (it == std::end(objects)) { - return nullptr; - } - - return *it; -} - -bool PluginContainer::initPlugin(IPlugin* plugin, IPluginProxy* pluginProxy, - bool skipInit) -{ - // when MO has no instance loaded, init() is not called on plugins, except - // for proxy plugins, where init() is called with a null IOrganizer - // - // after proxies are initialized, instantiate() is called for all the plugins - // they've discovered, but as for regular plugins, init() won't be - // called on them if m_OrganizerCore is null - - if (plugin == nullptr) { - return false; - } - - OrganizerProxy* proxy = nullptr; - if (m_Organizer) { - proxy = new OrganizerProxy(m_Organizer, this, plugin); - proxy->setParent(as_qobject(plugin)); - } - - // Check if it is a proxy plugin: - bool isProxy = dynamic_cast(plugin); - - auto [it, bl] = m_Requirements.emplace( - plugin, PluginRequirements(this, plugin, proxy, pluginProxy)); - - if (!m_Organizer && !isProxy) { - return true; - } - - if (skipInit) { - return true; - } - - if (!plugin->init(proxy)) { - log::warn("plugin failed to initialize"); - return false; - } - - // Update requirements: - it->second.fetchRequirements(); - - return true; -} - -void PluginContainer::registerGame(IPluginGame* game) -{ - m_SupportedGames.insert({game->gameName(), game}); -} - -void PluginContainer::unregisterGame(MOBase::IPluginGame* game) -{ - m_SupportedGames.erase(game->gameName()); -} - -IPlugin* PluginContainer::registerPlugin(QObject* plugin, const QString& filepath, - MOBase::IPluginProxy* pluginProxy) -{ - - // generic treatment for all plugins - IPlugin* pluginObj = qobject_cast(plugin); - if (pluginObj == nullptr) { - log::debug("PluginContainer::registerPlugin() called with a non IPlugin QObject."); - return nullptr; - } - - // If we already a plugin with this name: - bool skipInit = false; - auto& mapNames = bf::at_key(m_AccessPlugins); - if (mapNames.contains(pluginObj->name())) { - - IPlugin* other = mapNames[pluginObj->name()]; - - // If both plugins are from the same proxy and the same file, this is usually - // ok (in theory some one could write two different classes from the same Python - // file/module): - if (pluginProxy && m_Requirements.at(other).proxy() == pluginProxy && - this->filepath(other) == QDir::cleanPath(filepath)) { - - // Plugin has already been initialized: - skipInit = true; - - if (isBetterInterface(plugin, as_qobject(other))) { - log::debug( - "replacing plugin '{}' with interfaces [{}] by one with interfaces [{}]", - pluginObj->name(), implementedInterfaces(other).join(", "), - implementedInterfaces(plugin).join(", ")); - bf::at_key(m_AccessPlugins)[pluginObj->name()] = pluginObj; - } - } else { - log::warn("Trying to register two plugins with the name '{}' (from {} and {}), " - "the second one will not be registered.", - pluginObj->name(), this->filepath(other), QDir::cleanPath(filepath)); - return nullptr; - } - } else { - bf::at_key(m_AccessPlugins)[pluginObj->name()] = pluginObj; - } - - // Storing the original QObject* is a bit of a hack as I couldn't figure out any - // way to cast directly between IPlugin* and IPluginDiagnose* - bf::at_key(m_Plugins).push_back(plugin); - - plugin->setProperty("filepath", QDir::cleanPath(filepath)); - plugin->setParent(this); - - if (m_Organizer) { - m_Organizer->settings().plugins().registerPlugin(pluginObj); - } - - { // diagnosis plugin - IPluginDiagnose* diagnose = qobject_cast(plugin); - if (diagnose != nullptr) { - bf::at_key(m_Plugins).push_back(diagnose); - bf::at_key(m_AccessPlugins)[diagnose] = pluginObj; - diagnose->onInvalidated([&]() { - emit diagnosisUpdate(); - }); - } - } - { // file mapper plugin - IPluginFileMapper* mapper = qobject_cast(plugin); - if (mapper != nullptr) { - bf::at_key(m_Plugins).push_back(mapper); - bf::at_key(m_AccessPlugins)[mapper] = pluginObj; - } - } - { // mod page plugin - IPluginModPage* modPage = qobject_cast(plugin); - if (initPlugin(modPage, pluginProxy, skipInit)) { - bf::at_key(m_Plugins).push_back(modPage); - emit pluginRegistered(modPage); - return modPage; - } - } - { // game plugin - IPluginGame* game = qobject_cast(plugin); - if (game) { - game->detectGame(); - if (initPlugin(game, pluginProxy, skipInit)) { - bf::at_key(m_Plugins).push_back(game); - registerGame(game); - emit pluginRegistered(game); - return game; - } - } - } - { // tool plugins - IPluginTool* tool = qobject_cast(plugin); - if (initPlugin(tool, pluginProxy, skipInit)) { - bf::at_key(m_Plugins).push_back(tool); - emit pluginRegistered(tool); - return tool; - } - } - { // installer plugins - IPluginInstaller* installer = qobject_cast(plugin); - if (initPlugin(installer, pluginProxy, skipInit)) { - bf::at_key(m_Plugins).push_back(installer); - if (m_Organizer) { - installer->setInstallationManager(m_Organizer->installationManager()); - } - emit pluginRegistered(installer); - return installer; - } - } - { // preview plugins - IPluginPreview* preview = qobject_cast(plugin); - if (initPlugin(preview, pluginProxy, skipInit)) { - bf::at_key(m_Plugins).push_back(preview); - return preview; - } - } - { // proxy plugins - IPluginProxy* proxy = qobject_cast(plugin); - if (initPlugin(proxy, pluginProxy, skipInit)) { - bf::at_key(m_Plugins).push_back(proxy); - emit pluginRegistered(proxy); - - QStringList filepaths = mergedProxyList(proxy); - log::debug("proxy '{}' discovered {} proxied plugin candidate(s)", - proxy->name(), filepaths.size()); - printPluginDiagToStderr( - QString("proxy '%1' discovered %2 proxied plugin candidate(s)") - .arg(proxy->name()) - .arg(filepaths.size())); - for (const QString& filepath : filepaths) { - log::debug("proxy '{}' candidate: '{}'", proxy->name(), - QDir::toNativeSeparators(filepath)); - loadProxied(filepath, proxy); - } - return proxy; - } - } - - { // dummy plugins - // only initialize these, no processing otherwise - IPlugin* dummy = qobject_cast(plugin); - if (initPlugin(dummy, pluginProxy, skipInit)) { - bf::at_key(m_Plugins).push_back(dummy); - emit pluginRegistered(dummy); - return dummy; - } - } - - return nullptr; -} - -IPluginGame* PluginContainer::managedGame() const -{ - // TODO: This const_cast is safe but ugly. Most methods require a IPlugin*, so - // returning a const-version if painful. This should be fixed by making methods accept - // a const IPlugin* instead, but there are a few tricks with qobject_cast and const. - return m_Organizer ? const_cast(m_Organizer->managedGame()) : nullptr; -} - -bool PluginContainer::isEnabled(IPlugin* plugin) const -{ - // Check if it's a game plugin: - if (implementInterface(plugin)) { - return plugin == m_Organizer->managedGame(); - } - - // Check the master, if any: - auto& requirements = m_Requirements.at(plugin); - - if (requirements.master()) { - return isEnabled(requirements.master()); - } - - // Check if the plugin is enabled: - if (!m_Organizer->persistent(plugin->name(), "enabled", plugin->enabledByDefault()) - .toBool()) { - return false; - } - - // Check the requirements: - return m_Requirements.at(plugin).canEnable(); -} - -void PluginContainer::setEnabled(MOBase::IPlugin* plugin, bool enable, - bool dependencies) -{ - // If required, disable dependencies: - if (!enable && dependencies) { - for (auto* p : requirements(plugin).requiredFor()) { - // No need to "recurse" here since requiredFor already does it. - setEnabled(p, false, false); - } - } - - // Always disable/enable child plugins: - for (auto* p : requirements(plugin).children()) { - // "Child" plugin should have no dependencies. - setEnabled(p, enable, false); - } - - m_Organizer->setPersistent(plugin->name(), "enabled", enable, true); - - if (enable) { - emit pluginEnabled(plugin); - } else { - emit pluginDisabled(plugin); - } -} - -MOBase::IPlugin* PluginContainer::plugin(QString const& pluginName) const -{ - auto& map = bf::at_key(m_AccessPlugins); - auto it = map.find(pluginName); - if (it == std::end(map)) { - return nullptr; - } - return it->second; -} - -MOBase::IPlugin* PluginContainer::plugin(MOBase::IPluginDiagnose* diagnose) const -{ - auto& map = bf::at_key(m_AccessPlugins); - auto it = map.find(diagnose); - if (it == std::end(map)) { - return nullptr; - } - return it->second; -} - -MOBase::IPlugin* PluginContainer::plugin(MOBase::IPluginFileMapper* mapper) const -{ - auto& map = bf::at_key(m_AccessPlugins); - auto it = map.find(mapper); - if (it == std::end(map)) { - return nullptr; - } - return it->second; -} - -bool PluginContainer::isEnabled(QString const& pluginName) const -{ - IPlugin* p = plugin(pluginName); - return p ? isEnabled(p) : false; -} -bool PluginContainer::isEnabled(MOBase::IPluginDiagnose* diagnose) const -{ - IPlugin* p = plugin(diagnose); - return p ? isEnabled(p) : false; -} -bool PluginContainer::isEnabled(MOBase::IPluginFileMapper* mapper) const -{ - IPlugin* p = plugin(mapper); - return p ? isEnabled(p) : false; -} - -const PluginRequirements& PluginContainer::requirements(IPlugin* plugin) const -{ - return m_Requirements.at(plugin); -} - -OrganizerProxy* PluginContainer::organizerProxy(MOBase::IPlugin* plugin) const -{ - return requirements(plugin).m_Organizer; -} - -MOBase::IPluginProxy* PluginContainer::pluginProxy(MOBase::IPlugin* plugin) const -{ - return requirements(plugin).proxy(); -} - -QString PluginContainer::filepath(MOBase::IPlugin* plugin) const -{ - return as_qobject(plugin)->property("filepath").toString(); -} - -IPluginGame* PluginContainer::game(const QString& name) const -{ - auto iter = m_SupportedGames.find(name); - if (iter != m_SupportedGames.end()) { - return iter->second; - } else { - return nullptr; - } -} - -void PluginContainer::startPluginsImpl(const std::vector& plugins) const -{ - // setUserInterface() - if (m_UserInterface) { - for (auto* plugin : plugins) { - if (auto* proxy = qobject_cast(plugin)) { - proxy->setParentWidget(m_UserInterface->mainWindow()); - } - if (auto* modPage = qobject_cast(plugin)) { - modPage->setParentWidget(m_UserInterface->mainWindow()); - } - if (auto* tool = qobject_cast(plugin)) { - tool->setParentWidget(m_UserInterface->mainWindow()); - } - if (auto* installer = qobject_cast(plugin)) { - installer->setParentWidget(m_UserInterface->mainWindow()); - } - } - } - - // Trigger initial callbacks, e.g. onUserInterfaceInitialized and onProfileChanged. - if (m_Organizer) { - for (auto* object : plugins) { - auto* plugin = qobject_cast(object); - auto* oproxy = organizerProxy(plugin); - oproxy->connectSignals(); - oproxy->m_ProfileChanged(nullptr, m_Organizer->currentProfile().get()); - - if (m_UserInterface) { - oproxy->m_UserInterfaceInitialized(m_UserInterface->mainWindow()); - } - } - } -} - -std::vector PluginContainer::loadProxied(const QString& filepath, - IPluginProxy* proxy) -{ - std::vector proxiedPlugins; - - try { - log::debug("loading proxied plugin candidate '{}' via proxy '{}'", - QDir::toNativeSeparators(filepath), - (proxy ? proxy->name() : QStringLiteral(""))); - printPluginDiagToStderr( - QString("loading proxied plugin candidate '%1' via proxy '%2'") - .arg(QDir::toNativeSeparators(filepath)) - .arg(proxy ? proxy->name() : QStringLiteral(""))); - - // We get a list of matching plugins as proxies can return multiple plugins - // per file and do not have a good way of supporting multiple inheritance. - QList matchingPlugins = proxy->load(filepath); - if (matchingPlugins.isEmpty()) { - log::debug("no plugins were returned for proxied candidate '{}' via proxy '{}'", - QDir::toNativeSeparators(filepath), proxy->name()); - printPluginDiagToStderr( - QString("no plugins were returned for proxied candidate '%1' via proxy '%2'") - .arg(QDir::toNativeSeparators(filepath)) - .arg(proxy->name())); - } - - // We are going to group plugin by names and "fix" them later: - std::map> proxiedByNames; - - for (QObject* proxiedPlugin : matchingPlugins) { - if (proxiedPlugin == nullptr) { - log::warn("proxy '{}' returned a null QObject for '{}'", proxy->name(), - QDir::toNativeSeparators(filepath)); - printPluginDiagToStderr( - QString("proxy '%1' returned a null QObject for '%2'") - .arg(proxy->name()) - .arg(QDir::toNativeSeparators(filepath))); - continue; - } - - if (IPlugin* proxied = registerPlugin(proxiedPlugin, filepath, proxy); proxied) { - log::debug("loaded plugin '{}@{}' from '{}' - [{}]", proxied->name(), - proxied->version().canonicalString(), - QFileInfo(filepath).fileName(), - implementedInterfaces(proxied).join(", ")); - - // Store the plugin for later: - proxiedPlugins.push_back(proxiedPlugin); - proxiedByNames[proxied->name()].push_back(proxied); - } else { - log::warn( - "proxied candidate '{}' from proxy '{}' failed to register as an MO2 plugin", - QDir::toNativeSeparators(filepath), proxy->name()); - printPluginDiagToStderr( - QString("proxied candidate '%1' from proxy '%2' failed to register as an " - "MO2 plugin") - .arg(QDir::toNativeSeparators(filepath)) - .arg(proxy->name())); - } - } - - // Fake masters: - for (auto& [name, proxiedPlugins] : proxiedByNames) { - if (proxiedPlugins.size() > 1) { - auto it = std::min_element(std::begin(proxiedPlugins), std::end(proxiedPlugins), - [&](auto const& lhs, auto const& rhs) { - return isBetterInterface(as_qobject(lhs), - as_qobject(rhs)); - }); - - for (auto& proxiedPlugin : proxiedPlugins) { - if (proxiedPlugin != *it) { - m_Requirements.at(proxiedPlugin).setMaster(*it); - } - } - } - } - log::debug("finished proxied candidate '{}' via proxy '{}': {} plugin(s) loaded", - QDir::toNativeSeparators(filepath), proxy->name(), - proxiedPlugins.size()); - printPluginDiagToStderr( - QString("finished proxied candidate '%1' via proxy '%2': %3 plugin(s) loaded") - .arg(QDir::toNativeSeparators(filepath)) - .arg(proxy->name()) - .arg(proxiedPlugins.size())); - } catch (const std::exception& e) { - log::error("failed to initialize proxied candidate '{}' via proxy '{}': {}", - QDir::toNativeSeparators(filepath), - (proxy ? proxy->name() : QStringLiteral("")), e.what()); - printPluginDiagToStderr( - QString("failed to initialize proxied candidate '%1' via proxy '%2': %3") - .arg(QDir::toNativeSeparators(filepath)) - .arg(proxy ? proxy->name() : QStringLiteral("")) - .arg(e.what())); - reportError( - QObject::tr("failed to initialize plugin %1: %2").arg(filepath).arg(e.what())); - } catch (...) { - log::error("failed to initialize proxied candidate '{}' via proxy '{}': " - "unknown exception", - QDir::toNativeSeparators(filepath), - (proxy ? proxy->name() : QStringLiteral(""))); - printPluginDiagToStderr( - QString("failed to initialize proxied candidate '%1' via proxy '%2': unknown " - "exception") - .arg(QDir::toNativeSeparators(filepath)) - .arg(proxy ? proxy->name() : QStringLiteral(""))); - reportError(QObject::tr("failed to initialize plugin %1: unknown exception") - .arg(filepath)); - } - - return proxiedPlugins; -} - -QObject* PluginContainer::loadQtPlugin(const QString& filepath) -{ - std::unique_ptr pluginLoader(new QPluginLoader(filepath, this)); - if (pluginLoader->instance() == nullptr) { - m_FailedPlugins.push_back(filepath); - log::error("failed to load plugin {}: {}", filepath, pluginLoader->errorString()); - } else { - QObject* object = pluginLoader->instance(); - if (IPlugin* plugin = registerPlugin(object, filepath, nullptr); plugin) { - log::debug("loaded plugin '{}@{}' from '{}' - [{}]", plugin->name(), - plugin->version().canonicalString(), QFileInfo(filepath).fileName(), - implementedInterfaces(plugin).join(", ")); - m_PluginLoaders.push_back(pluginLoader.release()); - return object; - } else { - m_FailedPlugins.push_back(filepath); - log::warn("plugin '{}' failed to load (may be outdated)", filepath); - } - } - return nullptr; -} - -std::optional PluginContainer::isQtPluginFolder(const QString& filepath) -{ - - if (!QFileInfo(filepath).isDir()) { - return {}; - } - - QDirIterator iter(filepath, QDir::Files | QDir::NoDotAndDotDot); - while (iter.hasNext()) { - iter.next(); - const auto filePath = iter.filePath(); - - // not a library, skip - if (!QLibrary::isLibrary(filePath)) { - continue; - } - - // check if we have proper metadata - this does not load the plugin (metaData() - // should be very lightweight) - const QPluginLoader loader(filePath); - if (!loader.metaData().isEmpty()) { - return filePath; - } - } - - return {}; -} - -void PluginContainer::loadPlugin(QString const& filepath) -{ - std::vector plugins; - if (QFileInfo(filepath).isFile() && QLibrary::isLibrary(filepath)) { - QObject* plugin = loadQtPlugin(filepath); - if (plugin) { - plugins.push_back(plugin); - } - } else if (auto p = isQtPluginFolder(filepath)) { - QObject* plugin = loadQtPlugin(*p); - if (plugin) { - plugins.push_back(plugin); - } - } else { - // We need to check if this can be handled by a proxy. - for (auto* proxy : this->plugins()) { - auto filepaths = mergedProxyList(proxy); - if (filepaths.contains(filepath)) { - plugins = loadProxied(filepath, proxy); - break; - } - } - } - - for (auto* plugin : plugins) { - emit pluginRegistered(qobject_cast(plugin)); - } - - startPluginsImpl(plugins); -} - -void PluginContainer::unloadPlugin(MOBase::IPlugin* plugin, QObject* object) -{ - if (auto* game = qobject_cast(object)) { - - if (game == managedGame()) { - throw Exception("cannot unload the plugin for the currently managed game"); - } - - unregisterGame(game); - } - - // We need to remove from the m_Plugins maps BEFORE unloading from the proxy - // otherwise the qobject_cast to check the plugin type will not work. - bf::for_each(m_Plugins, [object](auto& t) { - using type = typename std::decay_t::value_type; - - // We do not want to remove from QObject since we are iterating over them. - if constexpr (!std::is_same{}) { - auto itp = - std::find(t.second.begin(), t.second.end(), qobject_cast(object)); - if (itp != t.second.end()) { - t.second.erase(itp); - } - } - }); - - emit pluginUnregistered(plugin); - - // Remove from the members. - if (auto* diagnose = qobject_cast(object)) { - bf::at_key(m_AccessPlugins).erase(diagnose); - } - if (auto* mapper = qobject_cast(object)) { - bf::at_key(m_AccessPlugins).erase(mapper); - } - - auto& mapNames = bf::at_key(m_AccessPlugins); - if (mapNames.contains(plugin->name())) { - mapNames.erase(plugin->name()); - } - - m_Organizer->settings().plugins().unregisterPlugin(plugin); - - // Force disconnection of the signals from the proxies. This is a safety - // operations since those signals should be disconnected when the proxies - // are destroyed anyway. - organizerProxy(plugin)->disconnectSignals(); - - // Is this a proxied plugin? - auto* proxy = pluginProxy(plugin); - - if (proxy) { - proxy->unload(filepath(plugin)); - } else { - // We need to find the loader. - auto it = std::find_if(m_PluginLoaders.begin(), m_PluginLoaders.end(), - [object](auto* loader) { - return loader->instance() == object; - }); - - if (it != m_PluginLoaders.end()) { - if (!(*it)->unload()) { - log::error("failed to unload {}: {}", (*it)->fileName(), (*it)->errorString()); - } - delete *it; - m_PluginLoaders.erase(it); - } else { - log::error("loader for plugin {} does not exist, cannot unload", plugin->name()); - } - } - - object->deleteLater(); - - // Do this at the end. - m_Requirements.erase(plugin); -} - -void PluginContainer::unloadPlugin(QString const& filepath) -{ - // We need to find all the plugins from the given path and - // unload them: - QString cleanPath = QDir::cleanPath(filepath); - auto& objects = bf::at_key(m_Plugins); - for (auto it = objects.begin(); it != objects.end();) { - auto* plugin = qobject_cast(*it); - if (this->filepath(plugin) == filepath) { - unloadPlugin(plugin, *it); - it = objects.erase(it); - } else { - ++it; - } - } -} - -void PluginContainer::reloadPlugin(QString const& filepath) -{ - unloadPlugin(filepath); - loadPlugin(filepath); -} - -void PluginContainer::unloadPlugins() -{ - if (m_Organizer) { - // this will clear several structures that can hold on to pointers to - // plugins, as well as read the plugin blacklist from the ini file, which - // is used in loadPlugins() below to skip plugins - // - // note that the first thing loadPlugins() does is call unloadPlugins(), - // so this makes sure the blacklist is always available - m_Organizer->settings().plugins().clearPlugins(); - } - - bf::for_each(m_Plugins, [](auto& t) { - t.second.clear(); - }); - bf::for_each(m_AccessPlugins, [](auto& t) { - t.second.clear(); - }); - m_Requirements.clear(); - - while (!m_PluginLoaders.empty()) { - QPluginLoader* loader = m_PluginLoaders.back(); - m_PluginLoaders.pop_back(); - if ((loader != nullptr) && !loader->unload()) { - log::debug("failed to unload {}: {}", loader->fileName(), loader->errorString()); - } - delete loader; - } -} - -void PluginContainer::loadPlugins() -{ - TimeThis tt("PluginContainer::loadPlugins()"); - - unloadPlugins(); - - for (QObject* plugin : QPluginLoader::staticInstances()) { - registerPlugin(plugin, "", nullptr); - } - - QFile loadCheck; - QString skipPlugin; - - if (m_Organizer) { - loadCheck.setFileName(qApp->property("dataPath").toString() + - "/plugin_loadcheck.tmp"); - - if (loadCheck.exists() && loadCheck.open(QIODevice::ReadOnly)) { - // oh, there was a failed plugin load last time. Find out which plugin was loaded - // last - QString fileName; - while (!loadCheck.atEnd()) { - fileName = QString::fromUtf8(loadCheck.readLine().constData()).trimmed(); - } - - log::warn("loadcheck file found for plugin '{}'", fileName); - - MOBase::TaskDialog dlg; - - const auto Skip = QMessageBox::Ignore; - const auto Blacklist = QMessageBox::Cancel; - const auto Load = QMessageBox::Ok; - - const auto r = - dlg.title(tr("Plugin error")) - .main(tr("Mod Organizer failed to load the plugin '%1' last time it was " - "started.") - .arg(fileName)) - .content(tr( - "The plugin can be skipped for this session, blacklisted, " - "or loaded normally, in which case it might fail again. Blacklisted " - "plugins can be re-enabled later in the settings.")) - .icon(QMessageBox::Warning) - .button({tr("Skip this plugin"), Skip}) - .button({tr("Blacklist this plugin"), Blacklist}) - .button({tr("Load this plugin"), Load}) - .exec(); - - switch (r) { - case Skip: - log::warn("user wants to skip plugin '{}'", fileName); - skipPlugin = fileName; - break; - - case Blacklist: - log::warn("user wants to blacklist plugin '{}'", fileName); - m_Organizer->settings().plugins().addBlacklist(fileName); - break; - - case Load: - log::warn("user wants to load plugin '{}' anyway", fileName); - break; - } - - loadCheck.close(); - } - - if (!loadCheck.open(QIODevice::WriteOnly)) { - log::warn("failed to open loadcheck file for writing '{}'", - QDir::toNativeSeparators(loadCheck.fileName())); - } - } - - m_BundledPluginPath = AppConfig::pluginsPath(); - - if (m_Organizer) { - QString instancePluginPath = - QDir(QDir::fromNativeSeparators(m_Organizer->basePath())).filePath("plugins"); - if (QDir::cleanPath(instancePluginPath) != QDir::cleanPath(m_BundledPluginPath)) { - QDir().mkpath(instancePluginPath); - m_PluginPath = instancePluginPath; - log::debug("instance plugin directory: {}", - QDir::toNativeSeparators(m_PluginPath)); - - // Migration: remove stale symlinks left by the old - // ensureBundledPluginsLinked() approach. Only symlinks are removed; real - // user files are left untouched. - QDirIterator cleanIter(instancePluginPath, - QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot); - while (cleanIter.hasNext()) { - cleanIter.next(); - if (QFileInfo(cleanIter.filePath()).isSymLink()) { - log::debug("removing stale plugin symlink '{}'", - QDir::toNativeSeparators(cleanIter.filePath())); - QFile::remove(cleanIter.filePath()); - } - } - } else { - m_PluginPath = m_BundledPluginPath; - } - } else { - m_PluginPath = m_BundledPluginPath; - } - - log::debug("bundled plugins: {}", QDir::toNativeSeparators(m_BundledPluginPath)); - log::debug("looking for plugins in {}", QDir::toNativeSeparators(m_PluginPath)); - - // Linux is case-sensitive; keep only the canonical Fallout NV plugin filename. - // Older builds may leave a stale lowercase artifact that causes duplicate - // registration warnings at startup. - auto cleanStaleNvPlugin = [](const QString& dir) { - const QString nvCanonical = dir + "/libgame_falloutNV.so"; - const QString nvStale = dir + "/libgame_falloutnv.so"; - if (QFile::exists(nvCanonical) && QFile::exists(nvStale)) { - if (QFile::remove(nvStale)) { - log::debug("removed stale plugin artifact '{}'", - QDir::toNativeSeparators(nvStale)); - } else { - log::warn("failed to remove stale plugin artifact '{}'", - QDir::toNativeSeparators(nvStale)); - } - } - }; - cleanStaleNvPlugin(m_BundledPluginPath); - if (m_PluginPath != m_BundledPluginPath) { - cleanStaleNvPlugin(m_PluginPath); - } - - // Build merged plugin map: instance extras first (low priority), - // then bundled plugins overwrite (high priority). - QMap pluginMap; // filename -> full path - - if (m_PluginPath != m_BundledPluginPath) { - QDirIterator instanceIter(m_PluginPath, - QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot); - while (instanceIter.hasNext()) { - instanceIter.next(); - pluginMap[instanceIter.fileName()] = instanceIter.filePath(); - } - } - - QDirIterator bundledIter(m_BundledPluginPath, - QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot); - while (bundledIter.hasNext()) { - bundledIter.next(); - pluginMap[bundledIter.fileName()] = bundledIter.filePath(); - } - - // Plugins that are permanently blacklisted for all users. These are - // known-incompatible with Fluorine / Linux and must never be loaded. - static const QSet hardBlacklist = { - QStringLiteral("rootbuilder"), - }; - - for (auto it = pluginMap.cbegin(); it != pluginMap.cend(); ++it) { - const QString& fileName = it.key(); - const QString& filepath = it.value(); - - if (hardBlacklist.contains(fileName.toLower())) { - log::debug("plugin \"{}\" is hard-blacklisted, skipping", fileName); - continue; - } - - if (skipPlugin == fileName) { - log::debug("plugin \"{}\" skipped for this session", fileName); - continue; - } - - if (m_Organizer) { - if (m_Organizer->settings().plugins().blacklisted(fileName)) { - log::debug("plugin \"{}\" blacklisted", fileName); - continue; - } - } - - if (loadCheck.isOpen()) { - loadCheck.write(fileName.toUtf8()); - loadCheck.write("\n"); - loadCheck.flush(); - } - - if (QLibrary::isLibrary(filepath)) { - loadQtPlugin(filepath); - } else if (auto p = isQtPluginFolder(filepath)) { - loadQtPlugin(*p); - } - } - - if (skipPlugin.isEmpty()) { - // remove the load check file on success - if (loadCheck.isOpen()) { - loadCheck.remove(); - } - } else { - // remember the plugin for next time - if (loadCheck.isOpen()) { - loadCheck.close(); - } - - log::warn("user skipped plugin '{}', remembering in loadcheck", skipPlugin); - if (loadCheck.open(QIODevice::WriteOnly)) { - loadCheck.write(skipPlugin.toUtf8()); - loadCheck.write("\n"); - loadCheck.flush(); - } else { - log::warn("failed to persist skipped plugin to '{}'", - QDir::toNativeSeparators(loadCheck.fileName())); - } - } - - bf::at_key(m_Plugins).push_back(this); - - if (m_Organizer) { - bf::at_key(m_Plugins).push_back(m_Organizer); - m_Organizer->connectPlugins(this); - } -} - -std::vector PluginContainer::activeProblems() const -{ - std::vector problems; - if (!m_FailedPlugins.empty()) { - problems.push_back(PROBLEM_PLUGINSNOTLOADED); - } - return problems; -} - -QString PluginContainer::shortDescription(unsigned int key) const -{ - switch (key) { - case PROBLEM_PLUGINSNOTLOADED: { - return tr("Some plugins could not be loaded"); - } break; - default: { - return tr("Description missing"); - } break; - } -} - -QString PluginContainer::fullDescription(unsigned int key) const -{ - switch (key) { - case PROBLEM_PLUGINSNOTLOADED: { - QString result = - tr("The following plugins could not be loaded. The reason may be missing " - "dependencies (i.e. python) or an outdated version:") + - "
      "; - for (const QString& plugin : m_FailedPlugins) { - result += "
    • " + plugin + "
    • "; - } - result += "
        "; - return result; - } break; - default: { - return tr("Description missing"); - } break; - } -} - -bool PluginContainer::hasGuidedFix(unsigned int) const -{ - return false; -} - -void PluginContainer::startGuidedFix(unsigned int) const {} +#include "plugincontainer.h" +#include "iuserinterface.h" +#include "organizercore.h" +#include "organizerproxy.h" +#include "report.h" +#include "shared/appconfig.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace MOBase; +using namespace MOShared; + +namespace bf = boost::fusion; + +static void printPluginDiagToStderr(const QString&) +{ +} + +// Welcome to the wonderful world of MO2 plugin management! +// +// We'll start by the C++ side. +// +// There are 9 types of MO2 plugins, two of which cannot be standalone: IPluginDiagnose +// and IPluginFileMapper. This means that you can have a class implementing IPluginGame, +// IPluginDiagnose and IPluginFileMapper. It is not possible for a class to implement +// two full plugin types (e.g. IPluginPreview and IPluginTool). +// +// Plugins are fetch as QObject initially and must be "qobject-casted" to the right +// type. +// +// Plugins are stored in the PluginContainer class in various C++ containers: there is a +// vector that stores all the plugin as QObject, multiple vectors that stores the plugin +// of each types, a map to find IPlugin object from their names or from IPluginDiagnose +// or IFileMapper (since these do not inherit IPlugin, they cannot be downcasted). +// +// Requirements for plugins are stored in m_Requirements: +// - IPluginGame cannot be enabled by user. A game plugin is considered enable only if +// it is +// the one corresponding to the currently managed games. +// - If a plugin has a master plugin (IPlugin::master()), it cannot be enabled/disabled +// by users, +// and will follow the enabled/disabled state of its parent. +// - Each plugin has an "enabled" setting stored in persistence. If the setting does +// not exist, +// the plugin's enabledByDefault is used instead. +// - A plugin is considered disabled if the setting is false. +// - If the setting is true, a plugin is considered disabled if one of its +// requirements is not met. +// - Users cannot enable a plugin if one of its requirements is not met. +// +// Now let's move to the Proxy side... Or the as of now, the Python side. +// +// Proxied plugins are much more annoying because they can implement all interfaces, and +// are given to MO2 as separate plugins... A Python class implementing IPluginGame and +// IPluginDiagnose will be seen by MO2 as two separate QObject, and they will all have +// the same name. +// +// When a proxied plugin is registered, a few things must be taken care of: +// - There can only be one plugin mapped to a name in the PluginContainer class, so we +// keep the +// plugin corresponding to the most relevant class (see PluginTypeOrder), e.g. if the +// class inherits both IPluginGame and IPluginFileMapper, we map the name to the C++ +// QObject corresponding to the IPluginGame. +// - When a proxied plugin implements multiple interfaces, the IPlugin corresponding to +// the most +// important interface is set as the parent (hidden) of the other IPlugin through +// PluginRequirements. This way, the plugin are managed together (enabled/disabled +// state). The "fake" children plugins will not be returned by +// PluginRequirements::children(). +// - Since each interface corresponds to a different QObject, we need to take care not +// to call +// IPlugin::init() on each QObject, but only on the first one. +// +// All the proxied plugins are linked to the proxy plugin by PluginRequirements. If the +// proxy plugin is disabled, the proxied plugins are not even loaded so not visible in +// the plugin management tab. + +template +struct PluginTypeName; + +template <> +struct PluginTypeName +{ + static QString value() { return QT_TR_NOOP("Plugin"); } +}; +template <> +struct PluginTypeName +{ + static QString value() { return QT_TR_NOOP("Diagnose"); } +}; +template <> +struct PluginTypeName +{ + static QString value() { return QT_TR_NOOP("Game"); } +}; +template <> +struct PluginTypeName +{ + static QString value() { return QT_TR_NOOP("Installer"); } +}; +template <> +struct PluginTypeName +{ + static QString value() { return QT_TR_NOOP("Mod Page"); } +}; +template <> +struct PluginTypeName +{ + static QString value() { return QT_TR_NOOP("Preview"); } +}; +template <> +struct PluginTypeName +{ + static QString value() { return QT_TR_NOOP("Tool"); } +}; +template <> +struct PluginTypeName +{ + static QString value() { return QT_TR_NOOP("Proxy"); } +}; +template <> +struct PluginTypeName +{ + static QString value() { return QT_TR_NOOP("File Mapper"); } +}; + +QStringList PluginContainer::pluginInterfaces() +{ + // Find all the names: + QStringList names; + boost::mp11::mp_for_each([&names](const auto* p) { + using plugin_type = std::decay_t; + auto name = PluginTypeName::value(); + if (!name.isEmpty()) { + names.append(name); + } + }); + + return names; +} + +// PluginRequirementProxy + +const std::set PluginRequirements::s_CorePlugins{"INI Bakery"}; + +PluginRequirements::PluginRequirements(PluginContainer* pluginContainer, + MOBase::IPlugin* plugin, OrganizerProxy* proxy, + MOBase::IPluginProxy* pluginProxy) + : m_PluginContainer(pluginContainer), m_Plugin(plugin), m_PluginProxy(pluginProxy), + m_Organizer(proxy) +{ + // There are a lots of things we cannot set here (e.g. m_Master) because we do not + // know the order plugins are loaded. +} + +void PluginRequirements::fetchRequirements() +{ + m_Requirements = m_Plugin->requirements(); +} + +IPluginProxy* PluginRequirements::proxy() const +{ + return m_PluginProxy; +} + +std::vector PluginRequirements::proxied() const +{ + std::vector children; + if (dynamic_cast(m_Plugin)) { + for (auto* obj : m_PluginContainer->plugins()) { + auto* plugin = qobject_cast(obj); + if (plugin && m_PluginContainer->requirements(plugin).proxy() == m_Plugin) { + children.push_back(plugin); + } + } + } + return children; +} + +IPlugin* PluginRequirements::master() const +{ + // If we have a m_Master, it was forced and thus override the default master(). + if (m_Master) { + return m_Master; + } + + if (m_Plugin->master().isEmpty()) { + return nullptr; + } + + return m_PluginContainer->plugin(m_Plugin->master()); +} + +void PluginRequirements::setMaster(IPlugin* master) +{ + m_Master = master; +} + +std::vector PluginRequirements::children() const +{ + std::vector children; + for (auto* obj : m_PluginContainer->plugins()) { + auto* plugin = qobject_cast(obj); + + // Not checking master() but requirements().master() due to "hidden" + // masters. + // If the master has the same name as the plugin, this is a "hidden" + // master, we do not add it here. + if (plugin && m_PluginContainer->requirements(plugin).master() == m_Plugin && + plugin->name() != m_Plugin->name()) { + children.push_back(plugin); + } + } + return children; +} + +std::vector PluginRequirements::problems() const +{ + std::vector result; + for (const auto& requirement : m_Requirements) { + if (auto p = requirement->check(m_Organizer)) { + result.push_back(*p); + } + } + return result; +} + +bool PluginRequirements::canEnable() const +{ + return problems().empty(); +} + +bool PluginRequirements::isCorePlugin() const +{ + // Let's consider game plugins as "core": + if (m_PluginContainer->implementInterface(m_Plugin)) { + return true; + } + + return s_CorePlugins.contains(m_Plugin->name()); +} + +bool PluginRequirements::hasRequirements() const +{ + return !m_Requirements.empty(); +} + +QStringList PluginRequirements::requiredGames() const +{ + // We look for a "GameDependencyRequirement" - There can be only one since otherwise + // it'd mean that the plugin requires two games at once. + for (const auto& requirement : m_Requirements) { + if (const auto* gdep = + dynamic_cast(requirement.get())) { + return gdep->gameNames(); + } + } + + return {}; +} + +std::vector PluginRequirements::requiredFor() const +{ + std::vector required; + std::set visited; + requiredFor(required, visited); + return required; +} + +void PluginRequirements::requiredFor(std::vector& required, + std::set& visited) const +{ + // Handle cyclic dependencies. + if (visited.contains(m_Plugin)) { + return; + } + visited.insert(m_Plugin); + + for (auto& [plugin, requirements] : m_PluginContainer->m_Requirements) { + + // If the plugin is not enabled, discard: + if (!m_PluginContainer->isEnabled(plugin)) { + continue; + } + + // Check the requirements: + for (auto& requirement : requirements.m_Requirements) { + + // We check for plugin dependency. Game dependency are not checked this way. + if (const auto* pdep = + dynamic_cast(requirement.get())) { + + // Check if at least one of the plugin in the requirements is enabled (except + // this one): + bool oneEnabled = false; + for (auto& pluginName : pdep->pluginNames()) { + if (pluginName != m_Plugin->name() && + m_PluginContainer->isEnabled(pluginName)) { + oneEnabled = true; + break; + } + } + + // No plugin enabled found, so the plugin requires this plugin: + if (!oneEnabled) { + required.push_back(plugin); + requirements.requiredFor(required, visited); + break; + } + } + } + } +} + +// PluginContainer + +PluginContainer::PluginContainer(OrganizerCore* organizer) + : m_Organizer(organizer), + m_GameFeatures(std::make_unique(organizer, this)), + m_PreviewGenerator(*this) +{} + +PluginContainer::~PluginContainer() +{ + m_Organizer = nullptr; + unloadPlugins(); +} + +void PluginContainer::startPlugins(IUserInterface* userInterface) +{ + m_UserInterface = userInterface; + startPluginsImpl(plugins()); +} + +QStringList PluginContainer::implementedInterfaces(IPlugin* plugin) const +{ + // We need a QObject to be able to qobject_cast<> to the plugin types: + QObject* oPlugin = as_qobject(plugin); + + if (!oPlugin) { + return {}; + } + + return implementedInterfaces(oPlugin); +} + +QStringList PluginContainer::implementedInterfaces(QObject* oPlugin) +{ + // Find all the names: + QStringList names; + boost::mp11::mp_for_each([oPlugin, &names](const auto* p) { + using plugin_type = std::decay_t; + if (qobject_cast(oPlugin)) { + auto name = PluginTypeName::value(); + if (!name.isEmpty()) { + names.append(name); + } + } + }); + + // If the plugin implements at least one interface other than IPlugin, remove IPlugin: + if (names.size() > 1) { + names.removeAll(PluginTypeName::value()); + } + + return names; +} + +QString PluginContainer::topImplementedInterface(IPlugin* plugin) const +{ + auto interfaces = implementedInterfaces(plugin); + return interfaces.isEmpty() ? "" : interfaces[0]; +} + +bool PluginContainer::isBetterInterface(QObject* lhs, QObject* rhs) +{ + int count = 0; + int lhsIdx = -1; + int rhsIdx = -1; + boost::mp11::mp_for_each([&](const auto* p) { + using plugin_type = std::decay_t; + if (lhsIdx < 0 && qobject_cast(lhs)) { + lhsIdx = count; + } + if (rhsIdx < 0 && qobject_cast(rhs)) { + rhsIdx = count; + } + ++count; + }); + return lhsIdx < rhsIdx; +} + +QStringList PluginContainer::mergedProxyList(IPluginProxy* proxy) const +{ + const QString bundled = + m_BundledPluginPath.isEmpty() ? AppConfig::pluginsPath() : m_BundledPluginPath; + const QString instance = + m_PluginPath.isEmpty() ? AppConfig::pluginsPath() : m_PluginPath; + + QMap merged; + // Instance plugins first (lower priority) + if (instance != bundled) { + for (const auto& p : proxy->pluginList(instance)) + merged[QFileInfo(p).fileName()] = p; + } + // Bundled plugins overwrite (higher priority) + for (const auto& p : proxy->pluginList(bundled)) + merged[QFileInfo(p).fileName()] = p; + + return merged.values(); +} + +QStringList PluginContainer::pluginFileNames() const +{ + QStringList result; + for (QPluginLoader* loader : m_PluginLoaders) { + result.append(loader->fileName()); + } + std::vector proxyList = bf::at_key(m_Plugins); + for (IPluginProxy* proxy : proxyList) { + result.append(mergedProxyList(proxy)); + } + return result; +} + +QObject* PluginContainer::as_qobject(MOBase::IPlugin* plugin) const +{ + // Find the correspond QObject - Can this be done safely with a cast? + const auto& objects = bf::at_key(m_Plugins); + auto it = + std::find_if(std::begin(objects), std::end(objects), [plugin](QObject* obj) { + return qobject_cast(obj) == plugin; + }); + + if (it == std::end(objects)) { + return nullptr; + } + + return *it; +} + +bool PluginContainer::initPlugin(IPlugin* plugin, IPluginProxy* pluginProxy, + bool skipInit) +{ + // when MO has no instance loaded, init() is not called on plugins, except + // for proxy plugins, where init() is called with a null IOrganizer + // + // after proxies are initialized, instantiate() is called for all the plugins + // they've discovered, but as for regular plugins, init() won't be + // called on them if m_OrganizerCore is null + + if (plugin == nullptr) { + return false; + } + + OrganizerProxy* proxy = nullptr; + if (m_Organizer) { + proxy = new OrganizerProxy(m_Organizer, this, plugin); + proxy->setParent(as_qobject(plugin)); + } + + // Check if it is a proxy plugin: + bool isProxy = dynamic_cast(plugin); + + auto [it, bl] = m_Requirements.emplace( + plugin, PluginRequirements(this, plugin, proxy, pluginProxy)); + + if (!m_Organizer && !isProxy) { + return true; + } + + if (skipInit) { + return true; + } + + if (!plugin->init(proxy)) { + log::warn("plugin failed to initialize"); + return false; + } + + // Update requirements: + it->second.fetchRequirements(); + + return true; +} + +void PluginContainer::registerGame(IPluginGame* game) +{ + m_SupportedGames.insert({game->gameName(), game}); +} + +void PluginContainer::unregisterGame(MOBase::IPluginGame* game) +{ + m_SupportedGames.erase(game->gameName()); +} + +IPlugin* PluginContainer::registerPlugin(QObject* plugin, const QString& filepath, + MOBase::IPluginProxy* pluginProxy) +{ + + // generic treatment for all plugins + IPlugin* pluginObj = qobject_cast(plugin); + if (pluginObj == nullptr) { + log::debug("PluginContainer::registerPlugin() called with a non IPlugin QObject."); + return nullptr; + } + + // If we already a plugin with this name: + bool skipInit = false; + auto& mapNames = bf::at_key(m_AccessPlugins); + if (mapNames.contains(pluginObj->name())) { + + IPlugin* other = mapNames[pluginObj->name()]; + + // If both plugins are from the same proxy and the same file, this is usually + // ok (in theory some one could write two different classes from the same Python + // file/module): + if (pluginProxy && m_Requirements.at(other).proxy() == pluginProxy && + this->filepath(other) == QDir::cleanPath(filepath)) { + + // Plugin has already been initialized: + skipInit = true; + + if (isBetterInterface(plugin, as_qobject(other))) { + log::debug( + "replacing plugin '{}' with interfaces [{}] by one with interfaces [{}]", + pluginObj->name(), implementedInterfaces(other).join(", "), + implementedInterfaces(plugin).join(", ")); + bf::at_key(m_AccessPlugins)[pluginObj->name()] = pluginObj; + } + } else { + log::warn("Trying to register two plugins with the name '{}' (from {} and {}), " + "the second one will not be registered.", + pluginObj->name(), this->filepath(other), QDir::cleanPath(filepath)); + return nullptr; + } + } else { + bf::at_key(m_AccessPlugins)[pluginObj->name()] = pluginObj; + } + + // Storing the original QObject* is a bit of a hack as I couldn't figure out any + // way to cast directly between IPlugin* and IPluginDiagnose* + bf::at_key(m_Plugins).push_back(plugin); + + plugin->setProperty("filepath", QDir::cleanPath(filepath)); + plugin->setParent(this); + + if (m_Organizer) { + m_Organizer->settings().plugins().registerPlugin(pluginObj); + } + + { // diagnosis plugin + IPluginDiagnose* diagnose = qobject_cast(plugin); + if (diagnose != nullptr) { + bf::at_key(m_Plugins).push_back(diagnose); + bf::at_key(m_AccessPlugins)[diagnose] = pluginObj; + diagnose->onInvalidated([&]() { + emit diagnosisUpdate(); + }); + } + } + { // file mapper plugin + IPluginFileMapper* mapper = qobject_cast(plugin); + if (mapper != nullptr) { + bf::at_key(m_Plugins).push_back(mapper); + bf::at_key(m_AccessPlugins)[mapper] = pluginObj; + } + } + { // mod page plugin + IPluginModPage* modPage = qobject_cast(plugin); + if (initPlugin(modPage, pluginProxy, skipInit)) { + bf::at_key(m_Plugins).push_back(modPage); + emit pluginRegistered(modPage); + return modPage; + } + } + { // game plugin + IPluginGame* game = qobject_cast(plugin); + if (game) { + game->detectGame(); + if (initPlugin(game, pluginProxy, skipInit)) { + bf::at_key(m_Plugins).push_back(game); + registerGame(game); + emit pluginRegistered(game); + return game; + } + } + } + { // tool plugins + IPluginTool* tool = qobject_cast(plugin); + if (initPlugin(tool, pluginProxy, skipInit)) { + bf::at_key(m_Plugins).push_back(tool); + emit pluginRegistered(tool); + return tool; + } + } + { // installer plugins + IPluginInstaller* installer = qobject_cast(plugin); + if (initPlugin(installer, pluginProxy, skipInit)) { + bf::at_key(m_Plugins).push_back(installer); + if (m_Organizer) { + installer->setInstallationManager(m_Organizer->installationManager()); + } + emit pluginRegistered(installer); + return installer; + } + } + { // preview plugins + IPluginPreview* preview = qobject_cast(plugin); + if (initPlugin(preview, pluginProxy, skipInit)) { + bf::at_key(m_Plugins).push_back(preview); + return preview; + } + } + { // proxy plugins + IPluginProxy* proxy = qobject_cast(plugin); + if (initPlugin(proxy, pluginProxy, skipInit)) { + bf::at_key(m_Plugins).push_back(proxy); + emit pluginRegistered(proxy); + + QStringList filepaths = mergedProxyList(proxy); + log::debug("proxy '{}' discovered {} proxied plugin candidate(s)", + proxy->name(), filepaths.size()); + printPluginDiagToStderr( + QString("proxy '%1' discovered %2 proxied plugin candidate(s)") + .arg(proxy->name()) + .arg(filepaths.size())); + for (const QString& filepath : filepaths) { + log::debug("proxy '{}' candidate: '{}'", proxy->name(), + QDir::toNativeSeparators(filepath)); + loadProxied(filepath, proxy); + } + return proxy; + } + } + + { // dummy plugins + // only initialize these, no processing otherwise + IPlugin* dummy = qobject_cast(plugin); + if (initPlugin(dummy, pluginProxy, skipInit)) { + bf::at_key(m_Plugins).push_back(dummy); + emit pluginRegistered(dummy); + return dummy; + } + } + + return nullptr; +} + +IPluginGame* PluginContainer::managedGame() const +{ + // TODO: This const_cast is safe but ugly. Most methods require a IPlugin*, so + // returning a const-version if painful. This should be fixed by making methods accept + // a const IPlugin* instead, but there are a few tricks with qobject_cast and const. + return m_Organizer ? const_cast(m_Organizer->managedGame()) : nullptr; +} + +bool PluginContainer::isEnabled(IPlugin* plugin) const +{ + // Check if it's a game plugin: + if (implementInterface(plugin)) { + return plugin == m_Organizer->managedGame(); + } + + // Check the master, if any: + const auto& requirements = m_Requirements.at(plugin); + + if (requirements.master()) { + return isEnabled(requirements.master()); + } + + // Check if the plugin is enabled: + if (!m_Organizer->persistent(plugin->name(), "enabled", plugin->enabledByDefault()) + .toBool()) { + return false; + } + + // Check the requirements: + return m_Requirements.at(plugin).canEnable(); +} + +void PluginContainer::setEnabled(MOBase::IPlugin* plugin, bool enable, + bool dependencies) +{ + // If required, disable dependencies: + if (!enable && dependencies) { + for (auto* p : requirements(plugin).requiredFor()) { + // No need to "recurse" here since requiredFor already does it. + setEnabled(p, false, false); + } + } + + // Always disable/enable child plugins: + for (auto* p : requirements(plugin).children()) { + // "Child" plugin should have no dependencies. + setEnabled(p, enable, false); + } + + m_Organizer->setPersistent(plugin->name(), "enabled", enable, true); + + if (enable) { + emit pluginEnabled(plugin); + } else { + emit pluginDisabled(plugin); + } +} + +MOBase::IPlugin* PluginContainer::plugin(QString const& pluginName) const +{ + const auto& map = bf::at_key(m_AccessPlugins); + auto it = map.find(pluginName); + if (it == std::end(map)) { + return nullptr; + } + return it->second; +} + +MOBase::IPlugin* PluginContainer::plugin(MOBase::IPluginDiagnose* diagnose) const +{ + const auto& map = bf::at_key(m_AccessPlugins); + auto it = map.find(diagnose); + if (it == std::end(map)) { + return nullptr; + } + return it->second; +} + +MOBase::IPlugin* PluginContainer::plugin(MOBase::IPluginFileMapper* mapper) const +{ + const auto& map = bf::at_key(m_AccessPlugins); + auto it = map.find(mapper); + if (it == std::end(map)) { + return nullptr; + } + return it->second; +} + +bool PluginContainer::isEnabled(QString const& pluginName) const +{ + IPlugin* p = plugin(pluginName); + return p ? isEnabled(p) : false; +} +bool PluginContainer::isEnabled(MOBase::IPluginDiagnose* diagnose) const +{ + IPlugin* p = plugin(diagnose); + return p ? isEnabled(p) : false; +} +bool PluginContainer::isEnabled(MOBase::IPluginFileMapper* mapper) const +{ + IPlugin* p = plugin(mapper); + return p ? isEnabled(p) : false; +} + +const PluginRequirements& PluginContainer::requirements(IPlugin* plugin) const +{ + return m_Requirements.at(plugin); +} + +OrganizerProxy* PluginContainer::organizerProxy(MOBase::IPlugin* plugin) const +{ + return requirements(plugin).m_Organizer; +} + +MOBase::IPluginProxy* PluginContainer::pluginProxy(MOBase::IPlugin* plugin) const +{ + return requirements(plugin).proxy(); +} + +QString PluginContainer::filepath(MOBase::IPlugin* plugin) const +{ + return as_qobject(plugin)->property("filepath").toString(); +} + +IPluginGame* PluginContainer::game(const QString& name) const +{ + auto iter = m_SupportedGames.find(name); + if (iter != m_SupportedGames.end()) { + return iter->second; + } else { + return nullptr; + } +} + +void PluginContainer::startPluginsImpl(const std::vector& plugins) const +{ + // setUserInterface() + if (m_UserInterface) { + for (auto* plugin : plugins) { + if (auto* proxy = qobject_cast(plugin)) { + proxy->setParentWidget(m_UserInterface->mainWindow()); + } + if (auto* modPage = qobject_cast(plugin)) { + modPage->setParentWidget(m_UserInterface->mainWindow()); + } + if (auto* tool = qobject_cast(plugin)) { + tool->setParentWidget(m_UserInterface->mainWindow()); + } + if (auto* installer = qobject_cast(plugin)) { + installer->setParentWidget(m_UserInterface->mainWindow()); + } + } + } + + // Trigger initial callbacks, e.g. onUserInterfaceInitialized and onProfileChanged. + if (m_Organizer) { + for (auto* object : plugins) { + auto* plugin = qobject_cast(object); + auto* oproxy = organizerProxy(plugin); + oproxy->connectSignals(); + oproxy->m_ProfileChanged(nullptr, m_Organizer->currentProfile().get()); + + if (m_UserInterface) { + oproxy->m_UserInterfaceInitialized(m_UserInterface->mainWindow()); + } + } + } +} + +std::vector PluginContainer::loadProxied(const QString& filepath, + IPluginProxy* proxy) +{ + std::vector proxiedPlugins; + + try { + log::debug("loading proxied plugin candidate '{}' via proxy '{}'", + QDir::toNativeSeparators(filepath), + (proxy ? proxy->name() : QStringLiteral(""))); + printPluginDiagToStderr( + QString("loading proxied plugin candidate '%1' via proxy '%2'") + .arg(QDir::toNativeSeparators(filepath)) + .arg(proxy ? proxy->name() : QStringLiteral(""))); + + // We get a list of matching plugins as proxies can return multiple plugins + // per file and do not have a good way of supporting multiple inheritance. + QList matchingPlugins = proxy->load(filepath); + if (matchingPlugins.isEmpty()) { + log::debug("no plugins were returned for proxied candidate '{}' via proxy '{}'", + QDir::toNativeSeparators(filepath), proxy->name()); + printPluginDiagToStderr( + QString("no plugins were returned for proxied candidate '%1' via proxy '%2'") + .arg(QDir::toNativeSeparators(filepath)) + .arg(proxy->name())); + } + + // We are going to group plugin by names and "fix" them later: + std::map> proxiedByNames; + + for (QObject* proxiedPlugin : matchingPlugins) { + if (proxiedPlugin == nullptr) { + log::warn("proxy '{}' returned a null QObject for '{}'", proxy->name(), + QDir::toNativeSeparators(filepath)); + printPluginDiagToStderr( + QString("proxy '%1' returned a null QObject for '%2'") + .arg(proxy->name()) + .arg(QDir::toNativeSeparators(filepath))); + continue; + } + + if (IPlugin* proxied = registerPlugin(proxiedPlugin, filepath, proxy); proxied) { + log::debug("loaded plugin '{}@{}' from '{}' - [{}]", proxied->name(), + proxied->version().canonicalString(), + QFileInfo(filepath).fileName(), + implementedInterfaces(proxied).join(", ")); + + // Store the plugin for later: + proxiedPlugins.push_back(proxiedPlugin); + proxiedByNames[proxied->name()].push_back(proxied); + } else { + log::warn( + "proxied candidate '{}' from proxy '{}' failed to register as an MO2 plugin", + QDir::toNativeSeparators(filepath), proxy->name()); + printPluginDiagToStderr( + QString("proxied candidate '%1' from proxy '%2' failed to register as an " + "MO2 plugin") + .arg(QDir::toNativeSeparators(filepath)) + .arg(proxy->name())); + } + } + + // Fake masters: + for (auto& [name, proxiedPlugins] : proxiedByNames) { + if (proxiedPlugins.size() > 1) { + auto it = std::min_element(std::begin(proxiedPlugins), std::end(proxiedPlugins), + [&](auto const& lhs, auto const& rhs) { + return isBetterInterface(as_qobject(lhs), + as_qobject(rhs)); + }); + + for (auto& proxiedPlugin : proxiedPlugins) { + if (proxiedPlugin != *it) { + m_Requirements.at(proxiedPlugin).setMaster(*it); + } + } + } + } + log::debug("finished proxied candidate '{}' via proxy '{}': {} plugin(s) loaded", + QDir::toNativeSeparators(filepath), proxy->name(), + proxiedPlugins.size()); + printPluginDiagToStderr( + QString("finished proxied candidate '%1' via proxy '%2': %3 plugin(s) loaded") + .arg(QDir::toNativeSeparators(filepath)) + .arg(proxy->name()) + .arg(proxiedPlugins.size())); + } catch (const std::exception& e) { + log::error("failed to initialize proxied candidate '{}' via proxy '{}': {}", + QDir::toNativeSeparators(filepath), + (proxy ? proxy->name() : QStringLiteral("")), e.what()); + printPluginDiagToStderr( + QString("failed to initialize proxied candidate '%1' via proxy '%2': %3") + .arg(QDir::toNativeSeparators(filepath)) + .arg(proxy ? proxy->name() : QStringLiteral("")) + .arg(e.what())); + reportError( + QObject::tr("failed to initialize plugin %1: %2").arg(filepath).arg(e.what())); + } catch (...) { + log::error("failed to initialize proxied candidate '{}' via proxy '{}': " + "unknown exception", + QDir::toNativeSeparators(filepath), + (proxy ? proxy->name() : QStringLiteral(""))); + printPluginDiagToStderr( + QString("failed to initialize proxied candidate '%1' via proxy '%2': unknown " + "exception") + .arg(QDir::toNativeSeparators(filepath)) + .arg(proxy ? proxy->name() : QStringLiteral(""))); + reportError(QObject::tr("failed to initialize plugin %1: unknown exception") + .arg(filepath)); + } + + return proxiedPlugins; +} + +QObject* PluginContainer::loadQtPlugin(const QString& filepath) +{ + std::unique_ptr pluginLoader(new QPluginLoader(filepath, this)); + if (pluginLoader->instance() == nullptr) { + m_FailedPlugins.push_back(filepath); + log::error("failed to load plugin {}: {}", filepath, pluginLoader->errorString()); + } else { + QObject* object = pluginLoader->instance(); + if (IPlugin* plugin = registerPlugin(object, filepath, nullptr); plugin) { + log::debug("loaded plugin '{}@{}' from '{}' - [{}]", plugin->name(), + plugin->version().canonicalString(), QFileInfo(filepath).fileName(), + implementedInterfaces(plugin).join(", ")); + m_PluginLoaders.push_back(pluginLoader.release()); + return object; + } else { + m_FailedPlugins.push_back(filepath); + log::warn("plugin '{}' failed to load (may be outdated)", filepath); + } + } + return nullptr; +} + +std::optional PluginContainer::isQtPluginFolder(const QString& filepath) +{ + + if (!QFileInfo(filepath).isDir()) { + return {}; + } + + QDirIterator iter(filepath, QDir::Files | QDir::NoDotAndDotDot); + while (iter.hasNext()) { + iter.next(); + const auto filePath = iter.filePath(); + + // not a library, skip + if (!QLibrary::isLibrary(filePath)) { + continue; + } + + // check if we have proper metadata - this does not load the plugin (metaData() + // should be very lightweight) + const QPluginLoader loader(filePath); + if (!loader.metaData().isEmpty()) { + return filePath; + } + } + + return {}; +} + +void PluginContainer::loadPlugin(QString const& filepath) +{ + std::vector plugins; + if (QFileInfo(filepath).isFile() && QLibrary::isLibrary(filepath)) { + QObject* plugin = loadQtPlugin(filepath); + if (plugin) { + plugins.push_back(plugin); + } + } else if (auto p = isQtPluginFolder(filepath)) { + QObject* plugin = loadQtPlugin(*p); + if (plugin) { + plugins.push_back(plugin); + } + } else { + // We need to check if this can be handled by a proxy. + for (auto* proxy : this->plugins()) { + auto filepaths = mergedProxyList(proxy); + if (filepaths.contains(filepath)) { + plugins = loadProxied(filepath, proxy); + break; + } + } + } + + for (auto* plugin : plugins) { + emit pluginRegistered(qobject_cast(plugin)); + } + + startPluginsImpl(plugins); +} + +void PluginContainer::unloadPlugin(MOBase::IPlugin* plugin, QObject* object) +{ + if (auto* game = qobject_cast(object)) { + + if (game == managedGame()) { + throw Exception("cannot unload the plugin for the currently managed game"); + } + + unregisterGame(game); + } + + // We need to remove from the m_Plugins maps BEFORE unloading from the proxy + // otherwise the qobject_cast to check the plugin type will not work. + bf::for_each(m_Plugins, [object](auto& t) { + using type = typename std::decay_t::value_type; + + // We do not want to remove from QObject since we are iterating over them. + if constexpr (!std::is_same{}) { + auto itp = + std::find(t.second.begin(), t.second.end(), qobject_cast(object)); + if (itp != t.second.end()) { + t.second.erase(itp); + } + } + }); + + emit pluginUnregistered(plugin); + + // Remove from the members. + if (auto* diagnose = qobject_cast(object)) { + bf::at_key(m_AccessPlugins).erase(diagnose); + } + if (auto* mapper = qobject_cast(object)) { + bf::at_key(m_AccessPlugins).erase(mapper); + } + + auto& mapNames = bf::at_key(m_AccessPlugins); + if (mapNames.contains(plugin->name())) { + mapNames.erase(plugin->name()); + } + + m_Organizer->settings().plugins().unregisterPlugin(plugin); + + // Force disconnection of the signals from the proxies. This is a safety + // operations since those signals should be disconnected when the proxies + // are destroyed anyway. + organizerProxy(plugin)->disconnectSignals(); + + // Is this a proxied plugin? + auto* proxy = pluginProxy(plugin); + + if (proxy) { + proxy->unload(filepath(plugin)); + } else { + // We need to find the loader. + auto it = std::find_if(m_PluginLoaders.begin(), m_PluginLoaders.end(), + [object](auto* loader) { + return loader->instance() == object; + }); + + if (it != m_PluginLoaders.end()) { + if (!(*it)->unload()) { + log::error("failed to unload {}: {}", (*it)->fileName(), (*it)->errorString()); + } + delete *it; + m_PluginLoaders.erase(it); + } else { + log::error("loader for plugin {} does not exist, cannot unload", plugin->name()); + } + } + + object->deleteLater(); + + // Do this at the end. + m_Requirements.erase(plugin); +} + +void PluginContainer::unloadPlugin(QString const& filepath) +{ + // We need to find all the plugins from the given path and + // unload them: + QString cleanPath = QDir::cleanPath(filepath); + auto& objects = bf::at_key(m_Plugins); + for (auto it = objects.begin(); it != objects.end();) { + auto* plugin = qobject_cast(*it); + if (this->filepath(plugin) == filepath) { + unloadPlugin(plugin, *it); + it = objects.erase(it); + } else { + ++it; + } + } +} + +void PluginContainer::reloadPlugin(QString const& filepath) +{ + unloadPlugin(filepath); + loadPlugin(filepath); +} + +void PluginContainer::unloadPlugins() +{ + if (m_Organizer) { + // this will clear several structures that can hold on to pointers to + // plugins, as well as read the plugin blacklist from the ini file, which + // is used in loadPlugins() below to skip plugins + // + // note that the first thing loadPlugins() does is call unloadPlugins(), + // so this makes sure the blacklist is always available + m_Organizer->settings().plugins().clearPlugins(); + } + + bf::for_each(m_Plugins, [](auto& t) { + t.second.clear(); + }); + bf::for_each(m_AccessPlugins, [](auto& t) { + t.second.clear(); + }); + m_Requirements.clear(); + + while (!m_PluginLoaders.empty()) { + QPluginLoader* loader = m_PluginLoaders.back(); + m_PluginLoaders.pop_back(); + if ((loader != nullptr) && !loader->unload()) { + log::debug("failed to unload {}: {}", loader->fileName(), loader->errorString()); + } + delete loader; + } +} + +void PluginContainer::loadPlugins() +{ + TimeThis tt("PluginContainer::loadPlugins()"); + + unloadPlugins(); + + for (QObject* plugin : QPluginLoader::staticInstances()) { + registerPlugin(plugin, "", nullptr); + } + + QFile loadCheck; + QString skipPlugin; + + if (m_Organizer) { + loadCheck.setFileName(qApp->property("dataPath").toString() + + "/plugin_loadcheck.tmp"); + + if (loadCheck.exists() && loadCheck.open(QIODevice::ReadOnly)) { + // oh, there was a failed plugin load last time. Find out which plugin was loaded + // last + QString fileName; + while (!loadCheck.atEnd()) { + fileName = QString::fromUtf8(loadCheck.readLine().constData()).trimmed(); + } + + log::warn("loadcheck file found for plugin '{}'", fileName); + + MOBase::TaskDialog dlg; + + const auto Skip = QMessageBox::Ignore; + const auto Blacklist = QMessageBox::Cancel; + const auto Load = QMessageBox::Ok; + + const auto r = + dlg.title(tr("Plugin error")) + .main(tr("Mod Organizer failed to load the plugin '%1' last time it was " + "started.") + .arg(fileName)) + .content(tr( + "The plugin can be skipped for this session, blacklisted, " + "or loaded normally, in which case it might fail again. Blacklisted " + "plugins can be re-enabled later in the settings.")) + .icon(QMessageBox::Warning) + .button({tr("Skip this plugin"), Skip}) + .button({tr("Blacklist this plugin"), Blacklist}) + .button({tr("Load this plugin"), Load}) + .exec(); + + switch (r) { + case Skip: + log::warn("user wants to skip plugin '{}'", fileName); + skipPlugin = fileName; + break; + + case Blacklist: + log::warn("user wants to blacklist plugin '{}'", fileName); + m_Organizer->settings().plugins().addBlacklist(fileName); + break; + + case Load: + log::warn("user wants to load plugin '{}' anyway", fileName); + break; + } + + loadCheck.close(); + } + + if (!loadCheck.open(QIODevice::WriteOnly)) { + log::warn("failed to open loadcheck file for writing '{}'", + QDir::toNativeSeparators(loadCheck.fileName())); + } + } + + m_BundledPluginPath = AppConfig::pluginsPath(); + + if (m_Organizer) { + QString instancePluginPath = + QDir(QDir::fromNativeSeparators(m_Organizer->basePath())).filePath("plugins"); + if (QDir::cleanPath(instancePluginPath) != QDir::cleanPath(m_BundledPluginPath)) { + QDir().mkpath(instancePluginPath); + m_PluginPath = instancePluginPath; + log::debug("instance plugin directory: {}", + QDir::toNativeSeparators(m_PluginPath)); + + // Migration: remove stale symlinks left by the old + // ensureBundledPluginsLinked() approach. Only symlinks are removed; real + // user files are left untouched. + QDirIterator cleanIter(instancePluginPath, + QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot); + while (cleanIter.hasNext()) { + cleanIter.next(); + if (QFileInfo(cleanIter.filePath()).isSymLink()) { + log::debug("removing stale plugin symlink '{}'", + QDir::toNativeSeparators(cleanIter.filePath())); + QFile::remove(cleanIter.filePath()); + } + } + } else { + m_PluginPath = m_BundledPluginPath; + } + } else { + m_PluginPath = m_BundledPluginPath; + } + + log::debug("bundled plugins: {}", QDir::toNativeSeparators(m_BundledPluginPath)); + log::debug("looking for plugins in {}", QDir::toNativeSeparators(m_PluginPath)); + + // Linux is case-sensitive; keep only the canonical Fallout NV plugin filename. + // Older builds may leave a stale lowercase artifact that causes duplicate + // registration warnings at startup. + auto cleanStaleNvPlugin = [](const QString& dir) { + const QString nvCanonical = dir + "/libgame_falloutNV.so"; + const QString nvStale = dir + "/libgame_falloutnv.so"; + if (QFile::exists(nvCanonical) && QFile::exists(nvStale)) { + if (QFile::remove(nvStale)) { + log::debug("removed stale plugin artifact '{}'", + QDir::toNativeSeparators(nvStale)); + } else { + log::warn("failed to remove stale plugin artifact '{}'", + QDir::toNativeSeparators(nvStale)); + } + } + }; + cleanStaleNvPlugin(m_BundledPluginPath); + if (m_PluginPath != m_BundledPluginPath) { + cleanStaleNvPlugin(m_PluginPath); + } + + // Build merged plugin map: instance extras first (low priority), + // then bundled plugins overwrite (high priority). + QMap pluginMap; // filename -> full path + + if (m_PluginPath != m_BundledPluginPath) { + QDirIterator instanceIter(m_PluginPath, + QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot); + while (instanceIter.hasNext()) { + instanceIter.next(); + pluginMap[instanceIter.fileName()] = instanceIter.filePath(); + } + } + + QDirIterator bundledIter(m_BundledPluginPath, + QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot); + while (bundledIter.hasNext()) { + bundledIter.next(); + pluginMap[bundledIter.fileName()] = bundledIter.filePath(); + } + + // Plugins that are permanently blacklisted for all users. These are + // known-incompatible with Fluorine / Linux and must never be loaded. + static const QSet hardBlacklist = { + QStringLiteral("rootbuilder"), + }; + + for (auto it = pluginMap.cbegin(); it != pluginMap.cend(); ++it) { + const QString& fileName = it.key(); + const QString& filepath = it.value(); + + if (hardBlacklist.contains(fileName.toLower())) { + log::debug("plugin \"{}\" is hard-blacklisted, skipping", fileName); + continue; + } + + if (skipPlugin == fileName) { + log::debug("plugin \"{}\" skipped for this session", fileName); + continue; + } + + if (m_Organizer) { + if (m_Organizer->settings().plugins().blacklisted(fileName)) { + log::debug("plugin \"{}\" blacklisted", fileName); + continue; + } + } + + if (loadCheck.isOpen()) { + loadCheck.write(fileName.toUtf8()); + loadCheck.write("\n"); + loadCheck.flush(); + } + + if (QLibrary::isLibrary(filepath)) { + loadQtPlugin(filepath); + } else if (auto p = isQtPluginFolder(filepath)) { + loadQtPlugin(*p); + } + } + + if (skipPlugin.isEmpty()) { + // remove the load check file on success + if (loadCheck.isOpen()) { + loadCheck.remove(); + } + } else { + // remember the plugin for next time + if (loadCheck.isOpen()) { + loadCheck.close(); + } + + log::warn("user skipped plugin '{}', remembering in loadcheck", skipPlugin); + if (loadCheck.open(QIODevice::WriteOnly)) { + loadCheck.write(skipPlugin.toUtf8()); + loadCheck.write("\n"); + loadCheck.flush(); + } else { + log::warn("failed to persist skipped plugin to '{}'", + QDir::toNativeSeparators(loadCheck.fileName())); + } + } + + bf::at_key(m_Plugins).push_back(this); + + if (m_Organizer) { + bf::at_key(m_Plugins).push_back(m_Organizer); + m_Organizer->connectPlugins(this); + } +} + +std::vector PluginContainer::activeProblems() const +{ + std::vector problems; + if (!m_FailedPlugins.empty()) { + problems.push_back(PROBLEM_PLUGINSNOTLOADED); + } + return problems; +} + +QString PluginContainer::shortDescription(unsigned int key) const +{ + switch (key) { + case PROBLEM_PLUGINSNOTLOADED: { + return tr("Some plugins could not be loaded"); + } break; + default: { + return tr("Description missing"); + } break; + } +} + +QString PluginContainer::fullDescription(unsigned int key) const +{ + switch (key) { + case PROBLEM_PLUGINSNOTLOADED: { + QString result = + tr("The following plugins could not be loaded. The reason may be missing " + "dependencies (i.e. python) or an outdated version:") + + "
          "; + for (const QString& plugin : m_FailedPlugins) { + result += "
        • " + plugin + "
        • "; + } + result += "
            "; + return result; + } break; + default: { + return tr("Description missing"); + } break; + } +} + +bool PluginContainer::hasGuidedFix(unsigned int) const +{ + return false; +} + +void PluginContainer::startGuidedFix(unsigned int) const {} diff --git a/src/src/pluginlist.cpp b/src/src/pluginlist.cpp index 41f3d27..0eb3c76 100644 --- a/src/src/pluginlist.cpp +++ b/src/src/pluginlist.cpp @@ -1,1978 +1,1977 @@ -/* -Copyright (C) 2012 Sebastian Herbord. All rights reserved. - -This file is part of Mod Organizer. - -Mod Organizer is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -Mod Organizer is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with Mod Organizer. If not, see . -*/ -#include "pluginlist.h" - -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include - -#include "gameplugins.h" -#include "modinfo.h" -#include "modlist.h" -#include "organizercore.h" -#include "settings.h" -#include "shared/directoryentry.h" -#include "shared/fileentry.h" -#include "shared/filesorigin.h" -#include "shared/windows_error.h" -#include "viewmarkingscrollbar.h" - -#include -#include - -using namespace MOBase; -using namespace MOShared; - -static QString TruncateString(const QString& text) -{ - QString new_text = text; - - if (new_text.length() > 1024) { - new_text.truncate(1024); - new_text += "..."; - } - - return new_text; -} - -PluginList::PluginList(OrganizerCore& organizer) - : QAbstractItemModel(&organizer), m_Organizer(organizer), m_FontMetrics(QFont()) -{ - connect(this, SIGNAL(writePluginsList()), this, SLOT(generatePluginIndexes())); - m_LastCheck.start(); -} - -PluginList::~PluginList() -{ - m_Refreshed.disconnect_all_slots(); - m_PluginMoved.disconnect_all_slots(); - m_PluginStateChanged.disconnect_all_slots(); -} - -QString PluginList::getColumnName(int column) -{ - switch (column) { - case COL_NAME: - return tr("Name"); - case COL_PRIORITY: - return tr("Priority"); - case COL_MODINDEX: - return tr("Mod Index"); - case COL_FLAGS: - return tr("Flags"); - case COL_FORMVERSION: - return tr("Form Version"); - case COL_HEADERVERSION: - return tr("Header Version"); - case COL_AUTHOR: - return tr("Author"); - case COL_DESCRIPTION: - return tr("Description"); - default: - return tr("unknown"); - } -} - -QString PluginList::getColumnToolTip(int column) -{ - switch (column) { - case COL_NAME: - return tr("Name of the plugin"); - case COL_FLAGS: - return tr("Emblems to highlight things that might require attention."); - case COL_PRIORITY: - return tr( - "Load priority of plugins. The higher, the more \"important\" it is and thus " - "overwrites data from plugins with lower priority."); - case COL_MODINDEX: - return tr("Determines the formids of objects originating from this mods."); - case COL_FORMVERSION: - return tr("Form version of the plugin."); - case COL_HEADERVERSION: - return tr("Header version of the plugin."); - case COL_AUTHOR: - return tr("Author of the plugin."); - case COL_DESCRIPTION: - return tr("Description of the plugin."); - default: - return tr("unknown"); - } -} - -void PluginList::highlightPlugins(const std::vector& modIndices, - const MOShared::DirectoryEntry& directoryEntry) -{ - auto profile = m_Organizer.currentProfile(); - - for (auto& esp : m_ESPs) { - esp.modSelected = false; - } - - for (auto& modIndex : modIndices) { - ModInfo::Ptr selectedMod = ModInfo::getByIndex(modIndex); - if (!selectedMod.isNull() && profile->modEnabled(modIndex)) { - QString modDataPath = selectedMod->absolutePath(); - modDataPath = - m_Organizer.managedGame()->modDataDirectory().isEmpty() - ? modDataPath - : modDataPath + "/" + m_Organizer.managedGame()->modDataDirectory(); - QDir dir(modDataPath); - QStringList plugins; - if (dir.exists()) { - plugins = dir.entryList(QStringList() << "*.esp" - << "*.esm" - << "*.esl"); - } - const MOShared::FilesOrigin& origin = - directoryEntry.getOriginByName(selectedMod->internalName().toStdWString()); - if (!plugins.empty()) { - for (auto plugin : plugins) { - MOShared::FileEntryPtr file = directoryEntry.findFile(plugin.toStdWString()); - if (file && file->getOrigin() != origin.getID()) { - const auto alternatives = file->getAlternatives(); - if (std::find_if(alternatives.begin(), alternatives.end(), - [&](const FileAlternative& element) { - return element.originID() == origin.getID(); - }) == alternatives.end()) - continue; - } - std::map::iterator iter = m_ESPsByName.find(plugin); - if (iter != m_ESPsByName.end()) { - m_ESPs[iter->second].modSelected = true; - } - } - } - } - } - - emit dataChanged(this->index(0, 0), this->index(static_cast(m_ESPs.size()) - 1, - this->columnCount() - 1)); -} - -void PluginList::highlightMasters(const QModelIndexList& selectedPluginIndices) -{ - for (auto& esp : m_ESPs) { - esp.isMasterOfSelectedPlugin = false; - } - - for (const auto& pluginIndex : selectedPluginIndices) { - const ESPInfo& plugin = m_ESPs[pluginIndex.row()]; - for (const auto& master : plugin.masters) { - const auto iter = m_ESPsByName.find(master); - if (iter != m_ESPsByName.end()) { - m_ESPs[iter->second].isMasterOfSelectedPlugin = true; - } - } - } -} - -void PluginList::refresh(const QString& profileName, - const DirectoryEntry& baseDirectory, - const QString& lockedOrderFile, bool force) -{ - TimeThis tt("PluginList::refresh()"); - - if (force) { - m_ESPs.clear(); - m_ESPsByName.clear(); - m_ESPsByPriority.clear(); - } - - ChangeBracket layoutChange(this); - - QStringList primaryPlugins = m_GamePlugin->primaryPlugins(); - QStringList enabledPlugins = m_GamePlugin->enabledPlugins(); - auto gamePlugins = m_Organizer.gameFeatures().gameFeature(); - const bool lightPluginsAreSupported = - gamePlugins ? gamePlugins->lightPluginsAreSupported() : false; - const bool mediumPluginsAreSupported = - gamePlugins ? gamePlugins->mediumPluginsAreSupported() : false; - const bool blueprintPluginsAreSupported = - gamePlugins ? gamePlugins->blueprintPluginsAreSupported() : false; - const bool loadOrderMechanismNone = - m_GamePlugin->loadOrderMechanism() == IPluginGame::LoadOrderMechanism::None; - - m_CurrentProfile = profileName; - - std::unordered_map availablePlugins; - QStringList archiveCandidates; - - for (FileEntryPtr current : baseDirectory.getFiles()) { - if (current.get() == nullptr) { - continue; - } - const QString& filename = ToQString(current->getName()); - - if (filename.endsWith(".esp", Qt::CaseInsensitive) || - filename.endsWith(".esm", Qt::CaseInsensitive) || - filename.endsWith(".esl", Qt::CaseInsensitive)) { - availablePlugins.insert(std::make_pair(filename, current)); - } else if (filename.endsWith(".bsa", Qt::CaseInsensitive) || - filename.endsWith("ba2", Qt::CaseInsensitive)) { - archiveCandidates.append(filename); - } - } - - for (const auto& [filename, current] : availablePlugins) { - if (m_ESPsByName.contains(filename)) { - continue; - } - - bool forceLoaded = Settings::instance().game().forceEnableCoreFiles() && - primaryPlugins.contains(filename, Qt::CaseInsensitive); - bool forceEnabled = enabledPlugins.contains(filename, Qt::CaseInsensitive); - bool forceDisabled = loadOrderMechanismNone && !forceLoaded && !forceEnabled; - if (!lightPluginsAreSupported && filename.endsWith(".esl")) { - forceDisabled = true; - } - - bool archive = false; - try { - FilesOrigin& origin = baseDirectory.getOriginByID(current->getOrigin(archive)); - - // name without extension - QString baseName = QFileInfo(filename).completeBaseName(); - - QString iniPath = baseName + ".ini"; - bool hasIni = baseDirectory.findFile(ToWString(iniPath)).get() != nullptr; - std::set loadedArchives; - for (const auto& archiveName : archiveCandidates) { - if (archiveName.startsWith(baseName, Qt::CaseInsensitive)) { - loadedArchives.insert(archiveName); - } - } - - QString originName = ToQString(origin.getName()); - unsigned int modIndex = ModInfo::getIndex(originName); - if (modIndex != UINT_MAX) { - ModInfo::Ptr modInfo = ModInfo::getByIndex(modIndex); - originName = modInfo->name(); - } - - m_ESPs.emplace_back(filename, forceLoaded, forceEnabled, forceDisabled, - originName, ToQString(current->getFullPath()), hasIni, - loadedArchives, lightPluginsAreSupported, - mediumPluginsAreSupported, blueprintPluginsAreSupported); - m_ESPs.rbegin()->priority = -1; - } catch (const std::exception& e) { - reportError(tr("failed to update esp info for file %1 (source id: %2), error: %3") - .arg(filename) - .arg(current->getOrigin(archive)) - .arg(e.what())); - } - } - - for (const auto& espName : m_ESPsByName) { - if (!availablePlugins.contains(espName.first)) { - m_ESPs[espName.second].name = ""; - } - } - - m_ESPs.erase(std::remove_if(m_ESPs.begin(), m_ESPs.end(), - [](const ESPInfo& info) -> bool { - return info.name.isEmpty(); - }), - m_ESPs.end()); - - fixPriorities(); - - // functions in GamePlugins will use the IPluginList interface of this, so - // indices need to work. priority will be off however - updateIndices(); - - if (gamePlugins) { - gamePlugins->readPluginLists(m_Organizer.managedGameOrganizer()->pluginList()); - } - - fixPrimaryPlugins(); - fixPluginRelationships(); - - testMasters(); - - updateIndices(); - - readLockedOrderFrom(lockedOrderFile); - - layoutChange.finish(); - - refreshLoadOrder(); - emit dataChanged(this->index(0, 0), - this->index(static_cast(m_ESPs.size()), columnCount())); - - m_Refreshed(); -} - -void PluginList::fixPrimaryPlugins() -{ - if (!m_Organizer.settings().game().forceEnableCoreFiles()) { - return; - } - - // This function ensures that the primary plugins are first and in the correct order - QStringList primaryPlugins = m_Organizer.managedGame()->primaryPlugins(); - int prio = 0; - int prioBlueprint = 0; - bool somethingChanged = false; - for (auto esp : m_ESPs) { - if (!esp.isBlueprintFlagged) - prioBlueprint++; - } - for (QString plugin : primaryPlugins) { - std::map::iterator iter = m_ESPsByName.find(plugin); - // Plugin is present? - if (iter != m_ESPsByName.end()) { - if (!m_ESPs[iter->second].isBlueprintFlagged) { - if (prio != m_ESPs[iter->second].priority) { - // Priority is wrong! Fix it! - int newPrio = prio; - setPluginPriority(iter->second, newPrio, true /* isForced */); - somethingChanged = true; - } - prio++; - } else { - if (prioBlueprint != m_ESPs[iter->second].priority) { - // Priority is wrong! Fix it! - int newPrio = prioBlueprint; - setPluginPriority(iter->second, newPrio, true /* isForced */); - somethingChanged = true; - } - prioBlueprint++; - } - } - } - - if (somethingChanged) { - writePluginsList(); - } -} - -void PluginList::fixPluginRelationships() -{ - TimeThis timer("PluginList::fixPluginRelationships"); - - // Count the types of plugins - int standardCount = 0; - int masterCount = 0; - int blueprintMasterCount = 0; - for (auto plugin : m_ESPs) { - if (plugin.hasLightExtension || plugin.hasMasterExtension || - plugin.isMasterFlagged) { - if (plugin.isBlueprintFlagged) { - blueprintMasterCount++; - } else { - masterCount++; - } - } - if (!plugin.isBlueprintFlagged) { - standardCount++; - } - } - - // Ensure masters are up top and normal plugins are down below - for (int i = 0; i < m_ESPs.size(); i++) { - ESPInfo& plugin = m_ESPs[i]; - if (plugin.hasLightExtension || plugin.hasMasterExtension || - plugin.isMasterFlagged) { - if (plugin.isBlueprintFlagged) { - if (plugin.priority > standardCount + blueprintMasterCount) { - int newPriority = standardCount + blueprintMasterCount; - setPluginPriority(i, newPriority); - } - } else { - if (plugin.priority > masterCount) { - int newPriority = masterCount; - setPluginPriority(i, newPriority); - } - } - } else { - if (plugin.isBlueprintFlagged) { - if (plugin.priority < standardCount + blueprintMasterCount) { - int newPriority = standardCount + blueprintMasterCount + 1; - setPluginPriority(i, newPriority); - } - } else { - if (plugin.priority < masterCount) { - int newPriority = masterCount + 1; - setPluginPriority(i, newPriority); - } - } - } - } - - // Ensure master/child relationships are observed - for (int i = 0; i < m_ESPs.size(); i++) { - ESPInfo& plugin = m_ESPs[i]; - int newPriority = plugin.priority; - for (auto master : plugin.masters) { - auto iter = m_ESPsByName.find(master); - if (iter != m_ESPsByName.end()) { - if (m_ESPs.at(iter->second).isBlueprintFlagged == plugin.isBlueprintFlagged) { - newPriority = std::max(newPriority, m_ESPs[iter->second].priority); - } - } - } - if (newPriority != plugin.priority) { - setPluginPriority(i, newPriority); - } - } -} - -void PluginList::fixPriorities() -{ - std::vector> espPrios; - - for (int i = 0; i < m_ESPs.size(); ++i) { - int prio = m_ESPs[i].priority; - if (prio == -1) { - prio = INT_MAX; - } - espPrios.push_back(std::make_pair(prio, i)); - } - - std::sort(espPrios.begin(), espPrios.end(), - [](const std::pair& lhs, const std::pair& rhs) { - return lhs.first < rhs.first; - }); - - for (int i = 0; i < espPrios.size(); ++i) { - m_ESPs[espPrios[i].second].priority = i; - } -} - -void PluginList::enableESP(const QString& name, bool enable) -{ - std::map::iterator iter = m_ESPsByName.find(name); - - if (iter != m_ESPsByName.end()) { - auto enabled = m_ESPs[iter->second].enabled; - m_ESPs[iter->second].enabled = (enable && !m_ESPs[iter->second].forceDisabled) || - m_ESPs[iter->second].forceLoaded || - m_ESPs[iter->second].forceEnabled; - - emit writePluginsList(); - if (enabled != m_ESPs[iter->second].enabled) { - pluginStatesChanged({name}, state(name)); - } - } else { - reportError(tr("Plugin not found: %1").arg(qUtf8Printable(name))); - } -} - -int PluginList::findPluginByPriority(int priority) -{ - for (int i = 0; i < m_ESPs.size(); i++) { - if (m_ESPs[i].priority == priority) { - return i; - } - } - log::error("No plugin with priority {}", priority); - return -1; -} - -void PluginList::setEnabled(const QModelIndexList& indices, bool enabled) -{ - QStringList dirty; - for (auto& idx : indices) { - if (m_ESPs[idx.row()].forceLoaded || m_ESPs[idx.row()].forceEnabled || - m_ESPs[idx.row()].forceDisabled) - continue; - if (m_ESPs[idx.row()].enabled != enabled) { - m_ESPs[idx.row()].enabled = enabled; - dirty.append(m_ESPs[idx.row()].name); - } - } - if (!dirty.isEmpty()) { - emit writePluginsList(); - pluginStatesChanged(dirty, enabled ? IPluginList::PluginState::STATE_ACTIVE - : IPluginList::PluginState::STATE_INACTIVE); - } -} - -void PluginList::setEnabledAll(bool enabled) -{ - QStringList dirty; - for (ESPInfo& info : m_ESPs) { - if (info.forceLoaded || info.forceEnabled || info.forceDisabled) - continue; - if (info.enabled != enabled) { - info.enabled = enabled; - dirty.append(info.name); - } - } - if (!dirty.isEmpty()) { - emit writePluginsList(); - pluginStatesChanged(dirty, enabled ? IPluginList::PluginState::STATE_ACTIVE - : IPluginList::PluginState::STATE_INACTIVE); - } -} - -void PluginList::sendToPriority(const QModelIndexList& indices, int newPriority) -{ - std::vector pluginsToMove; - for (auto& idx : indices) { - if (!m_ESPs[idx.row()].forceLoaded) { - pluginsToMove.push_back(idx.row()); - } - } - if (!pluginsToMove.empty()) { - changePluginPriority(pluginsToMove, newPriority); - } -} - -void PluginList::shiftPluginsPriority(const QModelIndexList& indices, int offset) -{ - // retrieve the plugin index and sort them by priority to avoid issue - // when moving them - std::vector allIndex; - for (auto& idx : indices) { - allIndex.push_back(idx.row()); - } - std::sort(allIndex.begin(), allIndex.end(), [=, this](int lhs, int rhs) { - bool cmp = m_ESPs[lhs].priority < m_ESPs[rhs].priority; - return offset > 0 ? !cmp : cmp; - }); - - for (auto index : allIndex) { - int newPriority = m_ESPs[index].priority + offset; - if (newPriority >= 0 && newPriority < rowCount()) { - setPluginPriority(index, newPriority); - } - } - - refreshLoadOrder(); -} - -void PluginList::toggleState(const QModelIndexList& indices) -{ - QModelIndex minRow, maxRow; - for (auto& idx : indices) { - if (!minRow.isValid() || (idx.row() < minRow.row())) { - minRow = idx; - } - if (!maxRow.isValid() || (idx.row() > maxRow.row())) { - maxRow = idx; - } - int oldState = idx.data(Qt::CheckStateRole).toInt(); - setData(idx, oldState == Qt::Unchecked ? Qt::Checked : Qt::Unchecked, - Qt::CheckStateRole); - } - - emit dataChanged(minRow, maxRow); -} - -bool PluginList::isEnabled(const QString& name) -{ - std::map::iterator iter = m_ESPsByName.find(name); - - if (iter != m_ESPsByName.end()) { - return m_ESPs[iter->second].enabled; - } else { - return false; - } -} - -void PluginList::clearInformation(const QString& name) -{ - std::map::iterator iter = m_ESPsByName.find(name); - - if (iter != m_ESPsByName.end()) { - m_AdditionalInfo[name].messages.clear(); - } -} - -void PluginList::clearAdditionalInformation() -{ - m_AdditionalInfo.clear(); -} - -void PluginList::addInformation(const QString& name, const QString& message) -{ - std::map::iterator iter = m_ESPsByName.find(name); - - if (iter != m_ESPsByName.end()) { - m_AdditionalInfo[name].messages.append(message); - } else { - log::warn("failed to associate message for \"{}\"", name); - } -} - -bool PluginList::isEnabled(int index) -{ - return m_ESPs.at(index).enabled; -} - -void PluginList::readLockedOrderFrom(const QString& fileName) -{ - m_LockedOrder.clear(); - - QFile file(fileName); - if (!file.exists()) { - // no locked load order, that's ok - return; - } - - if (!file.open(QIODevice::ReadOnly)) { - log::error("failed to open locked order file '{}': {}", fileName, file.errorString()); - return; - } - int lineNumber = 0; - while (!file.atEnd()) { - QByteArray line = file.readLine(); - ++lineNumber; - - // Skip empty lines or commented out lines (#) - if ((line.size() <= 0) || (line.at(0) == '#')) { - continue; - } - - QList fields = line.split('|'); - if (fields.count() != 2) { - // Don't know how to parse this so run away - log::error("locked order file: invalid line #{}: {}", lineNumber, - QString::fromUtf8(line).trimmed()); - continue; - } - - // Read the plugin name and priority - QString pluginName = QString::fromUtf8(fields.at(0)); - int priority = fields.at(1).trimmed().toInt(); - if (priority < 0) { - // WTF do you mean a negative priority? - log::error("locked order file: invalid line #{}: {}", lineNumber, - QString::fromUtf8(line).trimmed()); - continue; - } - - // Determine the index of the plugin - auto it = m_ESPsByName.find(pluginName); - if (it == m_ESPsByName.end()) { - // Plugin does not exist in the current set of plugins - m_LockedOrder[pluginName] = priority; - continue; - } - int pluginIndex = it->second; - - // Do not allow locking forced plugins - if (m_ESPs[pluginIndex].forceLoaded) { - continue; - } - - // If the priority is larger than the number of plugins, just keep it locked - if (priority >= m_ESPsByPriority.size()) { - m_LockedOrder[pluginName] = priority; - continue; - } - - // These are some helper functions for figuring out what is already locked - auto findLocked = [&](const std::pair& a) { - return a.second == priority; - }; - auto alreadyLocked = [&]() { - return std::find_if(m_LockedOrder.begin(), m_LockedOrder.end(), findLocked) != - m_LockedOrder.end(); - }; - - // See if we can just set the given priority - if (!m_ESPs[m_ESPsByPriority.at(priority)].forceLoaded && !alreadyLocked()) { - m_LockedOrder[pluginName] = priority; - continue; - } - - // Find the next higher priority we can set the plugin to - while (++priority < m_ESPs.size()) { - if (!m_ESPs[m_ESPsByPriority.at(priority)].forceLoaded && !alreadyLocked()) { - m_LockedOrder[pluginName] = priority; - break; - } - } - - // See if we walked off the end of the plugin list - if (priority >= m_ESPs.size()) { - // I guess go ahead and lock it here at the end of the list? - m_LockedOrder[pluginName] = priority; - continue; - } - } /* while (!file.atEnd()) */ - file.close(); -} - -void PluginList::writeLockedOrder(const QString& fileName) const -{ - SafeWriteFile file(fileName); - - file->resize(0); - file->write(QString("# This file was automatically generated by Mod Organizer.\r\n") - .toUtf8()); - for (auto iter = m_LockedOrder.begin(); iter != m_LockedOrder.end(); ++iter) { - file->write(QString("%1|%2\r\n").arg(iter->first).arg(iter->second).toUtf8()); - } - file->commit(); -} - -void PluginList::saveTo(const QString& lockedOrderFileName) const -{ - auto gamePlugins = m_Organizer.gameFeatures().gameFeature(); - if (gamePlugins) { - gamePlugins->writePluginLists(m_Organizer.managedGameOrganizer()->pluginList()); - } - - writeLockedOrder(lockedOrderFileName); -} - -bool PluginList::saveLoadOrder(DirectoryEntry& directoryStructure) -{ - if (m_GamePlugin->loadOrderMechanism() != IPluginGame::LoadOrderMechanism::FileTime) { - // nothing to do - return true; - } - - log::debug("setting file times on esps"); - - for (ESPInfo& esp : m_ESPs) { - std::wstring espName = ToWString(esp.name); - const FileEntryPtr fileEntry = directoryStructure.findFile(espName); - if (fileEntry.get() != nullptr) { - QString fileName; - bool archive = false; - int originid = fileEntry->getOrigin(archive); - - fileName = QDir(ToQString( - directoryStructure.getOriginByID(originid).getPath())) - .filePath(esp.name); - - // Use utimensat to set the file modification time. The values track the - // Windows-epoch FILETIME so they round-trip with the in-memory state. - ULONGLONG temp = (145731ULL + esp.priority) * 24 * 60 * 60 * 10000000ULL; - - FILETIME newWriteTime; - newWriteTime.dwLowDateTime = (DWORD)(temp & 0xFFFFFFFF); - newWriteTime.dwHighDateTime = (DWORD)(temp >> 32); - esp.time = newWriteTime; - fileEntry->setFileTime(newWriteTime); - - uint64_t ticks = - (static_cast(newWriteTime.dwHighDateTime) << 32) | - newWriteTime.dwLowDateTime; - // Convert from Windows epoch (1601) to Unix epoch (1970). - ticks -= 116444736000000000ULL; - time_t secs = static_cast(ticks / 10000000ULL); - struct timespec times[2]; - times[0].tv_sec = 0; - times[0].tv_nsec = UTIME_OMIT; - times[1].tv_sec = secs; - times[1].tv_nsec = 0; - std::string path = fileName.toStdString(); - if (utimensat(AT_FDCWD, path.c_str(), times, 0) != 0) { - log::warn("failed to set file time for {}", fileName); - } - } - } - return true; -} - -int PluginList::enabledCount() const -{ - int enabled = 0; - for (const auto& info : m_ESPs) { - if (info.enabled) { - ++enabled; - } - } - return enabled; -} - -QString PluginList::getIndexPriority(int index) const -{ - return m_ESPs[index].index; -} - -bool PluginList::isESPLocked(int index) const -{ - return m_LockedOrder.contains(m_ESPs.at(index).name); -} - -void PluginList::lockESPIndex(int index, bool lock) -{ - if (lock) { - if (!m_ESPs.at(index).forceLoaded) - m_LockedOrder[getName(index)] = m_ESPs.at(index).loadOrder; - else - return; - } else { - auto iter = m_LockedOrder.find(getName(index)); - if (iter != m_LockedOrder.end()) { - m_LockedOrder.erase(iter); - } - } - emit writePluginsList(); -} - -void PluginList::syncLoadOrder() -{ - int loadOrder = 0; - for (unsigned int i = 0; i < m_ESPs.size(); ++i) { - int index = m_ESPsByPriority[i]; - - if (m_ESPs[index].enabled) { - m_ESPs[index].loadOrder = loadOrder++; - } else { - m_ESPs[index].loadOrder = -1; - } - } -} - -void PluginList::refreshLoadOrder() -{ - ChangeBracket layoutChange(this); - syncLoadOrder(); - // set priorities according to locked load order - std::map lockedLoadOrder; - std::for_each(m_LockedOrder.begin(), m_LockedOrder.end(), - [&lockedLoadOrder](const std::pair& ele) { - lockedLoadOrder[ele.second] = ele.first; - }); - - int targetPrio = 0; - bool savePluginsList = false; - // this is guaranteed to iterate from lowest key (load order) to highest - for (auto iter = lockedLoadOrder.begin(); iter != lockedLoadOrder.end(); ++iter) { - auto nameIter = m_ESPsByName.find(iter->second); - if (nameIter != m_ESPsByName.end()) { - // locked esp exists - - // find the location to insert at - while ((targetPrio < static_cast(m_ESPs.size() - 1)) && - (m_ESPs[m_ESPsByPriority[targetPrio]].loadOrder < iter->first)) { - ++targetPrio; - } - - if (static_cast(targetPrio) >= m_ESPs.size()) { - continue; - } - - int temp = targetPrio; - int index = nameIter->second; - if (m_ESPs[index].priority != temp) { - setPluginPriority(index, temp); - m_ESPs[index].loadOrder = iter->first; - syncLoadOrder(); - savePluginsList = true; - } - } - } - if (savePluginsList) { - emit writePluginsList(); - } -} - -void PluginList::disconnectSlots() -{ - m_PluginMoved.disconnect_all_slots(); - m_Refreshed.disconnect_all_slots(); - m_PluginStateChanged.disconnect_all_slots(); -} - -int PluginList::timeElapsedSinceLastChecked() const -{ - return m_LastCheck.elapsed(); -} - -QStringList PluginList::pluginNames() const -{ - QStringList result; - - for (const ESPInfo& info : m_ESPs) { - result.append(info.name); - } - - return result; -} - -IPluginList::PluginStates PluginList::state(const QString& name) const -{ - auto iter = m_ESPsByName.find(name); - if (iter == m_ESPsByName.end()) { - return IPluginList::STATE_MISSING; - } else { - return m_ESPs[iter->second].enabled ? IPluginList::STATE_ACTIVE - : IPluginList::STATE_INACTIVE; - } -} - -void PluginList::setState(const QString& name, PluginStates state) -{ - auto iter = m_ESPsByName.find(name); - if (iter != m_ESPsByName.end()) { - m_ESPs[iter->second].enabled = - (state == IPluginList::STATE_ACTIVE && !m_ESPs[iter->second].forceDisabled) || - m_ESPs[iter->second].forceLoaded || m_ESPs[iter->second].forceEnabled; - } else { - log::warn("Plugin not found: {}", name); - } -} - -void PluginList::setLoadOrder(const QStringList& pluginList) -{ - for (ESPInfo& info : m_ESPs) { - info.priority = -1; - } - int maxPriority = 0; - for (const QString& plugin : pluginList) { - auto iter = m_ESPsByName.find(plugin); - if (iter != m_ESPsByName.end()) { - m_ESPs[iter->second].priority = maxPriority++; - } - } - - // use old priorities - for (ESPInfo& info : m_ESPs) { - if (info.priority == -1) { - info.priority = maxPriority++; - } - } - updateIndices(); -} - -int PluginList::priority(const QString& name) const -{ - auto iter = m_ESPsByName.find(name); - if (iter == m_ESPsByName.end()) { - return -1; - } else { - return m_ESPs[iter->second].priority; - } -} - -bool PluginList::setPriority(const QString& name, int newPriority) -{ - - if (newPriority < 0 || newPriority >= static_cast(m_ESPsByPriority.size())) { - return false; - } - - auto oldPriority = priority(name); - if (oldPriority == -1) { - return false; - } - - int rowIndex = findPluginByPriority(oldPriority); - - // We need to increment newPriority if its above the old one, otherwise the - // plugin is place right below the new priority. - if (oldPriority < newPriority) { - newPriority += 1; - } - changePluginPriority({rowIndex}, newPriority); - - return true; -} - -int PluginList::loadOrder(const QString& name) const -{ - auto iter = m_ESPsByName.find(name); - if (iter == m_ESPsByName.end()) { - return -1; - } else { - return m_ESPs[iter->second].loadOrder; - } -} - -QStringList PluginList::masters(const QString& name) const -{ - auto iter = m_ESPsByName.find(name); - if (iter == m_ESPsByName.end()) { - return {}; - } else { - QStringList result; - for (const QString& master : m_ESPs[iter->second].masters) { - result.append(master); - } - return result; - } -} - -QString PluginList::origin(const QString& name) const -{ - auto iter = m_ESPsByName.find(name); - if (iter == m_ESPsByName.end()) { - return {}; - } else { - return m_ESPs[iter->second].originName; - } -} - -bool PluginList::hasMasterExtension(const QString& name) const -{ - auto iter = m_ESPsByName.find(name); - if (iter == m_ESPsByName.end()) { - return false; - } else { - return m_ESPs[iter->second].hasMasterExtension; - } -} - -bool PluginList::hasLightExtension(const QString& name) const -{ - auto iter = m_ESPsByName.find(name); - if (iter == m_ESPsByName.end()) { - return false; - } else { - return m_ESPs[iter->second].hasLightExtension; - } -} - -bool PluginList::isMasterFlagged(const QString& name) const -{ - auto iter = m_ESPsByName.find(name); - if (iter == m_ESPsByName.end()) { - return false; - } else { - return m_ESPs[iter->second].isMasterFlagged; - } -} - -bool PluginList::isMediumFlagged(const QString& name) const -{ - auto iter = m_ESPsByName.find(name); - if (iter == m_ESPsByName.end()) { - return false; - } else { - return m_ESPs[iter->second].isMediumFlagged; - } -} - -bool PluginList::isLightFlagged(const QString& name) const -{ - auto iter = m_ESPsByName.find(name); - if (iter == m_ESPsByName.end()) { - return false; - } else { - return m_ESPs[iter->second].isLightFlagged; - } -} - -bool PluginList::isBlueprintFlagged(const QString& name) const -{ - auto iter = m_ESPsByName.find(name); - if (iter == m_ESPsByName.end()) { - return false; - } else { - return m_ESPs[iter->second].isBlueprintFlagged; - } -} - -bool PluginList::hasNoRecords(const QString& name) const -{ - auto iter = m_ESPsByName.find(name); - if (iter == m_ESPsByName.end()) { - return false; - } else { - return m_ESPs[iter->second].hasNoRecords; - } -} - -int PluginList::formVersion(const QString& name) const -{ - auto iter = m_ESPsByName.find(name); - if (iter == m_ESPsByName.end()) { - return -1; - } else { - return m_ESPs[iter->second].formVersion; - } -} - -float PluginList::headerVersion(const QString& name) const -{ - auto iter = m_ESPsByName.find(name); - if (iter == m_ESPsByName.end()) { - return -1; - } else { - return m_ESPs[iter->second].headerVersion; - } -} - -QString PluginList::author(const QString& name) const -{ - auto iter = m_ESPsByName.find(name); - if (iter == m_ESPsByName.end()) { - return {}; - } else { - return m_ESPs[iter->second].author; - } -} - -QString PluginList::description(const QString& name) const -{ - auto iter = m_ESPsByName.find(name); - if (iter == m_ESPsByName.end()) { - return {}; - } else { - return m_ESPs[iter->second].description; - } -} - -boost::signals2::connection PluginList::onPluginStateChanged( - const std::function&)>& func) -{ - return m_PluginStateChanged.connect(func); -} - -void PluginList::pluginStatesChanged(QStringList const& pluginNames, - PluginStates state) const -{ - if (pluginNames.isEmpty()) { - return; - } - std::map infos; - for (auto& name : pluginNames) { - infos[name] = state; - } - m_PluginStateChanged(infos); -} - -boost::signals2::connection -PluginList::onRefreshed(const std::function& callback) -{ - return m_Refreshed.connect(callback); -} - -boost::signals2::connection -PluginList::onPluginMoved(const std::function& func) -{ - return m_PluginMoved.connect(func); -} - -void PluginList::updateIndices() -{ - m_ESPsByName.clear(); - m_ESPsByPriority.clear(); - m_ESPsByPriority.resize(m_ESPs.size()); - for (unsigned int i = 0; i < m_ESPs.size(); ++i) { - if (m_ESPs[i].priority < 0) { - continue; - } - if (m_ESPs[i].priority >= static_cast(m_ESPs.size())) { - log::error("invalid plugin priority: {}", m_ESPs[i].priority); - continue; - } - m_ESPsByName[m_ESPs[i].name] = i; - m_ESPsByPriority.at(static_cast(m_ESPs[i].priority)) = i; - } - - generatePluginIndexes(); -} - -void PluginList::generatePluginIndexes() -{ - int numESLs = 0; - int numESHs = 0; - int numSkipped = 0; - - auto gamePlugins = m_Organizer.gameFeatures().gameFeature(); - const bool lightPluginsSupported = - gamePlugins ? gamePlugins->lightPluginsAreSupported() : false; - const bool mediumPluginsSupported = - gamePlugins ? gamePlugins->mediumPluginsAreSupported() : false; - - std::vector coreLightPlugins; - std::vector coreMediumPlugins; - - for (int l = 0; l < m_ESPs.size(); ++l) { - int i = m_ESPsByPriority.at(l); - if (!m_ESPs[i].enabled) { - m_ESPs[i].index = QString(); - ++numSkipped; - continue; - } - if (mediumPluginsSupported && m_ESPs[i].isMediumFlagged) { - int ESHpos = 253 + (numESHs / 256); - m_ESPs[i].index = QString("%1:%2") - .arg(ESHpos, 2, 16, QChar('0')) - .arg(numESHs % 256, 2, 16, QChar('0')) - .toUpper(); - ++numESHs; - - } else if (lightPluginsSupported && - (m_ESPs[i].hasLightExtension || m_ESPs[i].isLightFlagged)) { - int ESLpos = 254 + (numESLs / 4096); - m_ESPs[i].index = QString("%1:%2") - .arg(ESLpos, 2, 16, QChar('0')) - .arg(numESLs % 4096, 3, 16, QChar('0')) - .toUpper(); - ++numESLs; - } else { - m_ESPs[i].index = QString("%1") - .arg(l - numESHs - numESLs - numSkipped, 2, 16, QChar('0')) - .toUpper(); - } - } - emit esplist_changed(); -} - -int PluginList::rowCount(const QModelIndex& parent) const -{ - if (!parent.isValid()) { - return static_cast(m_ESPs.size()); - } else { - return 0; - } -} - -int PluginList::columnCount(const QModelIndex&) const -{ - return COL_LASTCOLUMN + 1; -} - -void PluginList::testMasters() -{ - std::set enabledMasters; - for (const auto& iter : m_ESPs) { - if (iter.enabled) { - enabledMasters.insert(iter.name); - } - } - - for (auto& iter : m_ESPs) { - iter.masterUnset.clear(); - if (iter.enabled) { - for (const auto& master : iter.masters) { - if (!enabledMasters.contains(master)) { - iter.masterUnset.insert(master); - } - } - } - } -} - -QVariant PluginList::data(const QModelIndex& modelIndex, int role) const -{ - int index = modelIndex.row(); - - if ((role == Qt::DisplayRole) || (role == Qt::EditRole)) { - return displayData(modelIndex); - } else if ((role == Qt::CheckStateRole) && (modelIndex.column() == 0)) { - return checkstateData(modelIndex); - } else if (role == Qt::ForegroundRole) { - return foregroundData(modelIndex); - } else if (role == Qt::BackgroundRole) { - return backgroundData(modelIndex); - } else if (role == Qt::FontRole) { - return fontData(modelIndex); - } else if (role == Qt::TextAlignmentRole) { - return alignmentData(modelIndex); - } else if (role == Qt::ToolTipRole) { - return tooltipData(modelIndex); - } else if (role == Qt::UserRole + 1) { - return iconData(modelIndex); - } - return {}; -} - -QVariant PluginList::displayData(const QModelIndex& modelIndex) const -{ - const int index = modelIndex.row(); - const auto& plugin = m_ESPs[index]; - - switch (modelIndex.column()) { - case COL_NAME: - return plugin.name; - - case COL_PRIORITY: - return QString::number(plugin.priority); - - case COL_MODINDEX: - return plugin.index; - - case COL_FORMVERSION: - return plugin.formVersion != 0 ? QString::number(plugin.formVersion) : QString(); - - case COL_HEADERVERSION: - return QString::number(plugin.headerVersion); - - case COL_AUTHOR: - return plugin.author; - - case COL_DESCRIPTION: - return plugin.description; - - default: - return {}; - } -} - -QVariant PluginList::checkstateData(const QModelIndex& modelIndex) const -{ - const int index = modelIndex.row(); - - if (m_ESPs[index].forceLoaded || m_ESPs[index].forceEnabled) { - return Qt::Checked; - } else if (m_ESPs[index].forceDisabled) { - return Qt::Unchecked; - } - - return m_ESPs[index].enabled ? Qt::Checked : Qt::Unchecked; -} - -QVariant PluginList::foregroundData(const QModelIndex& modelIndex) const -{ - const int index = modelIndex.row(); - - if ((modelIndex.column() == COL_NAME) && m_ESPs[index].forceLoaded) { - return QBrush(Qt::gray); - } - - if ((modelIndex.column() == COL_NAME) && m_ESPs[index].forceDisabled) { - return QBrush(Qt::darkRed); - } - - return {}; -} - -QVariant PluginList::backgroundData(const QModelIndex& modelIndex) const -{ - const int index = modelIndex.row(); - const ESPInfo& plugin = m_ESPs[index]; - - if (plugin.modSelected) { - return Settings::instance().colors().pluginListContained(); - } else if (plugin.isMasterOfSelectedPlugin) { - return Settings::instance().colors().pluginListMaster(); - } - - return {}; -} - -QVariant PluginList::fontData(const QModelIndex& modelIndex) const -{ - const int index = modelIndex.row(); - - QFont result; - - if (m_ESPs[index].hasMasterExtension || m_ESPs[index].isMasterFlagged || - m_ESPs[index].hasLightExtension) - result.setWeight(QFont::Bold); - if (m_ESPs[index].isLightFlagged || m_ESPs[index].hasLightExtension) - result.setItalic(true); - else if (m_ESPs[index].isMediumFlagged) - result.setUnderline(true); - if (m_ESPs[index].isBlueprintFlagged) - result.setLetterSpacing(QFont::SpacingType::AbsoluteSpacing, 2); - - return result; -} - -QVariant PluginList::alignmentData(const QModelIndex& modelIndex) -{ - const int index = modelIndex.row(); - - if (modelIndex.column() == 0) { - return {Qt::AlignLeft | Qt::AlignVCenter}; - } else { - return {Qt::AlignHCenter | Qt::AlignVCenter}; - } -} - -QVariant PluginList::tooltipData(const QModelIndex& modelIndex) const -{ - const int index = modelIndex.row(); - const auto& esp = m_ESPs[index]; - - QString toolTip; - - toolTip += "" + tr("Origin") + ": " + esp.originName; - - if (esp.forceLoaded) { - toolTip += "
            " + - tr("This plugin can't be disabled or moved (enforced by the game).") + - ""; - } - - if (esp.forceEnabled) { - toolTip += "
            " + - tr("This plugin can't be disabled (enforced by the game).") + ""; - } - - if (esp.formVersion != 0) { - // Oblivion-style plugin headers don't have a form version - toolTip += - "
            " + tr("Form Version") + ": " + QString::number(esp.formVersion); - } - - toolTip += - "
            " + tr("Header Version") + ": " + QString::number(esp.headerVersion); - - if (!esp.author.isEmpty()) { - toolTip += "
            " + tr("Author") + ": " + TruncateString(esp.author); - } - - if (esp.description.size() > 0) { - toolTip += - "
            " + tr("Description") + ": " + TruncateString(esp.description); - } - - if (!esp.masterUnset.empty()) { - toolTip += - "
            " + tr("Missing Masters") + ": " + "" + - TruncateString( - QStringList(esp.masterUnset.begin(), esp.masterUnset.end()).join(", ")) + - ""; - } - - std::set enabledMasters; - std::set_difference(esp.masters.begin(), esp.masters.end(), esp.masterUnset.begin(), - esp.masterUnset.end(), - std::inserter(enabledMasters, enabledMasters.end())); - - if (!enabledMasters.empty()) { - toolTip += "
            " + tr("Enabled Masters") + - ": " + TruncateString(SetJoin(enabledMasters, ", ")); - } - - if (!esp.archives.empty()) { - QString archiveString = - esp.archives.size() < 6 - ? TruncateString( - QStringList(esp.archives.begin(), esp.archives.end()).join(", ")) + - "
            " - : ""; - toolTip += "
            " + tr("Loads Archives") + ": " + archiveString + - tr("There are Archives connected to this plugin. Their assets will be " - "added to your game, overwriting in case of conflicts following the " - "plugin order. Loose files will always overwrite assets from " - "Archives. (This flag only checks for Archives from the same mod as " - "the plugin)"); - } - - if (esp.hasIni) { - toolTip += "
            " + tr("Loads INI settings") + - ": " - "
            " + - tr("There is an ini file connected to this plugin. Its settings will " - "be added to your game settings, overwriting in case of conflicts."); - } - - if (esp.isLightFlagged && !esp.hasLightExtension) { - QString type = esp.hasMasterExtension ? "ESM" : "ESP"; - toolTip += - "

            " + - tr("This %1 is flagged as a light plugin (ESL). It will adhere to the %1 load " - "order but the records will be loaded in ESL space (FE/FF). You can have up " - "to 4096 light plugins in addition to other plugin types.") - .arg(type); - } else if (esp.isMediumFlagged && esp.hasMasterExtension) { - toolTip += "

            " + - tr("This ESM is flagged as a medium plugin (ESH). It adheres to the ESM " - "load order but loads records in ESH space (FD). You can have 256 " - "medium plugins in addition to other plugin types."); - } - - if (esp.isBlueprintFlagged) { - toolTip += "

            " + - tr("This plugin has the blueprint flag. This forces it to load after " - "every other non-blueprint plugin. Blueprint plugins will adhere to " - "standard load order rules with other blueprint plugins."); - } - - if (esp.isLightFlagged && esp.isMediumFlagged) { - toolTip += - "

            " + - tr("WARNING: This plugin is both light and medium flagged. " - "This could indicate that the file was saved improperly " - "and may have mismatched record references. Use it at your own risk."); - } - - if (esp.hasNoRecords) { - toolTip += "

            " + tr("This is a dummy plugin. It contains no records and is " - "typically used to load a paired archive file."); - } - - if (esp.forceDisabled) { - auto feature = m_Organizer.gameFeatures().gameFeature(); - if (feature && esp.hasLightExtension && feature->lightPluginsAreSupported()) { - toolTip += "

            " + tr("Light plugins (ESL) are not supported by this game."); - } else { - toolTip += "

            " + tr("This game does not currently permit custom plugin " - "loading. There may be manual workarounds."); - } - } - - // additional info - auto itor = m_AdditionalInfo.find(esp.name); - - if (itor != m_AdditionalInfo.end()) { - if (!itor->second.messages.isEmpty()) { - toolTip += "
              "; - - for (auto&& message : itor->second.messages) { - toolTip += "
            • " + message + "
            • "; - } - - toolTip += "
            "; - } - } - - return toolTip; -} - -QVariant PluginList::iconData(const QModelIndex& modelIndex) const -{ - int index = modelIndex.row(); - - QVariantList result; - - const auto& esp = m_ESPs[index]; - - auto infoItor = m_AdditionalInfo.find(esp.name); - - const AdditionalInfo* info = nullptr; - if (infoItor != m_AdditionalInfo.end()) { - info = &infoItor->second; - } - - if (isProblematic(esp, info)) { - result.append(":/MO/gui/warning"); - } - - if (m_LockedOrder.contains(esp.name)) { - result.append(":/MO/gui/locked"); - } - - if (hasInfo(esp, info)) { - result.append(":/MO/gui/information"); - } - - if (esp.hasIni) { - result.append(":/MO/gui/attachment"); - } - - if (!esp.archives.empty()) { - result.append(":/MO/gui/archive_conflict_neutral"); - } - - if (esp.isLightFlagged && !esp.hasLightExtension) { - result.append(":/MO/gui/awaiting"); - } - - if (esp.isMediumFlagged) { - result.append(":/MO/gui/run"); - if (esp.isLightFlagged) { - result.append(":/MO/gui/warning"); - } - } - - if (esp.isBlueprintFlagged) { - result.append(":/MO/gui/resources/go-down.png"); - } - - if (esp.hasNoRecords) { - result.append(":/MO/gui/unchecked-checkbox"); - } - - return result; -} - -bool PluginList::isProblematic(const ESPInfo& esp, const AdditionalInfo*) -{ - return !esp.masterUnset.empty(); -} - -bool PluginList::hasInfo(const ESPInfo&, const AdditionalInfo* info) -{ - return info && !info->messages.empty(); -} - -bool PluginList::setData(const QModelIndex& modIndex, const QVariant& value, int role) -{ - QString modName = modIndex.data().toString(); - IPluginList::PluginStates oldState = state(modName); - - bool result = false; - - if (role == Qt::CheckStateRole) { - m_ESPs[modIndex.row()].enabled = value.toInt() == Qt::Checked || - m_ESPs[modIndex.row()].forceLoaded || - m_ESPs[modIndex.row()].forceEnabled; - m_LastCheck.restart(); - emit dataChanged(modIndex, modIndex); - - refreshLoadOrder(); - emit writePluginsList(); - - result = true; - } else if (role == Qt::EditRole) { - if (modIndex.column() == COL_PRIORITY) { - bool ok = false; - int newPriority = value.toInt(&ok); - if (ok) { - setPluginPriority(modIndex.row(), newPriority); - result = true; - } - refreshLoadOrder(); - emit writePluginsList(); - } - } - - IPluginList::PluginStates newState = state(modName); - if (oldState != newState) { - try { - pluginStatesChanged({modName}, newState); - testMasters(); - emit dataChanged(this->index(0, 0), - this->index(static_cast(m_ESPs.size()), columnCount())); - } catch (const std::exception& e) { - log::error("failed to invoke state changed notification: {}", e.what()); - } catch (...) { - log::error("failed to invoke state changed notification: unknown exception"); - } - } - - return result; -} - -QVariant PluginList::headerData(int section, Qt::Orientation orientation, - int role) const -{ - if (orientation == Qt::Horizontal) { - if (role == Qt::DisplayRole) { - return getColumnName(section); - } else if (role == Qt::ToolTipRole) { - return getColumnToolTip(section); - } - } - return QAbstractItemModel::headerData(section, orientation, role); -} - -Qt::ItemFlags PluginList::flags(const QModelIndex& modelIndex) const -{ - int index = modelIndex.row(); - Qt::ItemFlags result = QAbstractItemModel::flags(modelIndex); - - if (modelIndex.isValid()) { - if (!m_ESPs[index].forceLoaded && !m_ESPs[index].forceDisabled) - result |= Qt::ItemIsDragEnabled; - if (!m_ESPs[index].forceLoaded && !m_ESPs[index].forceEnabled && - !m_ESPs[index].forceDisabled) - result |= Qt::ItemIsUserCheckable; - if (modelIndex.column() == COL_PRIORITY) - result |= Qt::ItemIsEditable; - result &= ~Qt::ItemIsDropEnabled; - } else { - result |= Qt::ItemIsDropEnabled; - } - - return result; -} - -void PluginList::setPluginPriority(int row, int& newPriority, bool isForced) -{ - int newPriorityTemp = newPriority; - - // enforce valid range - if (newPriorityTemp < 0) - newPriorityTemp = 0; - else if (newPriorityTemp >= static_cast(m_ESPsByPriority.size())) - newPriorityTemp = static_cast(m_ESPsByPriority.size()) - 1; - - int blueprintStartPos = 0; - for (auto esp : m_ESPs) { - if (!esp.isBlueprintFlagged) { - blueprintStartPos++; - } - } - - bool isBlueprint = m_ESPs[row].isBlueprintFlagged; - int lowerLimit = 0; - int upperLimit = 0; - if (isBlueprint) { - if (newPriorityTemp < blueprintStartPos) { - newPriorityTemp = blueprintStartPos; - } - lowerLimit = blueprintStartPos; - upperLimit = static_cast(m_ESPsByPriority.size() - 1); - } - if (!isBlueprint) { - if (newPriorityTemp >= blueprintStartPos) { - newPriorityTemp = blueprintStartPos - 1; - } - upperLimit = blueprintStartPos - 1; - } - if (!m_ESPs[row].isMasterFlagged && !m_ESPs[row].hasLightExtension && - !m_ESPs[row].hasMasterExtension) { - // don't allow esps to be moved above esms - while ((newPriorityTemp < upperLimit) && - (m_ESPs.at(m_ESPsByPriority.at(newPriorityTemp)).isMasterFlagged || - m_ESPs.at(m_ESPsByPriority.at(newPriorityTemp)).hasLightExtension || - m_ESPs.at(m_ESPsByPriority.at(newPriorityTemp)).hasMasterExtension)) { - ++newPriorityTemp; - } - } else { - // don't allow esms to be moved below esps - while ((newPriorityTemp > lowerLimit) && - !m_ESPs.at(m_ESPsByPriority.at(newPriorityTemp)).isMasterFlagged && - !m_ESPs.at(m_ESPsByPriority.at(newPriorityTemp)).hasLightExtension && - !m_ESPs.at(m_ESPsByPriority.at(newPriorityTemp)).hasMasterExtension) { - --newPriorityTemp; - } - // also don't allow "regular" esms to be moved above primary plugins - while ((newPriorityTemp < upperLimit) && - (m_ESPs.at(m_ESPsByPriority.at(newPriorityTemp)).forceLoaded)) { - ++newPriorityTemp; - } - } - - int oldPriority = m_ESPs.at(row).priority; - if (newPriorityTemp < oldPriority) { // moving up - // don't allow plugins to be moved above their masters - for (auto master : m_ESPs[row].masters) { - auto iter = m_ESPsByName.find(master); - if (iter != m_ESPsByName.end()) { - if (m_ESPs[iter->second].isBlueprintFlagged == - m_ESPs.at(row).isBlueprintFlagged) { - int masterPriority = m_ESPs[iter->second].priority; - if (masterPriority >= newPriorityTemp) { - newPriorityTemp = masterPriority + 1; - } - } - } - } - } else if (newPriorityTemp > oldPriority) { // moving down - // don't allow masters to be moved below their children - for (int i = oldPriority + 1; i <= newPriorityTemp; i++) { - PluginList::ESPInfo* otherInfo = &m_ESPs.at(m_ESPsByPriority[i]); - for (auto master : otherInfo->masters) { - auto iter = m_ESPsByName.find(master); - if (iter != m_ESPsByName.end()) { - if (m_ESPs[iter->second].isBlueprintFlagged == - otherInfo->isBlueprintFlagged) { - if (master.compare(m_ESPs[row].name, Qt::CaseInsensitive) == 0) { - newPriorityTemp = otherInfo->priority - 1; - break; - } - } - } - } - } - } - - try { - if (newPriorityTemp != oldPriority) { - if (newPriorityTemp > oldPriority) { - // priority is higher than the old, so the gap we left is in lower priorities - for (int i = oldPriority + 1; i <= newPriorityTemp; ++i) { - --m_ESPs.at(m_ESPsByPriority.at(i)).priority; - } - emit dataChanged(index(oldPriority + 1, 0), - index(newPriorityTemp, columnCount())); - } else { - for (int i = newPriorityTemp; i < oldPriority; ++i) { - ++m_ESPs.at(m_ESPsByPriority.at(i)).priority; - } - emit dataChanged(index(newPriorityTemp, 0), - index(oldPriority - 1, columnCount())); - ++newPriority; - } - - m_ESPs.at(row).priority = newPriorityTemp; - emit dataChanged(index(row, 0), index(row, columnCount())); - m_PluginMoved(m_ESPs[row].name, oldPriority, newPriorityTemp); - } - } catch (const std::out_of_range&) { - reportError(tr("failed to restore load order for %1").arg(m_ESPs[row].name)); - } - - updateIndices(); -} - -void PluginList::changePluginPriority(std::vector rows, int newPriority) -{ - ChangeBracket layoutChange(this); - const std::vector& esp = m_ESPs; - - // sort the moving plugins by ascending priorities - std::sort(rows.begin(), rows.end(), [&esp](const int& LHS, const int& RHS) { - return esp[LHS].priority < esp[RHS].priority; - }); - - // if at least on plugin is increasing in priority, the target index is - // that of the row BELOW the dropped location, otherwise it's the one above - for (std::vector::const_iterator iter = rows.begin(); iter != rows.end(); - ++iter) { - if (m_ESPs[*iter].priority < newPriority) { - --newPriority; - break; - } - } - - for (std::vector::const_iterator iter = rows.begin(); iter != rows.end(); - ++iter) { - setPluginPriority(*iter, newPriority); - } - - layoutChange.finish(); - refreshLoadOrder(); - emit writePluginsList(); -} - -bool PluginList::dropMimeData(const QMimeData* mimeData, Qt::DropAction action, int row, - int, const QModelIndex& parent) -{ - if (action == Qt::IgnoreAction) { - return true; - } - - QByteArray encoded = mimeData->data("application/x-qabstractitemmodeldatalist"); - QDataStream stream(&encoded, QIODevice::ReadOnly); - - std::vector sourceRows; - - while (!stream.atEnd()) { - int sourceRow, col; - QMap roleDataMap; - stream >> sourceRow >> col >> roleDataMap; - if (col == 0) { // only add each row once - sourceRows.push_back(sourceRow); - } - } - - if (row == -1) { - row = parent.row(); - } - - int newPriority; - - if ((row < 0) || (row >= static_cast(m_ESPs.size()))) { - newPriority = static_cast(m_ESPs.size()); - } else { - newPriority = m_ESPs[row].priority; - } - changePluginPriority(sourceRows, newPriority); - - return false; -} - -QModelIndex PluginList::index(int row, int column, const QModelIndex&) const -{ - if ((row < 0) || (row >= rowCount()) || (column < 0) || (column >= columnCount())) { - return {}; - } - return createIndex(row, column, row); -} - -QModelIndex PluginList::parent(const QModelIndex&) const -{ - return {}; -} - -PluginList::ESPInfo::ESPInfo(const QString& name, bool forceLoaded, bool forceEnabled, - bool forceDisabled, const QString& originName, - const QString& fullPath, bool hasIni, - std::set archives, bool lightSupported, - bool mediumSupported, bool blueprintSupported) - : name(name), fullPath(fullPath), enabled(forceLoaded), forceLoaded(forceLoaded), - forceEnabled(forceEnabled), forceDisabled(forceDisabled), originName(originName), hasIni(hasIni), - archives(archives.begin(), archives.end()) - -{ - QString parsePath = fullPath; - // Linux is case-sensitive while Windows-authored paths sometimes mismatch - // the actual filename casing. Resolve to the on-disk casing so the plugin - // parser doesn't fail when only the case differs. - if (!QFileInfo::exists(parsePath)) { - const QFileInfo fi(parsePath); - const QDir dir(fi.path()); - if (dir.exists()) { - const auto candidates = dir.entryList(QDir::Files | QDir::Readable); - const auto targetName = fi.fileName(); - const auto it = std::find_if(candidates.begin(), candidates.end(), - [&](const QString& candidate) { - return candidate.compare(targetName, - Qt::CaseInsensitive) == 0; - }); - if (it != candidates.end()) { - parsePath = dir.filePath(*it); - this->fullPath = parsePath; - log::warn("plugin path case mismatch, resolved '{}' -> '{}'", fullPath, - parsePath); - } - } - } - - try { - // Linux filesystem is UTF-8. ToWString → wstring → naive wchar_t->char - // copy in esptk truncates non-ASCII (ö, –), producing "file not found" - // on paths like "Mörskom Estate" or "Official Master Files – Cleaned". - // Pass UTF-8 bytes directly. - ESP::File file(parsePath.toStdString()); - auto extension = name.right(3).toLower(); - hasMasterExtension = (extension == "esm"); - hasLightExtension = (extension == "esl"); - isMasterFlagged = file.isMaster(); - isLightFlagged = lightSupported && file.isLight(mediumSupported); - isMediumFlagged = mediumSupported && file.isMedium(); - isBlueprintFlagged = blueprintSupported && - (isMasterFlagged || hasMasterExtension || hasLightExtension) && - file.isBlueprint(); - hasNoRecords = file.isDummy(); - - formVersion = file.formVersion(); - headerVersion = file.headerVersion(); - author = QString::fromLatin1(file.author().c_str()); - description = QString::fromLatin1(file.description().c_str()); - - for (auto&& m : file.masters()) { - masters.insert(QString::fromStdString(m)); - } - } catch (const std::exception& e) { - log::error("failed to parse plugin file {}: {}", fullPath, e.what()); - hasMasterExtension = false; - hasLightExtension = false; - isMasterFlagged = false; - isLightFlagged = false; - isMediumFlagged = false; - isBlueprintFlagged = false; - hasNoRecords = false; - } -} - -void PluginList::managedGameChanged(const IPluginGame* gamePlugin) -{ - m_GamePlugin = gamePlugin; -} +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ +#include "pluginlist.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "gameplugins.h" +#include "modinfo.h" +#include "modlist.h" +#include "organizercore.h" +#include "settings.h" +#include "shared/directoryentry.h" +#include "shared/fileentry.h" +#include "shared/filesorigin.h" +#include "shared/windows_error.h" +#include "viewmarkingscrollbar.h" + +#include +#include + +using namespace MOBase; +using namespace MOShared; + +static QString TruncateString(const QString& text) +{ + QString new_text = text; + + if (new_text.length() > 1024) { + new_text.truncate(1024); + new_text += "..."; + } + + return new_text; +} + +PluginList::PluginList(OrganizerCore& organizer) + : QAbstractItemModel(&organizer), m_Organizer(organizer), m_FontMetrics(QFont()) +{ + connect(this, SIGNAL(writePluginsList()), this, SLOT(generatePluginIndexes())); + m_LastCheck.start(); +} + +PluginList::~PluginList() +{ + m_Refreshed.disconnect_all_slots(); + m_PluginMoved.disconnect_all_slots(); + m_PluginStateChanged.disconnect_all_slots(); +} + +QString PluginList::getColumnName(int column) +{ + switch (column) { + case COL_NAME: + return tr("Name"); + case COL_PRIORITY: + return tr("Priority"); + case COL_MODINDEX: + return tr("Mod Index"); + case COL_FLAGS: + return tr("Flags"); + case COL_FORMVERSION: + return tr("Form Version"); + case COL_HEADERVERSION: + return tr("Header Version"); + case COL_AUTHOR: + return tr("Author"); + case COL_DESCRIPTION: + return tr("Description"); + default: + return tr("unknown"); + } +} + +QString PluginList::getColumnToolTip(int column) +{ + switch (column) { + case COL_NAME: + return tr("Name of the plugin"); + case COL_FLAGS: + return tr("Emblems to highlight things that might require attention."); + case COL_PRIORITY: + return tr( + "Load priority of plugins. The higher, the more \"important\" it is and thus " + "overwrites data from plugins with lower priority."); + case COL_MODINDEX: + return tr("Determines the formids of objects originating from this mods."); + case COL_FORMVERSION: + return tr("Form version of the plugin."); + case COL_HEADERVERSION: + return tr("Header version of the plugin."); + case COL_AUTHOR: + return tr("Author of the plugin."); + case COL_DESCRIPTION: + return tr("Description of the plugin."); + default: + return tr("unknown"); + } +} + +void PluginList::highlightPlugins(const std::vector& modIndices, + const MOShared::DirectoryEntry& directoryEntry) +{ + auto profile = m_Organizer.currentProfile(); + + for (auto& esp : m_ESPs) { + esp.modSelected = false; + } + + for (const auto& modIndex : modIndices) { + ModInfo::Ptr selectedMod = ModInfo::getByIndex(modIndex); + if (!selectedMod.isNull() && profile->modEnabled(modIndex)) { + QString modDataPath = selectedMod->absolutePath(); + modDataPath = + m_Organizer.managedGame()->modDataDirectory().isEmpty() + ? modDataPath + : modDataPath + "/" + m_Organizer.managedGame()->modDataDirectory(); + QDir dir(modDataPath); + QStringList plugins; + if (dir.exists()) { + plugins = dir.entryList(QStringList() << "*.esp" + << "*.esm" + << "*.esl"); + } + const MOShared::FilesOrigin& origin = + directoryEntry.getOriginByName(selectedMod->internalName().toStdWString()); + if (!plugins.empty()) { + for (auto plugin : plugins) { + MOShared::FileEntryPtr file = directoryEntry.findFile(plugin.toStdWString()); + if (file && file->getOrigin() != origin.getID()) { + const auto alternatives = file->getAlternatives(); + if (std::find_if(alternatives.begin(), alternatives.end(), + [&](const FileAlternative& element) { + return element.originID() == origin.getID(); + }) == alternatives.end()) + continue; + } + std::map::iterator iter = m_ESPsByName.find(plugin); + if (iter != m_ESPsByName.end()) { + m_ESPs[iter->second].modSelected = true; + } + } + } + } + } + + emit dataChanged(this->index(0, 0), this->index(static_cast(m_ESPs.size()) - 1, + this->columnCount() - 1)); +} + +void PluginList::highlightMasters(const QModelIndexList& selectedPluginIndices) +{ + for (auto& esp : m_ESPs) { + esp.isMasterOfSelectedPlugin = false; + } + + for (const auto& pluginIndex : selectedPluginIndices) { + const ESPInfo& plugin = m_ESPs[pluginIndex.row()]; + for (const auto& master : plugin.masters) { + const auto iter = m_ESPsByName.find(master); + if (iter != m_ESPsByName.end()) { + m_ESPs[iter->second].isMasterOfSelectedPlugin = true; + } + } + } +} + +void PluginList::refresh(const QString& profileName, + const DirectoryEntry& baseDirectory, + const QString& lockedOrderFile, bool force) +{ + TimeThis tt("PluginList::refresh()"); + + if (force) { + m_ESPs.clear(); + m_ESPsByName.clear(); + m_ESPsByPriority.clear(); + } + + ChangeBracket layoutChange(this); + + QStringList primaryPlugins = m_GamePlugin->primaryPlugins(); + QStringList enabledPlugins = m_GamePlugin->enabledPlugins(); + auto gamePlugins = m_Organizer.gameFeatures().gameFeature(); + const bool lightPluginsAreSupported = + gamePlugins ? gamePlugins->lightPluginsAreSupported() : false; + const bool mediumPluginsAreSupported = + gamePlugins ? gamePlugins->mediumPluginsAreSupported() : false; + const bool blueprintPluginsAreSupported = + gamePlugins ? gamePlugins->blueprintPluginsAreSupported() : false; + const bool loadOrderMechanismNone = + m_GamePlugin->loadOrderMechanism() == IPluginGame::LoadOrderMechanism::None; + + m_CurrentProfile = profileName; + + std::unordered_map availablePlugins; + QStringList archiveCandidates; + + for (FileEntryPtr current : baseDirectory.getFiles()) { + if (current.get() == nullptr) { + continue; + } + const QString& filename = ToQString(current->getName()); + + if (filename.endsWith(".esp", Qt::CaseInsensitive) || + filename.endsWith(".esm", Qt::CaseInsensitive) || + filename.endsWith(".esl", Qt::CaseInsensitive)) { + availablePlugins.insert(std::make_pair(filename, current)); + } else if (filename.endsWith(".bsa", Qt::CaseInsensitive) || + filename.endsWith("ba2", Qt::CaseInsensitive)) { + archiveCandidates.append(filename); + } + } + + for (const auto& [filename, current] : availablePlugins) { + if (m_ESPsByName.contains(filename)) { + continue; + } + + bool forceLoaded = Settings::instance().game().forceEnableCoreFiles() && + primaryPlugins.contains(filename, Qt::CaseInsensitive); + bool forceEnabled = enabledPlugins.contains(filename, Qt::CaseInsensitive); + bool forceDisabled = loadOrderMechanismNone && !forceLoaded && !forceEnabled; + if (!lightPluginsAreSupported && filename.endsWith(".esl")) { + forceDisabled = true; + } + + bool archive = false; + try { + FilesOrigin& origin = baseDirectory.getOriginByID(current->getOrigin(archive)); + + // name without extension + QString baseName = QFileInfo(filename).completeBaseName(); + + QString iniPath = baseName + ".ini"; + bool hasIni = baseDirectory.findFile(ToWString(iniPath)).get() != nullptr; + std::set loadedArchives; + for (const auto& archiveName : archiveCandidates) { + if (archiveName.startsWith(baseName, Qt::CaseInsensitive)) { + loadedArchives.insert(archiveName); + } + } + + QString originName = ToQString(origin.getName()); + unsigned int modIndex = ModInfo::getIndex(originName); + if (modIndex != UINT_MAX) { + ModInfo::Ptr modInfo = ModInfo::getByIndex(modIndex); + originName = modInfo->name(); + } + + m_ESPs.emplace_back(filename, forceLoaded, forceEnabled, forceDisabled, + originName, ToQString(current->getFullPath()), hasIni, + loadedArchives, lightPluginsAreSupported, + mediumPluginsAreSupported, blueprintPluginsAreSupported); + m_ESPs.rbegin()->priority = -1; + } catch (const std::exception& e) { + reportError(tr("failed to update esp info for file %1 (source id: %2), error: %3") + .arg(filename) + .arg(current->getOrigin(archive)) + .arg(e.what())); + } + } + + for (const auto& espName : m_ESPsByName) { + if (!availablePlugins.contains(espName.first)) { + m_ESPs[espName.second].name = ""; + } + } + + m_ESPs.erase(std::remove_if(m_ESPs.begin(), m_ESPs.end(), + [](const ESPInfo& info) -> bool { + return info.name.isEmpty(); + }), + m_ESPs.end()); + + fixPriorities(); + + // functions in GamePlugins will use the IPluginList interface of this, so + // indices need to work. priority will be off however + updateIndices(); + + if (gamePlugins) { + gamePlugins->readPluginLists(m_Organizer.managedGameOrganizer()->pluginList()); + } + + fixPrimaryPlugins(); + fixPluginRelationships(); + + testMasters(); + + updateIndices(); + + readLockedOrderFrom(lockedOrderFile); + + layoutChange.finish(); + + refreshLoadOrder(); + emit dataChanged(this->index(0, 0), + this->index(static_cast(m_ESPs.size()), columnCount())); + + m_Refreshed(); +} + +void PluginList::fixPrimaryPlugins() +{ + if (!m_Organizer.settings().game().forceEnableCoreFiles()) { + return; + } + + // This function ensures that the primary plugins are first and in the correct order + QStringList primaryPlugins = m_Organizer.managedGame()->primaryPlugins(); + int prio = 0; + int prioBlueprint = 0; + bool somethingChanged = false; + for (auto esp : m_ESPs) { + if (!esp.isBlueprintFlagged) + prioBlueprint++; + } + for (QString plugin : primaryPlugins) { + std::map::iterator iter = m_ESPsByName.find(plugin); + // Plugin is present? + if (iter != m_ESPsByName.end()) { + if (!m_ESPs[iter->second].isBlueprintFlagged) { + if (prio != m_ESPs[iter->second].priority) { + // Priority is wrong! Fix it! + int newPrio = prio; + setPluginPriority(iter->second, newPrio, true /* isForced */); + somethingChanged = true; + } + prio++; + } else { + if (prioBlueprint != m_ESPs[iter->second].priority) { + // Priority is wrong! Fix it! + int newPrio = prioBlueprint; + setPluginPriority(iter->second, newPrio, true /* isForced */); + somethingChanged = true; + } + prioBlueprint++; + } + } + } + + if (somethingChanged) { + writePluginsList(); + } +} + +void PluginList::fixPluginRelationships() +{ + TimeThis timer("PluginList::fixPluginRelationships"); + + // Count the types of plugins + int standardCount = 0; + int masterCount = 0; + int blueprintMasterCount = 0; + for (auto plugin : m_ESPs) { + if (plugin.hasLightExtension || plugin.hasMasterExtension || + plugin.isMasterFlagged) { + if (plugin.isBlueprintFlagged) { + blueprintMasterCount++; + } else { + masterCount++; + } + } + if (!plugin.isBlueprintFlagged) { + standardCount++; + } + } + + // Ensure masters are up top and normal plugins are down below + for (int i = 0; i < m_ESPs.size(); i++) { + ESPInfo& plugin = m_ESPs[i]; + if (plugin.hasLightExtension || plugin.hasMasterExtension || + plugin.isMasterFlagged) { + if (plugin.isBlueprintFlagged) { + if (plugin.priority > standardCount + blueprintMasterCount) { + int newPriority = standardCount + blueprintMasterCount; + setPluginPriority(i, newPriority); + } + } else { + if (plugin.priority > masterCount) { + int newPriority = masterCount; + setPluginPriority(i, newPriority); + } + } + } else { + if (plugin.isBlueprintFlagged) { + if (plugin.priority < standardCount + blueprintMasterCount) { + int newPriority = standardCount + blueprintMasterCount + 1; + setPluginPriority(i, newPriority); + } + } else { + if (plugin.priority < masterCount) { + int newPriority = masterCount + 1; + setPluginPriority(i, newPriority); + } + } + } + } + + // Ensure master/child relationships are observed + for (int i = 0; i < m_ESPs.size(); i++) { + ESPInfo& plugin = m_ESPs[i]; + int newPriority = plugin.priority; + for (auto master : plugin.masters) { + auto iter = m_ESPsByName.find(master); + if (iter != m_ESPsByName.end()) { + if (m_ESPs.at(iter->second).isBlueprintFlagged == plugin.isBlueprintFlagged) { + newPriority = std::max(newPriority, m_ESPs[iter->second].priority); + } + } + } + if (newPriority != plugin.priority) { + setPluginPriority(i, newPriority); + } + } +} + +void PluginList::fixPriorities() +{ + std::vector> espPrios; + + for (int i = 0; i < m_ESPs.size(); ++i) { + int prio = m_ESPs[i].priority; + if (prio == -1) { + prio = INT_MAX; + } + espPrios.emplace_back(prio, i); + } + + std::sort(espPrios.begin(), espPrios.end(), + [](const std::pair& lhs, const std::pair& rhs) { + return lhs.first < rhs.first; + }); + + for (int i = 0; i < espPrios.size(); ++i) { + m_ESPs[espPrios[i].second].priority = i; + } +} + +void PluginList::enableESP(const QString& name, bool enable) +{ + std::map::iterator iter = m_ESPsByName.find(name); + + if (iter != m_ESPsByName.end()) { + auto enabled = m_ESPs[iter->second].enabled; + m_ESPs[iter->second].enabled = (enable && !m_ESPs[iter->second].forceDisabled) || + m_ESPs[iter->second].forceLoaded || + m_ESPs[iter->second].forceEnabled; + + emit writePluginsList(); + if (enabled != m_ESPs[iter->second].enabled) { + pluginStatesChanged({name}, state(name)); + } + } else { + reportError(tr("Plugin not found: %1").arg(qUtf8Printable(name))); + } +} + +int PluginList::findPluginByPriority(int priority) +{ + for (int i = 0; i < m_ESPs.size(); i++) { + if (m_ESPs[i].priority == priority) { + return i; + } + } + log::error("No plugin with priority {}", priority); + return -1; +} + +void PluginList::setEnabled(const QModelIndexList& indices, bool enabled) +{ + QStringList dirty; + for (const auto& idx : indices) { + if (m_ESPs[idx.row()].forceLoaded || m_ESPs[idx.row()].forceEnabled || + m_ESPs[idx.row()].forceDisabled) + continue; + if (m_ESPs[idx.row()].enabled != enabled) { + m_ESPs[idx.row()].enabled = enabled; + dirty.append(m_ESPs[idx.row()].name); + } + } + if (!dirty.isEmpty()) { + emit writePluginsList(); + pluginStatesChanged(dirty, enabled ? IPluginList::PluginState::STATE_ACTIVE + : IPluginList::PluginState::STATE_INACTIVE); + } +} + +void PluginList::setEnabledAll(bool enabled) +{ + QStringList dirty; + for (ESPInfo& info : m_ESPs) { + if (info.forceLoaded || info.forceEnabled || info.forceDisabled) + continue; + if (info.enabled != enabled) { + info.enabled = enabled; + dirty.append(info.name); + } + } + if (!dirty.isEmpty()) { + emit writePluginsList(); + pluginStatesChanged(dirty, enabled ? IPluginList::PluginState::STATE_ACTIVE + : IPluginList::PluginState::STATE_INACTIVE); + } +} + +void PluginList::sendToPriority(const QModelIndexList& indices, int newPriority) +{ + std::vector pluginsToMove; + for (const auto& idx : indices) { + if (!m_ESPs[idx.row()].forceLoaded) { + pluginsToMove.push_back(idx.row()); + } + } + if (!pluginsToMove.empty()) { + changePluginPriority(pluginsToMove, newPriority); + } +} + +void PluginList::shiftPluginsPriority(const QModelIndexList& indices, int offset) +{ + // retrieve the plugin index and sort them by priority to avoid issue + // when moving them + std::vector allIndex; + for (const auto& idx : indices) { + allIndex.push_back(idx.row()); + } + std::sort(allIndex.begin(), allIndex.end(), [=, this](int lhs, int rhs) { + bool cmp = m_ESPs[lhs].priority < m_ESPs[rhs].priority; + return offset > 0 ? !cmp : cmp; + }); + + for (auto index : allIndex) { + int newPriority = m_ESPs[index].priority + offset; + if (newPriority >= 0 && newPriority < rowCount()) { + setPluginPriority(index, newPriority); + } + } + + refreshLoadOrder(); +} + +void PluginList::toggleState(const QModelIndexList& indices) +{ + QModelIndex minRow; + QModelIndex maxRow; + for (const auto& idx : indices) { + if (!minRow.isValid() || (idx.row() < minRow.row())) { + minRow = idx; + } + if (!maxRow.isValid() || (idx.row() > maxRow.row())) { + maxRow = idx; + } + int oldState = idx.data(Qt::CheckStateRole).toInt(); + setData(idx, oldState == Qt::Unchecked ? Qt::Checked : Qt::Unchecked, + Qt::CheckStateRole); + } + + emit dataChanged(minRow, maxRow); +} + +bool PluginList::isEnabled(const QString& name) +{ + std::map::iterator iter = m_ESPsByName.find(name); + + if (iter != m_ESPsByName.end()) { + return m_ESPs[iter->second].enabled; + } else { + return false; + } +} + +void PluginList::clearInformation(const QString& name) +{ + std::map::iterator iter = m_ESPsByName.find(name); + + if (iter != m_ESPsByName.end()) { + m_AdditionalInfo[name].messages.clear(); + } +} + +void PluginList::clearAdditionalInformation() +{ + m_AdditionalInfo.clear(); +} + +void PluginList::addInformation(const QString& name, const QString& message) +{ + std::map::iterator iter = m_ESPsByName.find(name); + + if (iter != m_ESPsByName.end()) { + m_AdditionalInfo[name].messages.append(message); + } else { + log::warn("failed to associate message for \"{}\"", name); + } +} + +bool PluginList::isEnabled(int index) +{ + return m_ESPs.at(index).enabled; +} + +void PluginList::readLockedOrderFrom(const QString& fileName) +{ + m_LockedOrder.clear(); + + QFile file(fileName); + if (!file.exists()) { + // no locked load order, that's ok + return; + } + + if (!file.open(QIODevice::ReadOnly)) { + log::error("failed to open locked order file '{}': {}", fileName, file.errorString()); + return; + } + int lineNumber = 0; + while (!file.atEnd()) { + QByteArray line = file.readLine(); + ++lineNumber; + + // Skip empty lines or commented out lines (#) + if ((line.size() <= 0) || (line.at(0) == '#')) { + continue; + } + + QList fields = line.split('|'); + if (fields.count() != 2) { + // Don't know how to parse this so run away + log::error("locked order file: invalid line #{}: {}", lineNumber, + QString::fromUtf8(line).trimmed()); + continue; + } + + // Read the plugin name and priority + QString pluginName = QString::fromUtf8(fields.at(0)); + int priority = fields.at(1).trimmed().toInt(); + if (priority < 0) { + // WTF do you mean a negative priority? + log::error("locked order file: invalid line #{}: {}", lineNumber, + QString::fromUtf8(line).trimmed()); + continue; + } + + // Determine the index of the plugin + auto it = m_ESPsByName.find(pluginName); + if (it == m_ESPsByName.end()) { + // Plugin does not exist in the current set of plugins + m_LockedOrder[pluginName] = priority; + continue; + } + int pluginIndex = it->second; + + // Do not allow locking forced plugins + if (m_ESPs[pluginIndex].forceLoaded) { + continue; + } + + // If the priority is larger than the number of plugins, just keep it locked + if (priority >= m_ESPsByPriority.size()) { + m_LockedOrder[pluginName] = priority; + continue; + } + + // These are some helper functions for figuring out what is already locked + auto findLocked = [&](const std::pair& a) { + return a.second == priority; + }; + auto alreadyLocked = [&]() { + return std::find_if(m_LockedOrder.begin(), m_LockedOrder.end(), findLocked) != + m_LockedOrder.end(); + }; + + // See if we can just set the given priority + if (!m_ESPs[m_ESPsByPriority.at(priority)].forceLoaded && !alreadyLocked()) { + m_LockedOrder[pluginName] = priority; + continue; + } + + // Find the next higher priority we can set the plugin to + while (++priority < m_ESPs.size()) { + if (!m_ESPs[m_ESPsByPriority.at(priority)].forceLoaded && !alreadyLocked()) { + m_LockedOrder[pluginName] = priority; + break; + } + } + + // See if we walked off the end of the plugin list + if (priority >= m_ESPs.size()) { + // I guess go ahead and lock it here at the end of the list? + m_LockedOrder[pluginName] = priority; + continue; + } + } /* while (!file.atEnd()) */ + file.close(); +} + +void PluginList::writeLockedOrder(const QString& fileName) const +{ + SafeWriteFile file(fileName); + + file->resize(0); + file->write(QString("# This file was automatically generated by Mod Organizer.\r\n") + .toUtf8()); + for (auto iter = m_LockedOrder.begin(); iter != m_LockedOrder.end(); ++iter) { + file->write(QString("%1|%2\r\n").arg(iter->first).arg(iter->second).toUtf8()); + } + file->commit(); +} + +void PluginList::saveTo(const QString& lockedOrderFileName) const +{ + auto gamePlugins = m_Organizer.gameFeatures().gameFeature(); + if (gamePlugins) { + gamePlugins->writePluginLists(m_Organizer.managedGameOrganizer()->pluginList()); + } + + writeLockedOrder(lockedOrderFileName); +} + +bool PluginList::saveLoadOrder(DirectoryEntry& directoryStructure) +{ + if (m_GamePlugin->loadOrderMechanism() != IPluginGame::LoadOrderMechanism::FileTime) { + // nothing to do + return true; + } + + log::debug("setting file times on esps"); + + for (ESPInfo& esp : m_ESPs) { + std::wstring espName = ToWString(esp.name); + const FileEntryPtr fileEntry = directoryStructure.findFile(espName); + if (fileEntry.get() != nullptr) { + QString fileName; + bool archive = false; + int originid = fileEntry->getOrigin(archive); + + fileName = QDir(ToQString( + directoryStructure.getOriginByID(originid).getPath())) + .filePath(esp.name); + + // Use utimensat to set the file modification time. The values track the + // Windows-epoch FILETIME so they round-trip with the in-memory state. + ULONGLONG temp = (145731ULL + esp.priority) * 24 * 60 * 60 * 10000000ULL; + + FILETIME newWriteTime; + newWriteTime.dwLowDateTime = (DWORD)(temp & 0xFFFFFFFF); + newWriteTime.dwHighDateTime = (DWORD)(temp >> 32); + esp.time = newWriteTime; + fileEntry->setFileTime(newWriteTime); + + uint64_t ticks = + (static_cast(newWriteTime.dwHighDateTime) << 32) | + newWriteTime.dwLowDateTime; + // Convert from Windows epoch (1601) to Unix epoch (1970). + ticks -= 116444736000000000ULL; + time_t secs = static_cast(ticks / 10000000ULL); + struct timespec times[2]; + times[0].tv_sec = 0; + times[0].tv_nsec = UTIME_OMIT; + times[1].tv_sec = secs; + times[1].tv_nsec = 0; + std::string path = fileName.toStdString(); + if (utimensat(AT_FDCWD, path.c_str(), times, 0) != 0) { + log::warn("failed to set file time for {}", fileName); + } + } + } + return true; +} + +int PluginList::enabledCount() const +{ + int enabled = 0; + for (const auto& info : m_ESPs) { + if (info.enabled) { + ++enabled; + } + } + return enabled; +} + +QString PluginList::getIndexPriority(int index) const +{ + return m_ESPs[index].index; +} + +bool PluginList::isESPLocked(int index) const +{ + return m_LockedOrder.contains(m_ESPs.at(index).name); +} + +void PluginList::lockESPIndex(int index, bool lock) +{ + if (lock) { + if (!m_ESPs.at(index).forceLoaded) + m_LockedOrder[getName(index)] = m_ESPs.at(index).loadOrder; + else + return; + } else { + auto iter = m_LockedOrder.find(getName(index)); + if (iter != m_LockedOrder.end()) { + m_LockedOrder.erase(iter); + } + } + emit writePluginsList(); +} + +void PluginList::syncLoadOrder() +{ + int loadOrder = 0; + for (unsigned int i = 0; i < m_ESPs.size(); ++i) { + int index = m_ESPsByPriority[i]; + + if (m_ESPs[index].enabled) { + m_ESPs[index].loadOrder = loadOrder++; + } else { + m_ESPs[index].loadOrder = -1; + } + } +} + +void PluginList::refreshLoadOrder() +{ + ChangeBracket layoutChange(this); + syncLoadOrder(); + // set priorities according to locked load order + std::map lockedLoadOrder; + std::for_each(m_LockedOrder.begin(), m_LockedOrder.end(), + [&lockedLoadOrder](const std::pair& ele) { + lockedLoadOrder[ele.second] = ele.first; + }); + + int targetPrio = 0; + bool savePluginsList = false; + // this is guaranteed to iterate from lowest key (load order) to highest + for (auto iter = lockedLoadOrder.begin(); iter != lockedLoadOrder.end(); ++iter) { + auto nameIter = m_ESPsByName.find(iter->second); + if (nameIter != m_ESPsByName.end()) { + // locked esp exists + + // find the location to insert at + while ((std::cmp_less(targetPrio, m_ESPs.size() - 1)) && + (m_ESPs[m_ESPsByPriority[targetPrio]].loadOrder < iter->first)) { + ++targetPrio; + } + + if (static_cast(targetPrio) >= m_ESPs.size()) { + continue; + } + + int temp = targetPrio; + int index = nameIter->second; + if (m_ESPs[index].priority != temp) { + setPluginPriority(index, temp); + m_ESPs[index].loadOrder = iter->first; + syncLoadOrder(); + savePluginsList = true; + } + } + } + if (savePluginsList) { + emit writePluginsList(); + } +} + +void PluginList::disconnectSlots() +{ + m_PluginMoved.disconnect_all_slots(); + m_Refreshed.disconnect_all_slots(); + m_PluginStateChanged.disconnect_all_slots(); +} + +int PluginList::timeElapsedSinceLastChecked() const +{ + return m_LastCheck.elapsed(); +} + +QStringList PluginList::pluginNames() const +{ + QStringList result; + + for (const ESPInfo& info : m_ESPs) { + result.append(info.name); + } + + return result; +} + +IPluginList::PluginStates PluginList::state(const QString& name) const +{ + auto iter = m_ESPsByName.find(name); + if (iter == m_ESPsByName.end()) { + return IPluginList::STATE_MISSING; + } else { + return m_ESPs[iter->second].enabled ? IPluginList::STATE_ACTIVE + : IPluginList::STATE_INACTIVE; + } +} + +void PluginList::setState(const QString& name, PluginStates state) +{ + auto iter = m_ESPsByName.find(name); + if (iter != m_ESPsByName.end()) { + m_ESPs[iter->second].enabled = + (state == IPluginList::STATE_ACTIVE && !m_ESPs[iter->second].forceDisabled) || + m_ESPs[iter->second].forceLoaded || m_ESPs[iter->second].forceEnabled; + } else { + log::warn("Plugin not found: {}", name); + } +} + +void PluginList::setLoadOrder(const QStringList& pluginList) +{ + for (ESPInfo& info : m_ESPs) { + info.priority = -1; + } + int maxPriority = 0; + for (const QString& plugin : pluginList) { + auto iter = m_ESPsByName.find(plugin); + if (iter != m_ESPsByName.end()) { + m_ESPs[iter->second].priority = maxPriority++; + } + } + + // use old priorities + for (ESPInfo& info : m_ESPs) { + if (info.priority == -1) { + info.priority = maxPriority++; + } + } + updateIndices(); +} + +int PluginList::priority(const QString& name) const +{ + auto iter = m_ESPsByName.find(name); + if (iter == m_ESPsByName.end()) { + return -1; + } else { + return m_ESPs[iter->second].priority; + } +} + +bool PluginList::setPriority(const QString& name, int newPriority) +{ + + if (newPriority < 0 || std::cmp_greater_equal(newPriority, m_ESPsByPriority.size())) { + return false; + } + + auto oldPriority = priority(name); + if (oldPriority == -1) { + return false; + } + + int rowIndex = findPluginByPriority(oldPriority); + + // We need to increment newPriority if its above the old one, otherwise the + // plugin is place right below the new priority. + if (oldPriority < newPriority) { + newPriority += 1; + } + changePluginPriority({rowIndex}, newPriority); + + return true; +} + +int PluginList::loadOrder(const QString& name) const +{ + auto iter = m_ESPsByName.find(name); + if (iter == m_ESPsByName.end()) { + return -1; + } else { + return m_ESPs[iter->second].loadOrder; + } +} + +QStringList PluginList::masters(const QString& name) const +{ + auto iter = m_ESPsByName.find(name); + if (iter == m_ESPsByName.end()) { + return {}; + } else { + QStringList result; + for (const QString& master : m_ESPs[iter->second].masters) { + result.append(master); + } + return result; + } +} + +QString PluginList::origin(const QString& name) const +{ + auto iter = m_ESPsByName.find(name); + if (iter == m_ESPsByName.end()) { + return {}; + } else { + return m_ESPs[iter->second].originName; + } +} + +bool PluginList::hasMasterExtension(const QString& name) const +{ + auto iter = m_ESPsByName.find(name); + if (iter == m_ESPsByName.end()) { + return false; + } else { + return m_ESPs[iter->second].hasMasterExtension; + } +} + +bool PluginList::hasLightExtension(const QString& name) const +{ + auto iter = m_ESPsByName.find(name); + if (iter == m_ESPsByName.end()) { + return false; + } else { + return m_ESPs[iter->second].hasLightExtension; + } +} + +bool PluginList::isMasterFlagged(const QString& name) const +{ + auto iter = m_ESPsByName.find(name); + if (iter == m_ESPsByName.end()) { + return false; + } else { + return m_ESPs[iter->second].isMasterFlagged; + } +} + +bool PluginList::isMediumFlagged(const QString& name) const +{ + auto iter = m_ESPsByName.find(name); + if (iter == m_ESPsByName.end()) { + return false; + } else { + return m_ESPs[iter->second].isMediumFlagged; + } +} + +bool PluginList::isLightFlagged(const QString& name) const +{ + auto iter = m_ESPsByName.find(name); + if (iter == m_ESPsByName.end()) { + return false; + } else { + return m_ESPs[iter->second].isLightFlagged; + } +} + +bool PluginList::isBlueprintFlagged(const QString& name) const +{ + auto iter = m_ESPsByName.find(name); + if (iter == m_ESPsByName.end()) { + return false; + } else { + return m_ESPs[iter->second].isBlueprintFlagged; + } +} + +bool PluginList::hasNoRecords(const QString& name) const +{ + auto iter = m_ESPsByName.find(name); + if (iter == m_ESPsByName.end()) { + return false; + } else { + return m_ESPs[iter->second].hasNoRecords; + } +} + +int PluginList::formVersion(const QString& name) const +{ + auto iter = m_ESPsByName.find(name); + if (iter == m_ESPsByName.end()) { + return -1; + } else { + return m_ESPs[iter->second].formVersion; + } +} + +float PluginList::headerVersion(const QString& name) const +{ + auto iter = m_ESPsByName.find(name); + if (iter == m_ESPsByName.end()) { + return -1; + } else { + return m_ESPs[iter->second].headerVersion; + } +} + +QString PluginList::author(const QString& name) const +{ + auto iter = m_ESPsByName.find(name); + if (iter == m_ESPsByName.end()) { + return {}; + } else { + return m_ESPs[iter->second].author; + } +} + +QString PluginList::description(const QString& name) const +{ + auto iter = m_ESPsByName.find(name); + if (iter == m_ESPsByName.end()) { + return {}; + } else { + return m_ESPs[iter->second].description; + } +} + +boost::signals2::connection PluginList::onPluginStateChanged( + const std::function&)>& func) +{ + return m_PluginStateChanged.connect(func); +} + +void PluginList::pluginStatesChanged(QStringList const& pluginNames, + PluginStates state) const +{ + if (pluginNames.isEmpty()) { + return; + } + std::map infos; + for (const auto& name : pluginNames) { + infos[name] = state; + } + m_PluginStateChanged(infos); +} + +boost::signals2::connection +PluginList::onRefreshed(const std::function& callback) +{ + return m_Refreshed.connect(callback); +} + +boost::signals2::connection +PluginList::onPluginMoved(const std::function& func) +{ + return m_PluginMoved.connect(func); +} + +void PluginList::updateIndices() +{ + m_ESPsByName.clear(); + m_ESPsByPriority.clear(); + m_ESPsByPriority.resize(m_ESPs.size()); + for (unsigned int i = 0; i < m_ESPs.size(); ++i) { + if (m_ESPs[i].priority < 0) { + continue; + } + if (std::cmp_greater_equal(m_ESPs[i].priority, m_ESPs.size())) { + log::error("invalid plugin priority: {}", m_ESPs[i].priority); + continue; + } + m_ESPsByName[m_ESPs[i].name] = i; + m_ESPsByPriority.at(static_cast(m_ESPs[i].priority)) = i; + } + + generatePluginIndexes(); +} + +void PluginList::generatePluginIndexes() +{ + int numESLs = 0; + int numESHs = 0; + int numSkipped = 0; + + auto gamePlugins = m_Organizer.gameFeatures().gameFeature(); + const bool lightPluginsSupported = + gamePlugins ? gamePlugins->lightPluginsAreSupported() : false; + const bool mediumPluginsSupported = + gamePlugins ? gamePlugins->mediumPluginsAreSupported() : false; + + std::vector coreLightPlugins; + std::vector coreMediumPlugins; + + for (int l = 0; l < m_ESPs.size(); ++l) { + int i = m_ESPsByPriority.at(l); + if (!m_ESPs[i].enabled) { + m_ESPs[i].index = QString(); + ++numSkipped; + continue; + } + if (mediumPluginsSupported && m_ESPs[i].isMediumFlagged) { + int ESHpos = 253 + (numESHs / 256); + m_ESPs[i].index = QString("%1:%2") + .arg(ESHpos, 2, 16, QChar('0')) + .arg(numESHs % 256, 2, 16, QChar('0')) + .toUpper(); + ++numESHs; + + } else if (lightPluginsSupported && + (m_ESPs[i].hasLightExtension || m_ESPs[i].isLightFlagged)) { + int ESLpos = 254 + (numESLs / 4096); + m_ESPs[i].index = QString("%1:%2") + .arg(ESLpos, 2, 16, QChar('0')) + .arg(numESLs % 4096, 3, 16, QChar('0')) + .toUpper(); + ++numESLs; + } else { + m_ESPs[i].index = QString("%1") + .arg(l - numESHs - numESLs - numSkipped, 2, 16, QChar('0')) + .toUpper(); + } + } + emit esplist_changed(); +} + +int PluginList::rowCount(const QModelIndex& parent) const +{ + if (!parent.isValid()) { + return static_cast(m_ESPs.size()); + } else { + return 0; + } +} + +int PluginList::columnCount(const QModelIndex&) const +{ + return COL_LASTCOLUMN + 1; +} + +void PluginList::testMasters() +{ + std::set enabledMasters; + for (const auto& iter : m_ESPs) { + if (iter.enabled) { + enabledMasters.insert(iter.name); + } + } + + for (auto& iter : m_ESPs) { + iter.masterUnset.clear(); + if (iter.enabled) { + for (const auto& master : iter.masters) { + if (!enabledMasters.contains(master)) { + iter.masterUnset.insert(master); + } + } + } + } +} + +QVariant PluginList::data(const QModelIndex& modelIndex, int role) const +{ + if ((role == Qt::DisplayRole) || (role == Qt::EditRole)) { + return displayData(modelIndex); + } else if ((role == Qt::CheckStateRole) && (modelIndex.column() == 0)) { + return checkstateData(modelIndex); + } else if (role == Qt::ForegroundRole) { + return foregroundData(modelIndex); + } else if (role == Qt::BackgroundRole) { + return backgroundData(modelIndex); + } else if (role == Qt::FontRole) { + return fontData(modelIndex); + } else if (role == Qt::TextAlignmentRole) { + return alignmentData(modelIndex); + } else if (role == Qt::ToolTipRole) { + return tooltipData(modelIndex); + } else if (role == Qt::UserRole + 1) { + return iconData(modelIndex); + } + return {}; +} + +QVariant PluginList::displayData(const QModelIndex& modelIndex) const +{ + const int index = modelIndex.row(); + const auto& plugin = m_ESPs[index]; + + switch (modelIndex.column()) { + case COL_NAME: + return plugin.name; + + case COL_PRIORITY: + return QString::number(plugin.priority); + + case COL_MODINDEX: + return plugin.index; + + case COL_FORMVERSION: + return plugin.formVersion != 0 ? QString::number(plugin.formVersion) : QString(); + + case COL_HEADERVERSION: + return QString::number(plugin.headerVersion); + + case COL_AUTHOR: + return plugin.author; + + case COL_DESCRIPTION: + return plugin.description; + + default: + return {}; + } +} + +QVariant PluginList::checkstateData(const QModelIndex& modelIndex) const +{ + const int index = modelIndex.row(); + + if (m_ESPs[index].forceLoaded || m_ESPs[index].forceEnabled) { + return Qt::Checked; + } else if (m_ESPs[index].forceDisabled) { + return Qt::Unchecked; + } + + return m_ESPs[index].enabled ? Qt::Checked : Qt::Unchecked; +} + +QVariant PluginList::foregroundData(const QModelIndex& modelIndex) const +{ + const int index = modelIndex.row(); + + if ((modelIndex.column() == COL_NAME) && m_ESPs[index].forceLoaded) { + return QBrush(Qt::gray); + } + + if ((modelIndex.column() == COL_NAME) && m_ESPs[index].forceDisabled) { + return QBrush(Qt::darkRed); + } + + return {}; +} + +QVariant PluginList::backgroundData(const QModelIndex& modelIndex) const +{ + const int index = modelIndex.row(); + const ESPInfo& plugin = m_ESPs[index]; + + if (plugin.modSelected) { + return Settings::instance().colors().pluginListContained(); + } else if (plugin.isMasterOfSelectedPlugin) { + return Settings::instance().colors().pluginListMaster(); + } + + return {}; +} + +QVariant PluginList::fontData(const QModelIndex& modelIndex) const +{ + const int index = modelIndex.row(); + + QFont result; + + if (m_ESPs[index].hasMasterExtension || m_ESPs[index].isMasterFlagged || + m_ESPs[index].hasLightExtension) + result.setWeight(QFont::Bold); + if (m_ESPs[index].isLightFlagged || m_ESPs[index].hasLightExtension) + result.setItalic(true); + else if (m_ESPs[index].isMediumFlagged) + result.setUnderline(true); + if (m_ESPs[index].isBlueprintFlagged) + result.setLetterSpacing(QFont::SpacingType::AbsoluteSpacing, 2); + + return result; +} + +QVariant PluginList::alignmentData(const QModelIndex& modelIndex) +{ + if (modelIndex.column() == 0) { + return {Qt::AlignLeft | Qt::AlignVCenter}; + } else { + return {Qt::AlignHCenter | Qt::AlignVCenter}; + } +} + +QVariant PluginList::tooltipData(const QModelIndex& modelIndex) const +{ + const int index = modelIndex.row(); + const auto& esp = m_ESPs[index]; + + QString toolTip; + + toolTip += "" + tr("Origin") + ": " + esp.originName; + + if (esp.forceLoaded) { + toolTip += "
            " + + tr("This plugin can't be disabled or moved (enforced by the game).") + + ""; + } + + if (esp.forceEnabled) { + toolTip += "
            " + + tr("This plugin can't be disabled (enforced by the game).") + ""; + } + + if (esp.formVersion != 0) { + // Oblivion-style plugin headers don't have a form version + toolTip += + "
            " + tr("Form Version") + ": " + QString::number(esp.formVersion); + } + + toolTip += + "
            " + tr("Header Version") + ": " + QString::number(esp.headerVersion); + + if (!esp.author.isEmpty()) { + toolTip += "
            " + tr("Author") + ": " + TruncateString(esp.author); + } + + if (esp.description.size() > 0) { + toolTip += + "
            " + tr("Description") + ": " + TruncateString(esp.description); + } + + if (!esp.masterUnset.empty()) { + toolTip += + "
            " + tr("Missing Masters") + ": " + "" + + TruncateString( + QStringList(esp.masterUnset.begin(), esp.masterUnset.end()).join(", ")) + + ""; + } + + std::set enabledMasters; + std::set_difference(esp.masters.begin(), esp.masters.end(), esp.masterUnset.begin(), + esp.masterUnset.end(), + std::inserter(enabledMasters, enabledMasters.end())); + + if (!enabledMasters.empty()) { + toolTip += "
            " + tr("Enabled Masters") + + ": " + TruncateString(SetJoin(enabledMasters, ", ")); + } + + if (!esp.archives.empty()) { + QString archiveString = + esp.archives.size() < 6 + ? TruncateString( + QStringList(esp.archives.begin(), esp.archives.end()).join(", ")) + + "
            " + : ""; + toolTip += "
            " + tr("Loads Archives") + ": " + archiveString + + tr("There are Archives connected to this plugin. Their assets will be " + "added to your game, overwriting in case of conflicts following the " + "plugin order. Loose files will always overwrite assets from " + "Archives. (This flag only checks for Archives from the same mod as " + "the plugin)"); + } + + if (esp.hasIni) { + toolTip += "
            " + tr("Loads INI settings") + + ": " + "
            " + + tr("There is an ini file connected to this plugin. Its settings will " + "be added to your game settings, overwriting in case of conflicts."); + } + + if (esp.isLightFlagged && !esp.hasLightExtension) { + QString type = esp.hasMasterExtension ? "ESM" : "ESP"; + toolTip += + "

            " + + tr("This %1 is flagged as a light plugin (ESL). It will adhere to the %1 load " + "order but the records will be loaded in ESL space (FE/FF). You can have up " + "to 4096 light plugins in addition to other plugin types.") + .arg(type); + } else if (esp.isMediumFlagged && esp.hasMasterExtension) { + toolTip += "

            " + + tr("This ESM is flagged as a medium plugin (ESH). It adheres to the ESM " + "load order but loads records in ESH space (FD). You can have 256 " + "medium plugins in addition to other plugin types."); + } + + if (esp.isBlueprintFlagged) { + toolTip += "

            " + + tr("This plugin has the blueprint flag. This forces it to load after " + "every other non-blueprint plugin. Blueprint plugins will adhere to " + "standard load order rules with other blueprint plugins."); + } + + if (esp.isLightFlagged && esp.isMediumFlagged) { + toolTip += + "

            " + + tr("WARNING: This plugin is both light and medium flagged. " + "This could indicate that the file was saved improperly " + "and may have mismatched record references. Use it at your own risk."); + } + + if (esp.hasNoRecords) { + toolTip += "

            " + tr("This is a dummy plugin. It contains no records and is " + "typically used to load a paired archive file."); + } + + if (esp.forceDisabled) { + auto feature = m_Organizer.gameFeatures().gameFeature(); + if (feature && esp.hasLightExtension && feature->lightPluginsAreSupported()) { + toolTip += "

            " + tr("Light plugins (ESL) are not supported by this game."); + } else { + toolTip += "

            " + tr("This game does not currently permit custom plugin " + "loading. There may be manual workarounds."); + } + } + + // additional info + auto itor = m_AdditionalInfo.find(esp.name); + + if (itor != m_AdditionalInfo.end()) { + if (!itor->second.messages.isEmpty()) { + toolTip += "
              "; + + for (auto&& message : itor->second.messages) { + toolTip += "
            • " + message + "
            • "; + } + + toolTip += "
            "; + } + } + + return toolTip; +} + +QVariant PluginList::iconData(const QModelIndex& modelIndex) const +{ + int index = modelIndex.row(); + + QVariantList result; + + const auto& esp = m_ESPs[index]; + + auto infoItor = m_AdditionalInfo.find(esp.name); + + const AdditionalInfo* info = nullptr; + if (infoItor != m_AdditionalInfo.end()) { + info = &infoItor->second; + } + + if (isProblematic(esp, info)) { + result.append(":/MO/gui/warning"); + } + + if (m_LockedOrder.contains(esp.name)) { + result.append(":/MO/gui/locked"); + } + + if (hasInfo(esp, info)) { + result.append(":/MO/gui/information"); + } + + if (esp.hasIni) { + result.append(":/MO/gui/attachment"); + } + + if (!esp.archives.empty()) { + result.append(":/MO/gui/archive_conflict_neutral"); + } + + if (esp.isLightFlagged && !esp.hasLightExtension) { + result.append(":/MO/gui/awaiting"); + } + + if (esp.isMediumFlagged) { + result.append(":/MO/gui/run"); + if (esp.isLightFlagged) { + result.append(":/MO/gui/warning"); + } + } + + if (esp.isBlueprintFlagged) { + result.append(":/MO/gui/resources/go-down.png"); + } + + if (esp.hasNoRecords) { + result.append(":/MO/gui/unchecked-checkbox"); + } + + return result; +} + +bool PluginList::isProblematic(const ESPInfo& esp, const AdditionalInfo*) +{ + return !esp.masterUnset.empty(); +} + +bool PluginList::hasInfo(const ESPInfo&, const AdditionalInfo* info) +{ + return info && !info->messages.empty(); +} + +bool PluginList::setData(const QModelIndex& modIndex, const QVariant& value, int role) +{ + QString modName = modIndex.data().toString(); + IPluginList::PluginStates oldState = state(modName); + + bool result = false; + + if (role == Qt::CheckStateRole) { + m_ESPs[modIndex.row()].enabled = value.toInt() == Qt::Checked || + m_ESPs[modIndex.row()].forceLoaded || + m_ESPs[modIndex.row()].forceEnabled; + m_LastCheck.restart(); + emit dataChanged(modIndex, modIndex); + + refreshLoadOrder(); + emit writePluginsList(); + + result = true; + } else if (role == Qt::EditRole) { + if (modIndex.column() == COL_PRIORITY) { + bool ok = false; + int newPriority = value.toInt(&ok); + if (ok) { + setPluginPriority(modIndex.row(), newPriority); + result = true; + } + refreshLoadOrder(); + emit writePluginsList(); + } + } + + IPluginList::PluginStates newState = state(modName); + if (oldState != newState) { + try { + pluginStatesChanged({modName}, newState); + testMasters(); + emit dataChanged(this->index(0, 0), + this->index(static_cast(m_ESPs.size()), columnCount())); + } catch (const std::exception& e) { + log::error("failed to invoke state changed notification: {}", e.what()); + } catch (...) { + log::error("failed to invoke state changed notification: unknown exception"); + } + } + + return result; +} + +QVariant PluginList::headerData(int section, Qt::Orientation orientation, + int role) const +{ + if (orientation == Qt::Horizontal) { + if (role == Qt::DisplayRole) { + return getColumnName(section); + } else if (role == Qt::ToolTipRole) { + return getColumnToolTip(section); + } + } + return QAbstractItemModel::headerData(section, orientation, role); +} + +Qt::ItemFlags PluginList::flags(const QModelIndex& modelIndex) const +{ + int index = modelIndex.row(); + Qt::ItemFlags result = QAbstractItemModel::flags(modelIndex); + + if (modelIndex.isValid()) { + if (!m_ESPs[index].forceLoaded && !m_ESPs[index].forceDisabled) + result |= Qt::ItemIsDragEnabled; + if (!m_ESPs[index].forceLoaded && !m_ESPs[index].forceEnabled && + !m_ESPs[index].forceDisabled) + result |= Qt::ItemIsUserCheckable; + if (modelIndex.column() == COL_PRIORITY) + result |= Qt::ItemIsEditable; + result &= ~Qt::ItemIsDropEnabled; + } else { + result |= Qt::ItemIsDropEnabled; + } + + return result; +} + +void PluginList::setPluginPriority(int row, int& newPriority, bool isForced) +{ + int newPriorityTemp = newPriority; + + // enforce valid range + if (newPriorityTemp < 0) + newPriorityTemp = 0; + else if (std::cmp_greater_equal(newPriorityTemp, m_ESPsByPriority.size())) + newPriorityTemp = static_cast(m_ESPsByPriority.size()) - 1; + + int blueprintStartPos = 0; + for (auto esp : m_ESPs) { + if (!esp.isBlueprintFlagged) { + blueprintStartPos++; + } + } + + bool isBlueprint = m_ESPs[row].isBlueprintFlagged; + int lowerLimit = 0; + int upperLimit = 0; + if (isBlueprint) { + if (newPriorityTemp < blueprintStartPos) { + newPriorityTemp = blueprintStartPos; + } + lowerLimit = blueprintStartPos; + upperLimit = static_cast(m_ESPsByPriority.size() - 1); + } + if (!isBlueprint) { + if (newPriorityTemp >= blueprintStartPos) { + newPriorityTemp = blueprintStartPos - 1; + } + upperLimit = blueprintStartPos - 1; + } + if (!m_ESPs[row].isMasterFlagged && !m_ESPs[row].hasLightExtension && + !m_ESPs[row].hasMasterExtension) { + // don't allow esps to be moved above esms + while ((newPriorityTemp < upperLimit) && + (m_ESPs.at(m_ESPsByPriority.at(newPriorityTemp)).isMasterFlagged || + m_ESPs.at(m_ESPsByPriority.at(newPriorityTemp)).hasLightExtension || + m_ESPs.at(m_ESPsByPriority.at(newPriorityTemp)).hasMasterExtension)) { + ++newPriorityTemp; + } + } else { + // don't allow esms to be moved below esps + while ((newPriorityTemp > lowerLimit) && + !m_ESPs.at(m_ESPsByPriority.at(newPriorityTemp)).isMasterFlagged && + !m_ESPs.at(m_ESPsByPriority.at(newPriorityTemp)).hasLightExtension && + !m_ESPs.at(m_ESPsByPriority.at(newPriorityTemp)).hasMasterExtension) { + --newPriorityTemp; + } + // also don't allow "regular" esms to be moved above primary plugins + while ((newPriorityTemp < upperLimit) && + (m_ESPs.at(m_ESPsByPriority.at(newPriorityTemp)).forceLoaded)) { + ++newPriorityTemp; + } + } + + int oldPriority = m_ESPs.at(row).priority; + if (newPriorityTemp < oldPriority) { // moving up + // don't allow plugins to be moved above their masters + for (auto master : m_ESPs[row].masters) { + auto iter = m_ESPsByName.find(master); + if (iter != m_ESPsByName.end()) { + if (m_ESPs[iter->second].isBlueprintFlagged == + m_ESPs.at(row).isBlueprintFlagged) { + int masterPriority = m_ESPs[iter->second].priority; + if (masterPriority >= newPriorityTemp) { + newPriorityTemp = masterPriority + 1; + } + } + } + } + } else if (newPriorityTemp > oldPriority) { // moving down + // don't allow masters to be moved below their children + for (int i = oldPriority + 1; i <= newPriorityTemp; i++) { + PluginList::ESPInfo* otherInfo = &m_ESPs.at(m_ESPsByPriority[i]); + for (auto master : otherInfo->masters) { + auto iter = m_ESPsByName.find(master); + if (iter != m_ESPsByName.end()) { + if (m_ESPs[iter->second].isBlueprintFlagged == + otherInfo->isBlueprintFlagged) { + if (master.compare(m_ESPs[row].name, Qt::CaseInsensitive) == 0) { + newPriorityTemp = otherInfo->priority - 1; + break; + } + } + } + } + } + } + + try { + if (newPriorityTemp != oldPriority) { + if (newPriorityTemp > oldPriority) { + // priority is higher than the old, so the gap we left is in lower priorities + for (int i = oldPriority + 1; i <= newPriorityTemp; ++i) { + --m_ESPs.at(m_ESPsByPriority.at(i)).priority; + } + emit dataChanged(index(oldPriority + 1, 0), + index(newPriorityTemp, columnCount())); + } else { + for (int i = newPriorityTemp; i < oldPriority; ++i) { + ++m_ESPs.at(m_ESPsByPriority.at(i)).priority; + } + emit dataChanged(index(newPriorityTemp, 0), + index(oldPriority - 1, columnCount())); + ++newPriority; + } + + m_ESPs.at(row).priority = newPriorityTemp; + emit dataChanged(index(row, 0), index(row, columnCount())); + m_PluginMoved(m_ESPs[row].name, oldPriority, newPriorityTemp); + } + } catch (const std::out_of_range&) { + reportError(tr("failed to restore load order for %1").arg(m_ESPs[row].name)); + } + + updateIndices(); +} + +void PluginList::changePluginPriority(std::vector rows, int newPriority) +{ + ChangeBracket layoutChange(this); + const std::vector& esp = m_ESPs; + + // sort the moving plugins by ascending priorities + std::sort(rows.begin(), rows.end(), [&esp](const int& LHS, const int& RHS) { + return esp[LHS].priority < esp[RHS].priority; + }); + + // if at least on plugin is increasing in priority, the target index is + // that of the row BELOW the dropped location, otherwise it's the one above + for (std::vector::const_iterator iter = rows.begin(); iter != rows.end(); + ++iter) { + if (m_ESPs[*iter].priority < newPriority) { + --newPriority; + break; + } + } + + for (std::vector::const_iterator iter = rows.begin(); iter != rows.end(); + ++iter) { + setPluginPriority(*iter, newPriority); + } + + layoutChange.finish(); + refreshLoadOrder(); + emit writePluginsList(); +} + +bool PluginList::dropMimeData(const QMimeData* mimeData, Qt::DropAction action, int row, + int, const QModelIndex& parent) +{ + if (action == Qt::IgnoreAction) { + return true; + } + + QByteArray encoded = mimeData->data("application/x-qabstractitemmodeldatalist"); + QDataStream stream(&encoded, QIODevice::ReadOnly); + + std::vector sourceRows; + + while (!stream.atEnd()) { + int sourceRow; + int col; + QMap roleDataMap; + stream >> sourceRow >> col >> roleDataMap; + if (col == 0) { // only add each row once + sourceRows.push_back(sourceRow); + } + } + + if (row == -1) { + row = parent.row(); + } + + int newPriority; + + if ((row < 0) || (std::cmp_greater_equal(row, m_ESPs.size()))) { + newPriority = static_cast(m_ESPs.size()); + } else { + newPriority = m_ESPs[row].priority; + } + changePluginPriority(sourceRows, newPriority); + + return false; +} + +QModelIndex PluginList::index(int row, int column, const QModelIndex&) const +{ + if ((row < 0) || (row >= rowCount()) || (column < 0) || (column >= columnCount())) { + return {}; + } + return createIndex(row, column, row); +} + +QModelIndex PluginList::parent(const QModelIndex&) const +{ + return {}; +} + +PluginList::ESPInfo::ESPInfo(const QString& name, bool forceLoaded, bool forceEnabled, + bool forceDisabled, const QString& originName, + const QString& fullPath, bool hasIni, + std::set archives, bool lightSupported, + bool mediumSupported, bool blueprintSupported) + : name(name), fullPath(fullPath), enabled(forceLoaded), forceLoaded(forceLoaded), + forceEnabled(forceEnabled), forceDisabled(forceDisabled), originName(originName), hasIni(hasIni), + archives(archives.begin(), archives.end()) + +{ + QString parsePath = fullPath; + // Linux is case-sensitive while Windows-authored paths sometimes mismatch + // the actual filename casing. Resolve to the on-disk casing so the plugin + // parser doesn't fail when only the case differs. + if (!QFileInfo::exists(parsePath)) { + const QFileInfo fi(parsePath); + const QDir dir(fi.path()); + if (dir.exists()) { + const auto candidates = dir.entryList(QDir::Files | QDir::Readable); + const auto targetName = fi.fileName(); + const auto it = std::find_if(candidates.begin(), candidates.end(), + [&](const QString& candidate) { + return candidate.compare(targetName, + Qt::CaseInsensitive) == 0; + }); + if (it != candidates.end()) { + parsePath = dir.filePath(*it); + this->fullPath = parsePath; + log::warn("plugin path case mismatch, resolved '{}' -> '{}'", fullPath, + parsePath); + } + } + } + + try { + // Linux filesystem is UTF-8. ToWString → wstring → naive wchar_t->char + // copy in esptk truncates non-ASCII (ö, –), producing "file not found" + // on paths like "Mörskom Estate" or "Official Master Files – Cleaned". + // Pass UTF-8 bytes directly. + ESP::File file(parsePath.toStdString()); + auto extension = name.right(3).toLower(); + hasMasterExtension = (extension == "esm"); + hasLightExtension = (extension == "esl"); + isMasterFlagged = file.isMaster(); + isLightFlagged = lightSupported && file.isLight(mediumSupported); + isMediumFlagged = mediumSupported && file.isMedium(); + isBlueprintFlagged = blueprintSupported && + (isMasterFlagged || hasMasterExtension || hasLightExtension) && + file.isBlueprint(); + hasNoRecords = file.isDummy(); + + formVersion = file.formVersion(); + headerVersion = file.headerVersion(); + author = QString::fromLatin1(file.author().c_str()); + description = QString::fromLatin1(file.description().c_str()); + + for (auto&& m : file.masters()) { + masters.insert(QString::fromStdString(m)); + } + } catch (const std::exception& e) { + log::error("failed to parse plugin file {}: {}", fullPath, e.what()); + hasMasterExtension = false; + hasLightExtension = false; + isMasterFlagged = false; + isLightFlagged = false; + isMediumFlagged = false; + isBlueprintFlagged = false; + hasNoRecords = false; + } +} + +void PluginList::managedGameChanged(const IPluginGame* gamePlugin) +{ + m_GamePlugin = gamePlugin; +} diff --git a/src/src/pluginlistcontextmenu.cpp b/src/src/pluginlistcontextmenu.cpp index d989c0f..9c90267 100644 --- a/src/src/pluginlistcontextmenu.cpp +++ b/src/src/pluginlistcontextmenu.cpp @@ -1,165 +1,165 @@ -#include "pluginlistcontextmenu.h" - -#include -#include - -#include "organizercore.h" -#include "pluginlistview.h" - -using namespace MOBase; - -PluginListContextMenu::PluginListContextMenu(const QModelIndex& index, - OrganizerCore& core, PluginListView* view) - : QMenu(view), m_core(core), - m_index(index.model() == view->model() ? view->indexViewToModel(index) : index), - m_view(view) -{ - if (view->selectionModel()->hasSelection()) { - m_selected = view->indexViewToModel(view->selectionModel()->selectedRows()); - } else if (index.isValid()) { - m_selected = {index}; - } - - if (!m_selected.isEmpty()) { - addAction(tr("Enable selected"), [=, this]() { - m_core.pluginList()->setEnabled(m_selected, true); - }); - addAction(tr("Disable selected"), [=, this]() { - m_core.pluginList()->setEnabled(m_selected, false); - }); - - addSeparator(); - } - - addAction(tr("Enable all"), [=, this]() { - if (QMessageBox::question(m_view->topLevelWidget(), tr("Confirm"), - tr("Really enable all plugins?"), - QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { - m_core.pluginList()->setEnabledAll(true); - } - }); - addAction(tr("Disable all"), [=, this]() { - if (QMessageBox::question(m_view->topLevelWidget(), tr("Confirm"), - tr("Really disable all plugins?"), - QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { - m_core.pluginList()->setEnabledAll(false); - } - }); - - if (!m_selected.isEmpty()) { - addSeparator(); - addMenu(createSendToContextMenu()); - - addSeparator(); - - bool hasLocked = false; - bool hasUnlocked = false; - for (auto& idx : m_selected) { - if (m_core.pluginList()->isEnabled(idx.row())) { - if (m_core.pluginList()->isESPLocked(idx.row())) { - hasLocked = true; - } else { - hasUnlocked = true; - } - } - } - - if (hasLocked) { - addAction(tr("Unlock load order"), [=, this]() { - setESPLock(m_selected, false); - }); - } - if (hasUnlocked) { - addAction(tr("Lock load order"), [=, this]() { - setESPLock(m_selected, true); - }); - } - } - - if (m_index.isValid()) { - addSeparator(); - - unsigned int modInfoIndex = - ModInfo::getIndex(m_core.pluginList()->origin(m_index.data().toString())); - // this is to avoid showing the option on game files like skyrim.esm - if (modInfoIndex != UINT_MAX) { - addAction(tr("Open Origin in Explorer"), [=, this]() { - openOriginExplorer(m_selected); - }); - ModInfo::Ptr modInfo = ModInfo::getByIndex(modInfoIndex); - std::vector flags = modInfo->getFlags(); - - if (!modInfo->isForeign() && m_selected.size() == 1) { - QAction* infoAction = addAction(tr("Open Origin Info..."), [=, this]() { - openOriginInformation(index); - }); - setDefaultAction(infoAction); - } - } - } -} - -QMenu* PluginListContextMenu::createSendToContextMenu() -{ - QMenu* menu = new QMenu(m_view); - menu->setTitle(tr("Send to... ")); - menu->addAction(tr("Top"), [=, this]() { - m_core.pluginList()->sendToPriority(m_selected, 0); - }); - menu->addAction(tr("Bottom"), [=, this]() { - m_core.pluginList()->sendToPriority(m_selected, INT_MAX); - }); - menu->addAction(tr("Priority..."), [=, this]() { - sendPluginsToPriority(m_selected); - }); - return menu; -} - -void PluginListContextMenu::sendPluginsToPriority(const QModelIndexList& indices) -{ - bool ok; - int newPriority = QInputDialog::getInt(m_view->topLevelWidget(), tr("Set Priority"), - tr("Set the priority of the selected plugins"), - 0, 0, INT_MAX, 1, &ok); - if (!ok) - return; - - m_core.pluginList()->sendToPriority(m_selected, newPriority); -} - -void PluginListContextMenu::setESPLock(const QModelIndexList& indices, bool locked) -{ - for (auto& idx : indices) { - if (m_core.pluginList()->isEnabled(idx.row())) { - m_core.pluginList()->lockESPIndex(idx.row(), locked); - } - } -} - -void PluginListContextMenu::openOriginExplorer(const QModelIndexList& indices) -{ - for (auto& idx : indices) { - QString fileName = idx.data().toString(); - unsigned int modIndex = ModInfo::getIndex(m_core.pluginList()->origin(fileName)); - if (modIndex == UINT_MAX) { - continue; - } - ModInfo::Ptr modInfo = ModInfo::getByIndex(modIndex); - shell::Explore(modInfo->absolutePath()); - } -} - -void PluginListContextMenu::openOriginInformation(const QModelIndex& index) -{ - try { - QString fileName = index.data().toString(); - unsigned int modIndex = ModInfo::getIndex(m_core.pluginList()->origin(fileName)); - ModInfo::Ptr modInfo = ModInfo::getByIndex(modIndex); - - if (modInfo->isRegular() || modInfo->isOverwrite()) { - emit openModInformation(modIndex); - } - } catch (const std::exception& e) { - reportError(e.what()); - } -} +#include "pluginlistcontextmenu.h" + +#include +#include + +#include "organizercore.h" +#include "pluginlistview.h" + +using namespace MOBase; + +PluginListContextMenu::PluginListContextMenu(const QModelIndex& index, + OrganizerCore& core, PluginListView* view) + : QMenu(view), m_core(core), + m_index(index.model() == view->model() ? view->indexViewToModel(index) : index), + m_view(view) +{ + if (view->selectionModel()->hasSelection()) { + m_selected = view->indexViewToModel(view->selectionModel()->selectedRows()); + } else if (index.isValid()) { + m_selected = {index}; + } + + if (!m_selected.isEmpty()) { + addAction(tr("Enable selected"), [=, this]() { + m_core.pluginList()->setEnabled(m_selected, true); + }); + addAction(tr("Disable selected"), [=, this]() { + m_core.pluginList()->setEnabled(m_selected, false); + }); + + addSeparator(); + } + + addAction(tr("Enable all"), [=, this]() { + if (QMessageBox::question(m_view->topLevelWidget(), tr("Confirm"), + tr("Really enable all plugins?"), + QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { + m_core.pluginList()->setEnabledAll(true); + } + }); + addAction(tr("Disable all"), [=, this]() { + if (QMessageBox::question(m_view->topLevelWidget(), tr("Confirm"), + tr("Really disable all plugins?"), + QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { + m_core.pluginList()->setEnabledAll(false); + } + }); + + if (!m_selected.isEmpty()) { + addSeparator(); + addMenu(createSendToContextMenu()); + + addSeparator(); + + bool hasLocked = false; + bool hasUnlocked = false; + for (auto& idx : m_selected) { + if (m_core.pluginList()->isEnabled(idx.row())) { + if (m_core.pluginList()->isESPLocked(idx.row())) { + hasLocked = true; + } else { + hasUnlocked = true; + } + } + } + + if (hasLocked) { + addAction(tr("Unlock load order"), [=, this]() { + setESPLock(m_selected, false); + }); + } + if (hasUnlocked) { + addAction(tr("Lock load order"), [=, this]() { + setESPLock(m_selected, true); + }); + } + } + + if (m_index.isValid()) { + addSeparator(); + + unsigned int modInfoIndex = + ModInfo::getIndex(m_core.pluginList()->origin(m_index.data().toString())); + // this is to avoid showing the option on game files like skyrim.esm + if (modInfoIndex != UINT_MAX) { + addAction(tr("Open Origin in Explorer"), [=, this]() { + openOriginExplorer(m_selected); + }); + ModInfo::Ptr modInfo = ModInfo::getByIndex(modInfoIndex); + std::vector flags = modInfo->getFlags(); + + if (!modInfo->isForeign() && m_selected.size() == 1) { + QAction* infoAction = addAction(tr("Open Origin Info..."), [=, this]() { + openOriginInformation(index); + }); + setDefaultAction(infoAction); + } + } + } +} + +QMenu* PluginListContextMenu::createSendToContextMenu() +{ + QMenu* menu = new QMenu(m_view); + menu->setTitle(tr("Send to... ")); + menu->addAction(tr("Top"), [=, this]() { + m_core.pluginList()->sendToPriority(m_selected, 0); + }); + menu->addAction(tr("Bottom"), [=, this]() { + m_core.pluginList()->sendToPriority(m_selected, INT_MAX); + }); + menu->addAction(tr("Priority..."), [=, this]() { + sendPluginsToPriority(m_selected); + }); + return menu; +} + +void PluginListContextMenu::sendPluginsToPriority(const QModelIndexList& indices) +{ + bool ok; + int newPriority = QInputDialog::getInt(m_view->topLevelWidget(), tr("Set Priority"), + tr("Set the priority of the selected plugins"), + 0, 0, INT_MAX, 1, &ok); + if (!ok) + return; + + m_core.pluginList()->sendToPriority(m_selected, newPriority); +} + +void PluginListContextMenu::setESPLock(const QModelIndexList& indices, bool locked) +{ + for (const auto& idx : indices) { + if (m_core.pluginList()->isEnabled(idx.row())) { + m_core.pluginList()->lockESPIndex(idx.row(), locked); + } + } +} + +void PluginListContextMenu::openOriginExplorer(const QModelIndexList& indices) +{ + for (const auto& idx : indices) { + QString fileName = idx.data().toString(); + unsigned int modIndex = ModInfo::getIndex(m_core.pluginList()->origin(fileName)); + if (modIndex == UINT_MAX) { + continue; + } + ModInfo::Ptr modInfo = ModInfo::getByIndex(modIndex); + shell::Explore(modInfo->absolutePath()); + } +} + +void PluginListContextMenu::openOriginInformation(const QModelIndex& index) +{ + try { + QString fileName = index.data().toString(); + unsigned int modIndex = ModInfo::getIndex(m_core.pluginList()->origin(fileName)); + ModInfo::Ptr modInfo = ModInfo::getByIndex(modIndex); + + if (modInfo->isRegular() || modInfo->isOverwrite()) { + emit openModInformation(modIndex); + } + } catch (const std::exception& e) { + reportError(e.what()); + } +} diff --git a/src/src/pluginlistview.cpp b/src/src/pluginlistview.cpp index a37cbc9..271e256 100644 --- a/src/src/pluginlistview.cpp +++ b/src/src/pluginlistview.cpp @@ -1,370 +1,370 @@ -#include "pluginlistview.h" - -#include -#include - -#include -#include - -#include "copyeventfilter.h" -#include "gameplugins.h" -#include "genericicondelegate.h" -#include "mainwindow.h" -#include "modelutils.h" -#include "modlistview.h" -#include "modlistviewactions.h" -#include "organizercore.h" -#include "pluginlistcontextmenu.h" -#include "pluginlistsortproxy.h" -#include "shared/directoryentry.h" -#include "shared/fileentry.h" -#include "shared/filesorigin.h" -#include "ui_mainwindow.h" - -using namespace MOBase; - -PluginListView::PluginListView(QWidget* parent) - : QTreeView(parent), - m_Scrollbar(new ViewMarkingScrollBar(this, Qt::BackgroundRole)) -{ - setVerticalScrollBar(m_Scrollbar); - MOBase::setCustomizableColumns(this); - installEventFilter(new CopyEventFilter(this)); -} - -void PluginListView::activated() -{ - // update highlighted mods - selectionModel()->selectionChanged({}, {}); -} - -int PluginListView::sortColumn() const -{ - return m_sortProxy ? m_sortProxy->sortColumn() : -1; -} - -QModelIndex PluginListView::indexModelToView(const QModelIndex& index) const -{ - return MOShared::indexModelToView(index, this); -} - -QModelIndexList PluginListView::indexModelToView(const QModelIndexList& index) const -{ - return MOShared::indexModelToView(index, this); -} - -QModelIndex PluginListView::indexViewToModel(const QModelIndex& index) const -{ - return MOShared::indexViewToModel(index, m_core->pluginList()); -} - -QModelIndexList PluginListView::indexViewToModel(const QModelIndexList& index) const -{ - return MOShared::indexViewToModel(index, m_core->pluginList()); -} - -void PluginListView::updatePluginCount() -{ - int activeMasterCount = 0; - int activeMediumMasterCount = 0; - int activeLightMasterCount = 0; - int activeBlueprintCount = 0; - int activeRegularCount = 0; - int masterCount = 0; - int mediumMasterCount = 0; - int lightMasterCount = 0; - int blueprintCount = 0; - int regularCount = 0; - int activeVisibleCount = 0; - - PluginList* list = m_core->pluginList(); - QString filter = ui.filter->text(); - - for (QString plugin : list->pluginNames()) { - bool active = list->isEnabled(plugin); - bool visible = m_sortProxy->filterMatchesPlugin(plugin); - if (list->isMediumFlagged(plugin)) { - mediumMasterCount++; - activeMediumMasterCount += active; - activeVisibleCount += visible && active; - } else if (list->hasLightExtension(plugin) || list->isLightFlagged(plugin)) { - lightMasterCount++; - activeLightMasterCount += active; - activeVisibleCount += visible && active; - } else if (list->hasMasterExtension(plugin) || list->isMasterFlagged(plugin)) { - masterCount++; - activeMasterCount += active; - activeVisibleCount += visible && active; - } else { - regularCount++; - activeRegularCount += active; - activeVisibleCount += visible && active; - } - - if (list->isBlueprintFlagged(plugin)) { - // separate if-statement because blueprint masters are also counted as - // (medium/light) masters - blueprintCount++; - activeBlueprintCount += active; - } - } - - int activeCount = activeMasterCount + activeMediumMasterCount + - activeLightMasterCount + activeRegularCount; - int totalCount = masterCount + mediumMasterCount + lightMasterCount + regularCount; - - auto toolTip = - tr("" - "" - "" - "" - "" - "") - .arg(activeCount) - .arg(totalCount) - .arg(activeMasterCount) - .arg(masterCount) - .arg(activeRegularCount) - .arg(regularCount) - .arg(activeMasterCount + activeRegularCount) - .arg(masterCount + regularCount); - - auto gamePlugins = m_core->gameFeatures().gameFeature(); - const bool lightPluginsAreSupported = - gamePlugins ? gamePlugins->lightPluginsAreSupported() : false; - const bool mediumPluginsAreSupported = - gamePlugins ? gamePlugins->mediumPluginsAreSupported() : false; - const bool blueprintPluginsAreSupported = - gamePlugins ? gamePlugins->blueprintPluginsAreSupported() : false; - - if (mediumPluginsAreSupported) { - toolTip += - tr("") - .arg(activeMediumMasterCount) - .arg(mediumMasterCount); - } - if (lightPluginsAreSupported) { - toolTip += - tr("") - .arg(activeLightMasterCount) - .arg(lightMasterCount); - } - if (blueprintPluginsAreSupported) { - toolTip += tr("") - .arg(activeBlueprintCount) - .arg(blueprintCount); - } - - toolTip += "
            TypeActive Total
            All plugins:%1 %2
            ESMs:%3 %4
            ESPs:%5 %6
            ESMs+ESPs:%7 %8
            ESHs:%1 %2
            ESLs:%1 %2
            Blueprint masters:%1 %2
            "; - - ui.counter->display(activeVisibleCount); - ui.counter->setToolTip(toolTip); -} - -void PluginListView::onFilterChanged(const QString& filter) -{ - if (!filter.isEmpty()) { - setStyleSheet("QTreeView { border: 2px ridge #f00; }"); - ui.counter->setStyleSheet("QLCDNumber { border: 2px ridge #f00; }"); - } else { - setStyleSheet(""); - ui.counter->setStyleSheet(""); - } - updatePluginCount(); -} - -std::pair PluginListView::selected() const -{ - return {indexViewToModel(currentIndex()), - indexViewToModel(selectionModel()->selectedRows())}; -} - -void PluginListView::setSelected(const QModelIndex& current, - const QModelIndexList& selected) -{ - setCurrentIndex(indexModelToView(current)); - for (auto idx : selected) { - selectionModel()->select(indexModelToView(idx), - QItemSelectionModel::Select | QItemSelectionModel::Rows); - } -} - -void PluginListView::setup(OrganizerCore& core, MainWindow* mw, Ui::MainWindow* mwui) -{ - m_core = &core; - ui = {mwui->activePluginsCounter, mwui->espFilterEdit}; - m_modActions = &mwui->modList->actions(); - - m_sortProxy = new PluginListSortProxy(&core); - m_sortProxy->setSourceModel(core.pluginList()); - setModel(m_sortProxy); - - sortByColumn(PluginList::COL_PRIORITY, Qt::AscendingOrder); - setItemDelegateForColumn(PluginList::COL_FLAGS, new GenericIconDelegate(this)); - - // counter - connect(core.pluginList(), &PluginList::writePluginsList, [=, this] { - updatePluginCount(); - }); - connect(core.pluginList(), &PluginList::esplist_changed, [=, this] { - updatePluginCount(); - }); - - // filter - connect(ui.filter, &QLineEdit::textChanged, m_sortProxy, - &PluginListSortProxy::updateFilter); - connect(ui.filter, &QLineEdit::textChanged, this, &PluginListView::onFilterChanged); - - // highlight mod list when selected - connect(selectionModel(), &QItemSelectionModel::selectionChanged, [=, this] { - std::set mods; - auto& directoryEntry = *m_core->directoryStructure(); - auto pluginIndices = indexViewToModel(selectionModel()->selectedRows()); - for (auto& idx : pluginIndices) { - QString pluginName = m_core->pluginList()->getName(idx.row()); - - const MOShared::FileEntryPtr fileEntry = - directoryEntry.findFile(pluginName.toStdWString()); - if (fileEntry.get() != nullptr) { - QString originName = QString::fromStdWString( - directoryEntry.getOriginByID(fileEntry->getOrigin()).getName()); - mods.insert(originName); - } - } - mwui->modList->setHighlightedMods(mods); - m_core->pluginList()->highlightMasters(pluginIndices); - repaint(); - verticalScrollBar()->repaint(); - }); - - // using a lambda here to avoid storing the mod list actions - connect(this, &QTreeView::customContextMenuRequested, [=, this](auto&& pos) { - onCustomContextMenuRequested(pos); - }); - connect(this, &QTreeView::doubleClicked, [=, this](auto&& index) { - onDoubleClicked(index); - }); -} - -void PluginListView::onCustomContextMenuRequested(const QPoint& pos) -{ - try { - PluginListContextMenu menu(indexViewToModel(indexAt(pos)), *m_core, this); - connect(&menu, &PluginListContextMenu::openModInformation, [=, this](auto&& modIndex) { - m_modActions->displayModInformation(modIndex); - }); - menu.exec(viewport()->mapToGlobal(pos)); - } catch (const std::exception& e) { - reportError(tr("Exception: ").arg(e.what())); - } catch (...) { - reportError(tr("Unknown exception")); - } -} - -void PluginListView::onDoubleClicked(const QModelIndex& index) -{ - if (!index.isValid()) { - return; - } - - if (m_core->pluginList()->timeElapsedSinceLastChecked() <= - QApplication::doubleClickInterval()) { - // don't interpret double click if we only just checked a plugin - return; - } - - try { - if (selectionModel()->hasSelection() && - selectionModel()->selectedRows().count() == 1) { - - QModelIndex idx = selectionModel()->currentIndex(); - QString fileName = idx.data().toString(); - - if (ModInfo::getIndex(m_core->pluginList()->origin(fileName)) == UINT_MAX) { - return; - } - - auto modIndex = ModInfo::getIndex(m_core->pluginList()->origin(fileName)); - ModInfo::Ptr modInfo = ModInfo::getByIndex(modIndex); - - if (modInfo->isRegular() || modInfo->isOverwrite()) { - - Qt::KeyboardModifiers modifiers = QApplication::queryKeyboardModifiers(); - if (modifiers.testFlag(Qt::ControlModifier)) { - m_modActions->openExplorer({m_core->modList()->index(modIndex, 0)}); - } else { - m_modActions->displayModInformation( - ModInfo::getIndex(m_core->pluginList()->origin(fileName))); - } - - // workaround to cancel the editor that might have opened because of - // selection-click - closePersistentEditor(index); - } - } - } catch (const std::exception& e) { - reportError(e.what()); - } -} - -bool PluginListView::moveSelection(int key) -{ - auto [cindex, sourceRows] = selected(); - - int offset = key == Qt::Key_Up ? -1 : 1; - if (m_sortProxy->sortOrder() == Qt::DescendingOrder) { - offset = -offset; - } - - m_core->pluginList()->shiftPluginsPriority(sourceRows, offset); - - // reset the selection and the index - setSelected(cindex, sourceRows); - - return true; -} - -bool PluginListView::toggleSelectionState() -{ - if (!selectionModel()->hasSelection()) { - return true; - } - m_core->pluginList()->toggleState(indexViewToModel(selectionModel()->selectedRows())); - return true; -} - -bool PluginListView::event(QEvent* event) -{ - if (event->type() == QEvent::KeyPress) { - QKeyEvent* keyEvent = static_cast(event); - - if (keyEvent->modifiers() == Qt::ControlModifier && - (keyEvent->key() == Qt::Key_Return || keyEvent->key() == Qt::Key_Enter)) { - if (selectionModel()->hasSelection() && - selectionModel()->selectedRows().count() == 1) { - QModelIndex idx = selectionModel()->currentIndex(); - QString fileName = idx.data().toString(); - - if (ModInfo::getIndex(m_core->pluginList()->origin(fileName)) == UINT_MAX) { - return false; - } - - auto modIndex = ModInfo::getIndex(m_core->pluginList()->origin(fileName)); - m_modActions->openExplorer({m_core->modList()->index(modIndex, 0)}); - return true; - } - } else if (keyEvent->modifiers() == Qt::ControlModifier && - (sortColumn() == PluginList::COL_PRIORITY || - sortColumn() == PluginList::COL_MODINDEX) && - (keyEvent->key() == Qt::Key_Up || keyEvent->key() == Qt::Key_Down)) { - return moveSelection(keyEvent->key()); - } else if (keyEvent->key() == Qt::Key_Space) { - return toggleSelectionState(); - } - return QTreeView::event(event); - } - return QTreeView::event(event); -} +#include "pluginlistview.h" + +#include +#include + +#include +#include + +#include "copyeventfilter.h" +#include "gameplugins.h" +#include "genericicondelegate.h" +#include "mainwindow.h" +#include "modelutils.h" +#include "modlistview.h" +#include "modlistviewactions.h" +#include "organizercore.h" +#include "pluginlistcontextmenu.h" +#include "pluginlistsortproxy.h" +#include "shared/directoryentry.h" +#include "shared/fileentry.h" +#include "shared/filesorigin.h" +#include "ui_mainwindow.h" + +using namespace MOBase; + +PluginListView::PluginListView(QWidget* parent) + : QTreeView(parent), + m_Scrollbar(new ViewMarkingScrollBar(this, Qt::BackgroundRole)) +{ + setVerticalScrollBar(m_Scrollbar); + MOBase::setCustomizableColumns(this); + installEventFilter(new CopyEventFilter(this)); +} + +void PluginListView::activated() +{ + // update highlighted mods + selectionModel()->selectionChanged({}, {}); +} + +int PluginListView::sortColumn() const +{ + return m_sortProxy ? m_sortProxy->sortColumn() : -1; +} + +QModelIndex PluginListView::indexModelToView(const QModelIndex& index) const +{ + return MOShared::indexModelToView(index, this); +} + +QModelIndexList PluginListView::indexModelToView(const QModelIndexList& index) const +{ + return MOShared::indexModelToView(index, this); +} + +QModelIndex PluginListView::indexViewToModel(const QModelIndex& index) const +{ + return MOShared::indexViewToModel(index, m_core->pluginList()); +} + +QModelIndexList PluginListView::indexViewToModel(const QModelIndexList& index) const +{ + return MOShared::indexViewToModel(index, m_core->pluginList()); +} + +void PluginListView::updatePluginCount() +{ + int activeMasterCount = 0; + int activeMediumMasterCount = 0; + int activeLightMasterCount = 0; + int activeBlueprintCount = 0; + int activeRegularCount = 0; + int masterCount = 0; + int mediumMasterCount = 0; + int lightMasterCount = 0; + int blueprintCount = 0; + int regularCount = 0; + int activeVisibleCount = 0; + + PluginList* list = m_core->pluginList(); + QString filter = ui.filter->text(); + + for (QString plugin : list->pluginNames()) { + bool active = list->isEnabled(plugin); + bool visible = m_sortProxy->filterMatchesPlugin(plugin); + if (list->isMediumFlagged(plugin)) { + mediumMasterCount++; + activeMediumMasterCount += active; + activeVisibleCount += visible && active; + } else if (list->hasLightExtension(plugin) || list->isLightFlagged(plugin)) { + lightMasterCount++; + activeLightMasterCount += active; + activeVisibleCount += visible && active; + } else if (list->hasMasterExtension(plugin) || list->isMasterFlagged(plugin)) { + masterCount++; + activeMasterCount += active; + activeVisibleCount += visible && active; + } else { + regularCount++; + activeRegularCount += active; + activeVisibleCount += visible && active; + } + + if (list->isBlueprintFlagged(plugin)) { + // separate if-statement because blueprint masters are also counted as + // (medium/light) masters + blueprintCount++; + activeBlueprintCount += active; + } + } + + int activeCount = activeMasterCount + activeMediumMasterCount + + activeLightMasterCount + activeRegularCount; + int totalCount = masterCount + mediumMasterCount + lightMasterCount + regularCount; + + auto toolTip = + tr("" + "" + "" + "" + "" + "") + .arg(activeCount) + .arg(totalCount) + .arg(activeMasterCount) + .arg(masterCount) + .arg(activeRegularCount) + .arg(regularCount) + .arg(activeMasterCount + activeRegularCount) + .arg(masterCount + regularCount); + + auto gamePlugins = m_core->gameFeatures().gameFeature(); + const bool lightPluginsAreSupported = + gamePlugins ? gamePlugins->lightPluginsAreSupported() : false; + const bool mediumPluginsAreSupported = + gamePlugins ? gamePlugins->mediumPluginsAreSupported() : false; + const bool blueprintPluginsAreSupported = + gamePlugins ? gamePlugins->blueprintPluginsAreSupported() : false; + + if (mediumPluginsAreSupported) { + toolTip += + tr("") + .arg(activeMediumMasterCount) + .arg(mediumMasterCount); + } + if (lightPluginsAreSupported) { + toolTip += + tr("") + .arg(activeLightMasterCount) + .arg(lightMasterCount); + } + if (blueprintPluginsAreSupported) { + toolTip += tr("") + .arg(activeBlueprintCount) + .arg(blueprintCount); + } + + toolTip += "
            TypeActive Total
            All plugins:%1 %2
            ESMs:%3 %4
            ESPs:%5 %6
            ESMs+ESPs:%7 %8
            ESHs:%1 %2
            ESLs:%1 %2
            Blueprint masters:%1 %2
            "; + + ui.counter->display(activeVisibleCount); + ui.counter->setToolTip(toolTip); +} + +void PluginListView::onFilterChanged(const QString& filter) +{ + if (!filter.isEmpty()) { + setStyleSheet("QTreeView { border: 2px ridge #f00; }"); + ui.counter->setStyleSheet("QLCDNumber { border: 2px ridge #f00; }"); + } else { + setStyleSheet(""); + ui.counter->setStyleSheet(""); + } + updatePluginCount(); +} + +std::pair PluginListView::selected() const +{ + return {indexViewToModel(currentIndex()), + indexViewToModel(selectionModel()->selectedRows())}; +} + +void PluginListView::setSelected(const QModelIndex& current, + const QModelIndexList& selected) +{ + setCurrentIndex(indexModelToView(current)); + for (auto idx : selected) { + selectionModel()->select(indexModelToView(idx), + QItemSelectionModel::Select | QItemSelectionModel::Rows); + } +} + +void PluginListView::setup(OrganizerCore& core, MainWindow* mw, Ui::MainWindow* mwui) +{ + m_core = &core; + ui = {.counter=mwui->activePluginsCounter, .filter=mwui->espFilterEdit}; + m_modActions = &mwui->modList->actions(); + + m_sortProxy = new PluginListSortProxy(&core); + m_sortProxy->setSourceModel(core.pluginList()); + setModel(m_sortProxy); + + sortByColumn(PluginList::COL_PRIORITY, Qt::AscendingOrder); + setItemDelegateForColumn(PluginList::COL_FLAGS, new GenericIconDelegate(this)); + + // counter + connect(core.pluginList(), &PluginList::writePluginsList, [=, this] { + updatePluginCount(); + }); + connect(core.pluginList(), &PluginList::esplist_changed, [=, this] { + updatePluginCount(); + }); + + // filter + connect(ui.filter, &QLineEdit::textChanged, m_sortProxy, + &PluginListSortProxy::updateFilter); + connect(ui.filter, &QLineEdit::textChanged, this, &PluginListView::onFilterChanged); + + // highlight mod list when selected + connect(selectionModel(), &QItemSelectionModel::selectionChanged, [=, this] { + std::set mods; + auto& directoryEntry = *m_core->directoryStructure(); + auto pluginIndices = indexViewToModel(selectionModel()->selectedRows()); + for (auto& idx : pluginIndices) { + QString pluginName = m_core->pluginList()->getName(idx.row()); + + const MOShared::FileEntryPtr fileEntry = + directoryEntry.findFile(pluginName.toStdWString()); + if (fileEntry.get() != nullptr) { + QString originName = QString::fromStdWString( + directoryEntry.getOriginByID(fileEntry->getOrigin()).getName()); + mods.insert(originName); + } + } + mwui->modList->setHighlightedMods(mods); + m_core->pluginList()->highlightMasters(pluginIndices); + repaint(); + verticalScrollBar()->repaint(); + }); + + // using a lambda here to avoid storing the mod list actions + connect(this, &QTreeView::customContextMenuRequested, [=, this](auto&& pos) { + onCustomContextMenuRequested(pos); + }); + connect(this, &QTreeView::doubleClicked, [=, this](auto&& index) { + onDoubleClicked(index); + }); +} + +void PluginListView::onCustomContextMenuRequested(const QPoint& pos) +{ + try { + PluginListContextMenu menu(indexViewToModel(indexAt(pos)), *m_core, this); + connect(&menu, &PluginListContextMenu::openModInformation, [=, this](auto&& modIndex) { + m_modActions->displayModInformation(modIndex); + }); + menu.exec(viewport()->mapToGlobal(pos)); + } catch (const std::exception& e) { + reportError(tr("Exception: ").arg(e.what())); + } catch (...) { + reportError(tr("Unknown exception")); + } +} + +void PluginListView::onDoubleClicked(const QModelIndex& index) +{ + if (!index.isValid()) { + return; + } + + if (m_core->pluginList()->timeElapsedSinceLastChecked() <= + QApplication::doubleClickInterval()) { + // don't interpret double click if we only just checked a plugin + return; + } + + try { + if (selectionModel()->hasSelection() && + selectionModel()->selectedRows().count() == 1) { + + QModelIndex idx = selectionModel()->currentIndex(); + QString fileName = idx.data().toString(); + + if (ModInfo::getIndex(m_core->pluginList()->origin(fileName)) == UINT_MAX) { + return; + } + + auto modIndex = ModInfo::getIndex(m_core->pluginList()->origin(fileName)); + ModInfo::Ptr modInfo = ModInfo::getByIndex(modIndex); + + if (modInfo->isRegular() || modInfo->isOverwrite()) { + + Qt::KeyboardModifiers modifiers = QApplication::queryKeyboardModifiers(); + if (modifiers.testFlag(Qt::ControlModifier)) { + ModListViewActions::openExplorer({m_core->modList()->index(modIndex, 0)}); + } else { + m_modActions->displayModInformation( + ModInfo::getIndex(m_core->pluginList()->origin(fileName))); + } + + // workaround to cancel the editor that might have opened because of + // selection-click + closePersistentEditor(index); + } + } + } catch (const std::exception& e) { + reportError(e.what()); + } +} + +bool PluginListView::moveSelection(int key) +{ + auto [cindex, sourceRows] = selected(); + + int offset = key == Qt::Key_Up ? -1 : 1; + if (m_sortProxy->sortOrder() == Qt::DescendingOrder) { + offset = -offset; + } + + m_core->pluginList()->shiftPluginsPriority(sourceRows, offset); + + // reset the selection and the index + setSelected(cindex, sourceRows); + + return true; +} + +bool PluginListView::toggleSelectionState() +{ + if (!selectionModel()->hasSelection()) { + return true; + } + m_core->pluginList()->toggleState(indexViewToModel(selectionModel()->selectedRows())); + return true; +} + +bool PluginListView::event(QEvent* event) +{ + if (event->type() == QEvent::KeyPress) { + QKeyEvent* keyEvent = static_cast(event); + + if (keyEvent->modifiers() == Qt::ControlModifier && + (keyEvent->key() == Qt::Key_Return || keyEvent->key() == Qt::Key_Enter)) { + if (selectionModel()->hasSelection() && + selectionModel()->selectedRows().count() == 1) { + QModelIndex idx = selectionModel()->currentIndex(); + QString fileName = idx.data().toString(); + + if (ModInfo::getIndex(m_core->pluginList()->origin(fileName)) == UINT_MAX) { + return false; + } + + auto modIndex = ModInfo::getIndex(m_core->pluginList()->origin(fileName)); + ModListViewActions::openExplorer({m_core->modList()->index(modIndex, 0)}); + return true; + } + } else if (keyEvent->modifiers() == Qt::ControlModifier && + (sortColumn() == PluginList::COL_PRIORITY || + sortColumn() == PluginList::COL_MODINDEX) && + (keyEvent->key() == Qt::Key_Up || keyEvent->key() == Qt::Key_Down)) { + return moveSelection(keyEvent->key()); + } else if (keyEvent->key() == Qt::Key_Space) { + return toggleSelectionState(); + } + return QTreeView::event(event); + } + return QTreeView::event(event); +} diff --git a/src/src/prefixsetuprunner.cpp b/src/src/prefixsetuprunner.cpp index f064a1c..cd2fcf4 100644 --- a/src/src/prefixsetuprunner.cpp +++ b/src/src/prefixsetuprunner.cpp @@ -418,7 +418,7 @@ void PrefixSetupRunner::buildStepList() auto addStep = [this](const QString& id, const QString& name, std::function fn) { - m_steps.append({id, name, SetupStep::Pending, {}}); + m_steps.append({.id=id, .displayName=name, .status=SetupStep::Pending, .errorMessage={}}); m_stepFunctions.append(std::move(fn)); }; @@ -569,7 +569,7 @@ QProcess* PrefixSetupRunner::buildWrappedProcess( // Remove AppImage/Fluorine vars that can confuse Wine. for (const char* var : {"QT_QPA_PLATFORM_PLUGIN_PATH", "MO2_PLUGINS_DIR", - "MO2_DLLS_DIR", "MO2_PYTHON_DIR", "MO2_BASE_DIR", + "MO2_LIBS_DIR", "MO2_PYTHON_DIR", "MO2_BASE_DIR", "APPIMAGE", "APPDIR", "OWD", "ARGV0", "APPIMAGE_ORIGINAL_EXEC", "DESKTOPINTEGRATION"}) { env.remove(var); @@ -1099,17 +1099,17 @@ bool PrefixSetupRunner::stepDirectXRuntime() // All single-DLL extractions: {cabFilter, dllFilter, displayName} struct DllEntry { const char* cabFilter; const char* dllFilter; const char* name; }; static const DllEntry singleDlls[] = { - {"*d3dcompiler_42*", "d3dcompiler_42.dll", "d3dcompiler_42"}, - {"*d3dcompiler_43*", "d3dcompiler_43.dll", "d3dcompiler_43"}, - {"*d3dx11_42*", "d3dx11_42.dll", "d3dx11_42"}, - {"*d3dx11_43*", "d3dx11_43.dll", "d3dx11_43"}, + {.cabFilter="*d3dcompiler_42*", .dllFilter="d3dcompiler_42.dll", .name="d3dcompiler_42"}, + {.cabFilter="*d3dcompiler_43*", .dllFilter="d3dcompiler_43.dll", .name="d3dcompiler_43"}, + {.cabFilter="*d3dx11_42*", .dllFilter="d3dx11_42.dll", .name="d3dx11_42"}, + {.cabFilter="*d3dx11_43*", .dllFilter="d3dx11_43.dll", .name="d3dx11_43"}, }; // Multi-DLL extractions (wildcards): {cabFilter, dllFilter, displayName} static const DllEntry multiDlls[] = { - {"*d3dx9*", "d3dx9*.dll", "d3dx9"}, - {"*d3dx10*", "d3dx10*.dll", "d3dx10"}, - {"*_xinput_*", "xinput*.dll", "xinput"}, + {.cabFilter="*d3dx9*", .dllFilter="d3dx9*.dll", .name="d3dx9"}, + {.cabFilter="*d3dx10*", .dllFilter="d3dx10*.dll", .name="d3dx10"}, + {.cabFilter="*_xinput_*", .dllFilter="xinput*.dll", .name="xinput"}, }; // Extract single DLLs (both arches). @@ -1262,9 +1262,9 @@ bool PrefixSetupRunner::stepDotNetRuntimes() struct RuntimePair { const char* url32; const char* url64; const char* name; }; static const RuntimePair runtimes[] = { - {DOTNET6_X86_URL, DOTNET6_X64_URL, ".NET 6"}, - {DOTNET7_X86_URL, DOTNET7_X64_URL, ".NET 7"}, - {DOTNET8_X86_URL, DOTNET8_X64_URL, ".NET 8"}, + {.url32=DOTNET6_X86_URL, .url64=DOTNET6_X64_URL, .name=".NET 6"}, + {.url32=DOTNET7_X86_URL, .url64=DOTNET7_X64_URL, .name=".NET 7"}, + {.url32=DOTNET8_X86_URL, .url64=DOTNET8_X64_URL, .name=".NET 8"}, }; for (const auto& rt : runtimes) { @@ -1726,7 +1726,7 @@ QString PrefixSetupRunner::findProtonScript() const return QFileInfo::exists(script) ? script : QString(); } -QString PrefixSetupRunner::detectSteamPath() +QString PrefixSetupRunner::detectSteamPath() { // Use native Steam detection first. const QString steamPath = findSteamPath(); @@ -1776,7 +1776,7 @@ QString PrefixSetupRunner::detectSLRRunScript() const return {}; } -QString PrefixSetupRunner::fluorineBinDir() +QString PrefixSetupRunner::fluorineBinDir() { return fluorineDataDir() + "/bin"; } @@ -1861,12 +1861,12 @@ void PrefixSetupRunner::killStalePrefixProcesses() const QThread::msleep(100); } -QString PrefixSetupRunner::fluorineCacheDir() +QString PrefixSetupRunner::fluorineCacheDir() { return fluorineDataDir() + "/cache"; } -QString PrefixSetupRunner::fluorineTmpDir() +QString PrefixSetupRunner::fluorineTmpDir() { return fluorineDataDir() + "/tmp"; } diff --git a/src/src/previewgenerator.cpp b/src/src/previewgenerator.cpp index 239bd57..33c0797 100644 --- a/src/src/previewgenerator.cpp +++ b/src/src/previewgenerator.cpp @@ -1,78 +1,78 @@ -/* -Copyright (C) 2014 Sebastian Herbord. All rights reserved. - -This file is part of Mod Organizer. - -Mod Organizer is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -Mod Organizer is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with Mod Organizer. If not, see . -*/ - -#include "previewgenerator.h" - -#include -#include -#include -#include -#include - -#include "plugincontainer.h" - -using namespace MOBase; - -PreviewGenerator::PreviewGenerator(const PluginContainer& pluginContainer) - : m_PluginContainer(pluginContainer) -{ - m_MaxSize = QGuiApplication::primaryScreen()->size() * 0.8; -} - -bool PreviewGenerator::previewSupported(const QString& fileExtension, - const bool& isArchive) const -{ - auto& previews = m_PluginContainer.plugins(); - for (auto* preview : previews) { - if (preview->supportedExtensions().contains(fileExtension)) { - if (!isArchive) - return true; - if (preview->supportsArchives()) - return true; - } - } - return false; -} - -QWidget* PreviewGenerator::genPreview(const QString& fileName) const -{ - const QString ext = QFileInfo(fileName).suffix().toLower(); - auto& previews = m_PluginContainer.plugins(); - for (auto* preview : previews) { - if (m_PluginContainer.isEnabled(preview) && - preview->supportedExtensions().contains(ext)) { - return preview->genFilePreview(fileName, m_MaxSize); - } - } - return nullptr; -} - -QWidget* PreviewGenerator::genArchivePreview(const QByteArray& fileData, - const QString& fileName) const -{ - const QString ext = QFileInfo(fileName).suffix().toLower(); - auto& previews = m_PluginContainer.plugins(); - for (auto* preview : previews) { - if (m_PluginContainer.isEnabled(preview) && - preview->supportedExtensions().contains(ext) && preview->supportsArchives()) { - return preview->genDataPreview(fileData, fileName, m_MaxSize); - } - } - return nullptr; -} +/* +Copyright (C) 2014 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "previewgenerator.h" + +#include +#include +#include +#include +#include + +#include "plugincontainer.h" + +using namespace MOBase; + +PreviewGenerator::PreviewGenerator(const PluginContainer& pluginContainer) + : m_PluginContainer(pluginContainer) +{ + m_MaxSize = QGuiApplication::primaryScreen()->size() * 0.8; +} + +bool PreviewGenerator::previewSupported(const QString& fileExtension, + const bool& isArchive) const +{ + const auto& previews = m_PluginContainer.plugins(); + for (auto* preview : previews) { + if (preview->supportedExtensions().contains(fileExtension)) { + if (!isArchive) + return true; + if (preview->supportsArchives()) + return true; + } + } + return false; +} + +QWidget* PreviewGenerator::genPreview(const QString& fileName) const +{ + const QString ext = QFileInfo(fileName).suffix().toLower(); + const auto& previews = m_PluginContainer.plugins(); + for (auto* preview : previews) { + if (m_PluginContainer.isEnabled(preview) && + preview->supportedExtensions().contains(ext)) { + return preview->genFilePreview(fileName, m_MaxSize); + } + } + return nullptr; +} + +QWidget* PreviewGenerator::genArchivePreview(const QByteArray& fileData, + const QString& fileName) const +{ + const QString ext = QFileInfo(fileName).suffix().toLower(); + const auto& previews = m_PluginContainer.plugins(); + for (auto* preview : previews) { + if (m_PluginContainer.isEnabled(preview) && + preview->supportedExtensions().contains(ext) && preview->supportsArchives()) { + return preview->genDataPreview(fileData, fileName, m_MaxSize); + } + } + return nullptr; +} diff --git a/src/src/processrunner.cpp b/src/src/processrunner.cpp index 3e585e6..e5430b1 100644 --- a/src/src/processrunner.cpp +++ b/src/src/processrunner.cpp @@ -23,7 +23,7 @@ #include #include #include -#include +#include #include #include #include @@ -728,7 +728,7 @@ ProcessRunner::Results waitForPid(pid_t pid, LPDWORD exitCode, } if (ls != nullptr) { - switch (ls->result()) { + switch (UILocker::Session::result()) { case UILocker::StillLocked: break; @@ -787,8 +787,8 @@ ProcessRunner::Results waitForProcesses(const std::vector& initialProces } ProcessRunner::ProcessRunner(OrganizerCore& core, IUserInterface* ui) - : m_core(core), m_ui(ui), - m_waitFlags(NoFlags), m_handle(INVALID_HANDLE_VALUE) + : m_core(core), m_ui(ui), + m_waitFlags(NoFlags), m_handle(INVALID_HANDLE_VALUE) { // all processes started in ProcessRunner are hooked by default setHooked(true); @@ -1321,7 +1321,7 @@ pid_t ProcessRunner::getProcessHandle() const env::HandlePtr ProcessRunner::stealProcessHandle() { - auto h = m_handle.release(); + auto *h = m_handle.release(); m_handle.reset(INVALID_HANDLE_VALUE); return env::HandlePtr(h); } diff --git a/src/src/profile.cpp b/src/src/profile.cpp index 299bae4..a8d8282 100644 --- a/src/src/profile.cpp +++ b/src/src/profile.cpp @@ -1,1276 +1,1279 @@ -/* -Copyright (C) 2012 Sebastian Herbord. All rights reserved. - -This file is part of Mod Organizer. - -Mod Organizer is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -Mod Organizer is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with Mod Organizer. If not, see . -*/ - -#include "profile.h" - -#include -#include "game_features.h" -#include "modinfo.h" -#include "modinfoforeign.h" -#include "registry.h" -#include "settings.h" -#include "shared/appconfig.h" -#include "shared/util.h" -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include // for QFile -#include // for operator|, QFlags -#include // for QIODevice, etc -#include -#include -#include // for QStringList -#include // for qUtf8Printable - -#include // for assert -#include // for UINT_MAX, INT_MAX, etc -#include // for size_t -#include // for wcslen - -#include // for max, min -#include // for exception -#include -#include // for set -#include -#include // for find - -using namespace MOBase; -using namespace MOShared; - -// MOBase::resolveFileCaseInsensitive moved to MOBase::resolveFileCaseInsensitive - -void Profile::touchFile(QString fileName) -{ - QFile modList(m_Directory.filePath(fileName)); - if (!modList.open(QIODevice::ReadWrite)) { - throw std::runtime_error(QObject::tr("failed to create %1") - .arg(m_Directory.filePath(fileName)) - .toUtf8() - .constData()); - } -} - -Profile::Profile(const QString& name, IPluginGame const* gamePlugin, - GameFeatures const& gameFeatures, bool useDefaultSettings) - : m_ModListWriter(std::bind(&Profile::doWriteModlist, this)), - m_GamePlugin(gamePlugin), m_GameFeatures(gameFeatures) -{ - QString profilesDir = Settings::instance().paths().profiles(); - QDir profileBase(profilesDir); - QString fixedName = name; - if (!fixDirectoryName(fixedName)) { - throw MyException(tr("invalid profile name: %1").arg(qUtf8Printable(name))); - } - - if (!profileBase.exists() || !profileBase.mkdir(fixedName)) { - throw MyException(tr("failed to create %1").arg(fixedName).toUtf8().constData()); - } - QString fullPath = profilesDir + "/" + fixedName; - m_Directory = QDir(fullPath); - m_Settings = - new QSettings(m_Directory.absoluteFilePath("settings.ini"), QSettings::IniFormat); - - try { - // create files. Needs to happen after m_Directory was set! - touchFile("modlist.txt"); - touchFile("archives.txt"); - - IPluginGame::ProfileSettings settings = - IPluginGame::CONFIGURATION | IPluginGame::MODS | IPluginGame::SAVEGAMES; - - if (useDefaultSettings) { - settings |= IPluginGame::PREFER_DEFAULTS; - } - - gamePlugin->initializeProfile(fullPath, settings); - findProfileSettings(); - } catch (...) { - // clean up in case of an error - shellDelete(QStringList(profileBase.absoluteFilePath(fixedName))); - throw; - } - refreshModStatus(); -} - -Profile::Profile(const QDir& directory, IPluginGame const* gamePlugin, - GameFeatures const& gameFeatures) - : m_Directory(directory), m_GamePlugin(gamePlugin), m_GameFeatures(gameFeatures), - m_ModListWriter(std::bind(&Profile::doWriteModlist, this)) -{ - assert(gamePlugin != nullptr); - - m_Settings = - new QSettings(directory.absoluteFilePath("settings.ini"), QSettings::IniFormat); - findProfileSettings(); - - if (!QFile::exists(m_Directory.filePath("modlist.txt"))) { - log::warn("missing modlist.txt in {}", directory.path()); - touchFile(m_Directory.filePath("modlist.txt")); - } - - IPluginGame::ProfileSettings settings = IPluginGame::MODS | IPluginGame::SAVEGAMES; - gamePlugin->initializeProfile(directory, settings); - - refreshModStatus(); -} - -Profile::Profile(const Profile& reference) - : m_Directory(reference.m_Directory), - m_ModListWriter(std::bind(&Profile::doWriteModlist, this)), - m_GamePlugin(reference.m_GamePlugin), m_GameFeatures(reference.m_GameFeatures) - -{ - m_Settings = - new QSettings(m_Directory.absoluteFilePath("settings.ini"), QSettings::IniFormat); - findProfileSettings(); - refreshModStatus(); -} - -Profile::~Profile() -{ - delete m_Settings; - m_ModListWriter.writeImmediately(true); -} - -void Profile::findProfileSettings() -{ - if (setting("", "LocalSaves") == QVariant()) { - if (m_Directory.exists("saves")) { - if (!Settings::instance().profileLocalSaves()) { - m_Directory.rename("saves", "_saves"); - storeSetting("", "LocalSaves", false); - } else { - storeSetting("", "LocalSaves", true); - } - } else { - if (m_Directory.exists("_saves")) { - if (Settings::instance().profileLocalSaves()) { - m_Directory.rename("_saves", "saves"); - storeSetting("", "LocalSaves", true); - } else { - storeSetting("", "LocalSaves", false); - } - } else { - storeSetting("", "LocalSaves", Settings::instance().profileLocalSaves()); - } - } - } - - if (setting("", "LocalSettings") == QVariant()) { - QString backupFile = getIniFileName() + "_"; - if (m_Directory.exists(backupFile)) { - storeSetting("", "LocalSettings", true); - m_Directory.rename(backupFile, getIniFileName()); - } else if (Settings::instance().profileLocalInis()) { - storeSetting("", "LocalSettings", true); - enableLocalSettings(true); - } else { - storeSetting("", "LocalSettings", false); - } - } - - if (setting("", "AutomaticArchiveInvalidation") == QVariant()) { - auto invalidation = m_GameFeatures.gameFeature(); - auto dataArchives = m_GameFeatures.gameFeature(); - bool found = false; - if ((invalidation != nullptr) && (dataArchives != nullptr)) { - for (const QString& archive : dataArchives->archives(this)) { - if (invalidation->isInvalidationBSA(archive)) { - found = true; - break; - } - } - } - if (found) { - if (!Settings::instance().profileArchiveInvalidation()) { - deactivateInvalidation(); - } else { - storeSetting("", "AutomaticArchiveInvalidation", true); - } - } else { - if (Settings::instance().profileArchiveInvalidation()) { - activateInvalidation(); - } else { - storeSetting("", "AutomaticArchiveInvalidation", false); - } - } - } -} - -bool Profile::exists() const -{ - return m_Directory.exists(); -} - -void Profile::writeModlist() -{ - m_ModListWriter.write(); -} - -void Profile::writeModlistNow(bool onlyIfPending) -{ - m_ModListWriter.writeImmediately(onlyIfPending); -} - -void Profile::cancelModlistWrite() -{ - m_ModListWriter.cancel(); -} - -void Profile::doWriteModlist() -{ - if (!m_Directory.exists()) - return; - - try { - QString fileName = getModlistFileName(); - SafeWriteFile file(fileName); - - file->write(QString("# This file was automatically generated by Mod Organizer.\r\n") - .toUtf8()); - if (m_ModStatus.empty()) { - return; - } - - for (auto iter = m_ModIndexByPriority.crbegin(); - iter != m_ModIndexByPriority.crend(); iter++) { - // the priority order was inverted on load so it has to be inverted again - const auto index = iter->second; - ModInfo::Ptr modInfo = ModInfo::getByIndex(index); - if (!modInfo->hasAutomaticPriority()) { - if (modInfo->isForeign()) { - file->write("*"); - } else if (m_ModStatus[index].m_Enabled) { - file->write("+"); - } else { - file->write("-"); - } - file->write(modInfo->name().toUtf8()); - file->write("\r\n"); - } - } - - file->commit(); - } catch (const std::exception& e) { - reportError(tr("failed to write mod list: %1").arg(e.what())); - return; - } -} - -void Profile::createTweakedIniFile() -{ - QString tweakedIni = m_Directory.absoluteFilePath("initweaks.ini"); - - if (QFile::exists(tweakedIni) && !shellDeleteQuiet(tweakedIni)) { - const auto e = GetLastError(); - reportError(tr("failed to update tweaked ini file, wrong settings may be used: %1") - .arg(QString::fromStdWString(formatSystemMessage(e)))); - return; - } - - for (const auto& [priority, index] : m_ModIndexByPriority) { - if (m_ModStatus[index].m_Enabled) { - ModInfo::Ptr modInfo = ModInfo::getByIndex(index); - mergeTweaks(modInfo, tweakedIni); - } - } - - mergeTweak(getProfileTweaks(), tweakedIni); - - bool error = false; - if (!MOBase::WriteRegistryValue( - QStringLiteral("Archive"), QStringLiteral("bInvalidateOlderFiles"), - QStringLiteral("1"), tweakedIni)) { - error = true; - } - - if (error) { - const auto e = ::GetLastError(); - reportError(tr("failed to create tweaked ini: %1") - .arg(QString::fromStdWString(formatSystemMessage(e)))); - } -} - -// static -void Profile::renameModInAllProfiles(const QString& oldName, const QString& newName) -{ - QDir profilesDir(Settings::instance().paths().profiles()); - profilesDir.setFilter(QDir::AllDirs | QDir::NoDotAndDotDot); - QDirIterator profileIter(profilesDir); - while (profileIter.hasNext()) { - profileIter.next(); - QFile modList(profileIter.filePath() + "/modlist.txt"); - if (modList.exists()) - renameModInList(modList, oldName, newName); - else - log::warn("Profile has no modlist.txt: {}", profileIter.filePath()); - } -} - -// static -void Profile::renameModInList(QFile& modList, const QString& oldName, - const QString& newName) -{ - if (!modList.open(QIODevice::ReadOnly)) { - reportError(tr("failed to open %1").arg(modList.fileName())); - return; - } - - QBuffer outBuffer; - outBuffer.open(QIODevice::WriteOnly); - - int renamed = 0; - while (!modList.atEnd()) { - QByteArray line = modList.readLine(); - - if (line.length() == 0) { - // ignore empty lines - log::warn("mod list contained invalid data: empty line"); - continue; - } - - char spec = line.at(0); - if (spec == '#') { - // don't touch comments - outBuffer.write(line); - continue; - } - - QString modName = QString::fromUtf8(line).mid(1).trimmed(); - - if (modName.isEmpty()) { - // file broken? - log::warn("mod list contained invalid data: missing mod name"); - continue; - } - - outBuffer.write(QByteArray(1, spec)); - if (modName == oldName) { - modName = newName; - ++renamed; - } - outBuffer.write(qUtf8Printable(modName)); - outBuffer.write("\r\n"); - } - modList.close(); - - if (renamed) { - modList.open(QIODevice::WriteOnly); - modList.write(outBuffer.buffer()); - modList.close(); - } - - if (renamed) - log::debug(R"(Renamed {} "{}" mod to "{}" in {})", renamed, oldName, newName, - modList.fileName()); -} - -void Profile::refreshModStatus() -{ - // this function refreshes mod status (enabled/disabled) and priority - // using the profile mod list file and the mods in the mods folder using - // the following steps - // - // 1) the mod list file is read and mods status/priority are updated by - // considering the content of the file (for status) and the order (for - // priority), missing or invalid mods are discarded (with a warning) - // 2) the priority are reversed to match the plugin list (highest wins) - // since the mod list is written in reverse order - // 3) at the same time, new mods (not in the mod list file) are added - // - foreign mods are given low priority (below 0) - // - regular mods are given high priority (above mods from the mod list) - // 4) the priority are shifted to ensure that the minimum priority is 0 - // 5) the priority of backups are computed such that the first backup is - // above all regular mods - // - // in the context of the profile, "regular mods" means a mod whose priority - // can be set by the user (i.e. not a backup or overwrite) - // - // this method ensures that the mods priority is as follow - // - // 0 mod1 - // 1 mod2 - // ... - // K-1 modK (K = m_NumRegularMods) - // K backup1 - // K+1 backup2 - // ... - // N-2 backupX - // N-1 overwrite (N = number of mods) - // - - writeModlistNow(true); // if there are pending changes write them first - - QFile file(getModlistFileName()); - if (!file.open(QIODevice::ReadOnly)) { - throw MyException( - tr("\"%1\" is missing or inaccessible").arg(getModlistFileName())); - } - - bool modStatusModified = false; - m_ModStatus.clear(); - m_ModStatus.resize(ModInfo::getNumMods()); - log::debug("refreshModStatus: ModInfo has {} entries", ModInfo::getNumMods()); - - std::set namesRead; - - bool warnAboutOverwrite = false; - unsigned int modsNotFound = 0; - - // load mods from file and update enabled state and priority for them - int index = 0; - while (!file.atEnd()) { - QByteArray line = file.readLine().trimmed(); - - // find the mod name and the enabled status - bool enabled = true; - QString modName; - if (line.length() == 0) { - // empty line - continue; - } else if (line.at(0) == '#') { - // comment line - continue; - } else if (line.at(0) == '-') { - enabled = false; - modName = QString::fromUtf8(line.mid(1).trimmed().constData()); - } else if (line.at(0) == '+' || line.at(0) == '*') { - modName = QString::fromUtf8(line.mid(1).trimmed().constData()); - } else { - modName = QString::fromUtf8(line.trimmed().constData()); - } - - if (modName.isEmpty()) { - continue; - } - - if (modName.compare("overwrite", Qt::CaseInsensitive) == 0) { - warnAboutOverwrite = true; - } - - // check if the name was already read - if (namesRead.contains(modName)) { - continue; - } - namesRead.insert(modName); - - unsigned int modIndex = ModInfo::getIndex(modName); - if (modIndex == UINT_MAX) { - if (modsNotFound < 5) { - log::warn(R"(mod not found: "{}" (profile "{}"))", modName, m_Directory.path()); - } - ++modsNotFound; - // need to rewrite the modlist to fix this - modStatusModified = true; - continue; - } - - // find the mod and check that this is a regular mod (and not a backup) - ModInfo::Ptr info = ModInfo::getByIndex(modIndex); - if (modIndex < m_ModStatus.size() && !info->hasAutomaticPriority()) { - m_ModStatus[modIndex].m_Enabled = enabled; - if (m_ModStatus[modIndex].m_Priority == -1) { - if (static_cast(index) >= m_ModStatus.size()) { - throw Exception(tr("invalid mod index: %1").arg(index)); - } - m_ModStatus[modIndex].m_Priority = index++; - } - } else { - log::warn(R"(no mod state for "{}" (profile "{}"))", modName, - m_Directory.path()); - // need to rewrite the modlist to fix this - modStatusModified = true; - } - - } // while (!file.atEnd()) - - file.close(); - - if (modsNotFound > 0) { - log::error("refreshModStatus: {} mods from modlist.txt were not found in " - "ModInfo (total ModInfo entries: {})", - modsNotFound, ModInfo::getNumMods()); - } - - const int numKnownMods = index; - int topInsert = 0; - - // invert priority order to match that of the pluginlist, also - // give priorities to mods not referenced in the profile and - // count the number of regular mods - m_NumRegularMods = 0; - for (size_t i = 0; i < m_ModStatus.size(); ++i) { - ModInfo::Ptr modInfo = ModInfo::getByIndex(static_cast(i)); - if (modInfo->alwaysEnabled()) { - m_ModStatus[i].m_Enabled = true; - } - - if (modInfo->isOverwrite()) { - m_ModStatus[i].m_Priority = m_ModStatus.size() - 1; - continue; - } - - if (m_ModStatus[i].m_Priority != -1) { - m_ModStatus[i].m_Priority = numKnownMods - m_ModStatus[i].m_Priority - 1; - ++m_NumRegularMods; - } else { - if (static_cast(index) >= m_ModStatus.size()) { - throw Exception(tr("invalid mod index: %1").arg(index)); - } - - // skip backups on purpose to avoid inserting backups in-between - // regular mods - if (modInfo->isForeign()) { - m_ModStatus[i].m_Priority = --topInsert; - ++m_NumRegularMods; - } else if (!modInfo->isBackup()) { - m_ModStatus[i].m_Priority = index++; - ++m_NumRegularMods; - } - - // also, mark the mod-list as changed - modStatusModified = true; - } - } - - // to support insertion of new mods at the top we may now have mods with negative - // priority, so shift them all up to align priority with 0 - if (topInsert < 0) { - int offset = topInsert * -1; - for (size_t i = 0; i < m_ModStatus.size(); ++i) { - ModInfo::Ptr modInfo = ModInfo::getByIndex(static_cast(i)); - if (modInfo->hasAutomaticPriority()) { - continue; - } - - m_ModStatus[i].m_Priority += offset; - } - } - - // set the backups priority - int backupPriority = m_NumRegularMods; - for (size_t i = 0; i < m_ModStatus.size(); ++i) { - ModInfo::Ptr modInfo = ModInfo::getByIndex(static_cast(i)); - if (modInfo->isBackup()) { - m_ModStatus[i].m_Priority = backupPriority++; - } - } - - updateIndices(); - - // User has a mod named some variation of "overwrite". Tell them about it. - if (warnAboutOverwrite) { - reportError(tr("A mod named \"overwrite\" was detected, disabled, and moved to the " - "highest priority on the mod list. " - "You may want to rename this mod and enable it again.")); - // also, mark the mod-list as changed - modStatusModified = true; - } - - if (modStatusModified) { - m_ModListWriter.write(); - } -} - -void Profile::dumpModStatus() const -{ - for (unsigned int i = 0; i < m_ModStatus.size(); ++i) { - ModInfo::Ptr info = ModInfo::getByIndex(i); - log::warn("{}: {} - {} ({})", i, info->name(), m_ModStatus[i].m_Priority, - m_ModStatus[i].m_Enabled ? "enabled" : "disabled"); - } -} - -void Profile::updateIndices() -{ - m_ModIndexByPriority.clear(); - for (unsigned int i = 0; i < m_ModStatus.size(); ++i) { - int priority = m_ModStatus[i].m_Priority; - m_ModIndexByPriority[priority] = i; - } -} - -std::vector> Profile::getActiveMods() -{ - std::vector> result; - for (const auto& [priority, index] : m_ModIndexByPriority) { - if (m_ModStatus[index].m_Enabled) { - ModInfo::Ptr modInfo = ModInfo::getByIndex(index); - result.push_back(std::make_tuple(modInfo->internalName(), modInfo->absolutePath(), - m_ModStatus[index].m_Priority)); - } - } - - return result; -} - -void Profile::setModEnabled(unsigned int index, bool enabled) -{ - if (index >= m_ModStatus.size()) { - throw MyException(tr("invalid mod index: %1").arg(index)); - } - - ModInfo::Ptr modInfo = ModInfo::getByIndex(index); - - // we could quit in the following case, this shouldn't be a change anyway, - // but at least this allows the situation to be fixed in case of an error - if (modInfo->alwaysEnabled()) { - enabled = true; - } - if (modInfo->alwaysDisabled()) { - enabled = false; - } - - if (enabled != m_ModStatus[index].m_Enabled) { - m_ModStatus[index].m_Enabled = enabled; - emit modStatusChanged(index); - } -} - -void Profile::setModsEnabled(const QList& modsToEnable, - const QList& modsToDisable) -{ - QList dirtyMods; - for (auto idx : modsToEnable) { - if (idx >= m_ModStatus.size()) { - log::error("invalid mod index: {}", idx); - continue; - } - if (ModInfo::getByIndex(idx)->alwaysDisabled()) { - continue; - } - if (!m_ModStatus[idx].m_Enabled) { - m_ModStatus[idx].m_Enabled = true; - dirtyMods.append(idx); - } - } - for (auto idx : modsToDisable) { - if (idx >= m_ModStatus.size()) { - log::error("invalid mod index: {}", idx); - continue; - } - if (ModInfo::getByIndex(idx)->alwaysEnabled()) { - continue; - } - if (m_ModStatus[idx].m_Enabled) { - m_ModStatus[idx].m_Enabled = false; - dirtyMods.append(idx); - } - } - if (!dirtyMods.isEmpty()) { - emit modStatusChanged(dirtyMods); - } -} - -bool Profile::modEnabled(unsigned int index) const -{ - if (index >= m_ModStatus.size()) { - throw MyException(tr("invalid mod index: %1").arg(index)); - } - - return m_ModStatus[index].m_Enabled; -} - -int Profile::getModPriority(unsigned int index) const -{ - if (index >= m_ModStatus.size()) { - throw MyException(tr("invalid mod index: %1").arg(index)); - } - - return m_ModStatus[index].m_Priority; -} - -bool Profile::setModPriority(unsigned int index, int& newPriority) -{ - if (ModInfo::getByIndex(index)->hasAutomaticPriority()) { - // can't change priority of overwrite/backups - return false; - } - - newPriority = std::clamp(newPriority, 0, static_cast(m_NumRegularMods) - 1); - - int oldPriority = m_ModStatus.at(index).m_Priority; - int lastPriority = INT_MIN; - - if (newPriority == oldPriority) { - // nothing to do - return false; - } - - for (const auto& [priority, index] : m_ModIndexByPriority) { - if (newPriority < oldPriority && priority >= newPriority && - priority < oldPriority) { - m_ModStatus.at(index).m_Priority += 1; - } else if (newPriority > oldPriority && priority <= newPriority && - priority > oldPriority) { - m_ModStatus.at(index).m_Priority -= 1; - } - lastPriority = std::max(lastPriority, priority); - } - - newPriority = std::min(newPriority, lastPriority); - m_ModStatus.at(index).m_Priority = std::min(newPriority, lastPriority); - - updateIndices(); - m_ModListWriter.write(); - - return true; -} - -Profile* Profile::createPtrFrom(const QString& name, const Profile& reference, - MOBase::IPluginGame const* gamePlugin) -{ - QString profileDirectory = Settings::instance().paths().profiles() + "/" + name; - reference.copyFilesTo(profileDirectory); - return new Profile(QDir(profileDirectory), gamePlugin, reference.m_GameFeatures); -} - -void Profile::copyFilesTo(QString& target) const -{ - copyDir(m_Directory.absolutePath(), target, false); -} - -std::vector Profile::splitDZString(const wchar_t* buffer) -{ - std::vector result; - const wchar_t* pos = buffer; - size_t length = wcslen(pos); - while (length != 0U) { - result.push_back(pos); - pos += length + 1; - length = wcslen(pos); - } - return result; -} - -void Profile::mergeTweak(const QString& tweakName, const QString& tweakedIni) -{ - // Parse the tweak INI file line-by-line and merge each key=value into the - // destination using WriteRegistryValue (which uses the safe line-by-line - // writer that does NOT interpret backslashes as line continuations or - // URL-encode spaces in keys). - QFile sourceFile(tweakName); - if (!sourceFile.open(QIODevice::ReadOnly | QIODevice::Text)) { - if (QFileInfo::exists(tweakName)) { - log::warn("mergeTweak: tweak file '{}' exists but could not be opened", tweakName); - } else { - log::debug("mergeTweak: tweak file '{}' does not exist, skipping", tweakName); - } - return; - } - - QString currentSection; - QTextStream stream(&sourceFile); - while (!stream.atEnd()) { - QString line = stream.readLine().trimmed(); - if (line.isEmpty() || line.startsWith(';') || line.startsWith('#')) { - continue; - } - if (line.startsWith('[') && line.endsWith(']')) { - currentSection = line.mid(1, line.length() - 2).trimmed(); - continue; - } - const int eqPos = line.indexOf('='); - if (eqPos > 0 && !currentSection.isEmpty()) { - const QString key = line.left(eqPos).trimmed(); - const QString value = line.mid(eqPos + 1).trimmed(); - MOBase::WriteRegistryValue(currentSection, key, value, tweakedIni); - } - } -} - -void Profile::mergeTweaks(ModInfo::Ptr modInfo, const QString& tweakedIni) const -{ - std::vector iniTweaks = modInfo->getIniTweaks(); - for (std::vector::iterator iter = iniTweaks.begin(); iter != iniTweaks.end(); - ++iter) { - mergeTweak(*iter, tweakedIni); - } -} - -bool Profile::invalidationActive(bool* supported) const -{ - auto invalidation = m_GameFeatures.gameFeature(); - auto dataArchives = m_GameFeatures.gameFeature(); - - if (supported != nullptr) { - *supported = ((invalidation != nullptr) && (dataArchives != nullptr)); - } - - return setting("", "AutomaticArchiveInvalidation", - Settings::instance().profileArchiveInvalidation()) - .toBool(); -} - -void Profile::deactivateInvalidation() -{ - auto invalidation = m_GameFeatures.gameFeature(); - - if (invalidation != nullptr) { - invalidation->deactivate(this); - } - - storeSetting("", "AutomaticArchiveInvalidation", false); -} - -void Profile::activateInvalidation() -{ - auto invalidation = m_GameFeatures.gameFeature(); - - if (invalidation != nullptr) { - invalidation->activate(this); - } - - storeSetting("", "AutomaticArchiveInvalidation", true); -} - -bool Profile::localSavesEnabled() const -{ - return setting("", "LocalSaves", Settings::instance().profileLocalSaves()).toBool(); -} - -bool Profile::enableLocalSaves(bool enable) -{ - if (enable) { - if (!m_Directory.exists("saves")) { - m_Directory.mkdir("saves"); - } - } else { - QDialogButtonBox::StandardButton res; - res = QuestionBoxMemory::query( - QApplication::activeModalWidget(), "deleteSavesQuery", - tr("Delete profile-specific save games?"), - tr("Do you want to delete the profile-specific save games? (If you select " - "\"No\", the " - "save games will show up again if you re-enable profile-specific save " - "games)"), - QDialogButtonBox::No | QDialogButtonBox::Yes | QDialogButtonBox::Cancel, - QDialogButtonBox::No); - if (res == QMessageBox::Yes) { - shellDelete(QStringList(m_Directory.absoluteFilePath("saves"))); - } else if (res == QMessageBox::No) { - // No action - } else { - return false; - } - } - storeSetting("", "LocalSaves", enable); - return true; -} - -bool Profile::localSettingsEnabled() const -{ - bool enabled = - setting("", "LocalSettings", Settings::instance().profileLocalInis()).toBool(); - if (enabled) { - QStringList missingFiles; - for (QString file : m_GamePlugin->iniFiles()) { - QString fileName = QFileInfo(file).fileName(); - // Use case-insensitive lookup on Linux — the file may exist with - // different casing (e.g. "skyrimprefs.ini" vs "SkyrimPrefs.ini"). - QString resolved = MOBase::resolveFileCaseInsensitive( - m_Directory.filePath(fileName)); - if (!QFile::exists(resolved)) { - log::warn("missing {} in {}", fileName, m_Directory.path()); - missingFiles << fileName; - } - } - if (!missingFiles.empty()) { - m_GamePlugin->initializeProfile(m_Directory, IPluginGame::CONFIGURATION); - QMessageBox::StandardButton res = - QMessageBox::warning(QApplication::activeModalWidget(), - tr("Missing profile-specific game INI files!"), - tr("Some of your profile-specific game INI files were " - "missing. They will now be copied " - "from the vanilla game folder. You might want to " - "double-check your settings.\n\n" - "Missing files:\n") + - missingFiles.join("\n")); - } - } - return enabled; -} - -bool Profile::enableLocalSettings(bool enable) -{ - if (enable) { - m_GamePlugin->initializeProfile(m_Directory.absolutePath(), - IPluginGame::CONFIGURATION); - } else { - QDialogButtonBox::StandardButton res; - res = QuestionBoxMemory::query(QApplication::activeModalWidget(), "deleteINIQuery", - tr("Delete profile-specific game INI files?"), - tr("Do you want to delete the profile-specific game " - "INI files? (If you select \"No\", the " - "INI files will be used again if you re-enable " - "profile-specific game INI files.)"), - QDialogButtonBox::No | QDialogButtonBox::Yes | - QDialogButtonBox::Cancel, - QDialogButtonBox::No); - if (res == QMessageBox::Yes) { - QStringList filesToDelete; - for (QString file : m_GamePlugin->iniFiles()) { - QString resolved = MOBase::resolveFileCaseInsensitive( - m_Directory.absoluteFilePath(QFileInfo(file).fileName())); - filesToDelete << resolved; - } - shellDelete(filesToDelete, true); - } else if (res == QMessageBox::No) { - // No action - } else { - return false; - } - } - storeSetting("", "LocalSettings", enable); - return true; -} - -QString Profile::getModlistFileName() const -{ - return QDir::cleanPath(m_Directory.absoluteFilePath("modlist.txt")); -} - -QString Profile::getPluginsFileName() const -{ - return QDir::cleanPath(m_Directory.absoluteFilePath("plugins.txt")); -} - -QString Profile::getLoadOrderFileName() const -{ - return QDir::cleanPath(m_Directory.absoluteFilePath("loadorder.txt")); -} - -QString Profile::getLockedOrderFileName() const -{ - return QDir::cleanPath(m_Directory.absoluteFilePath("lockedorder.txt")); -} - -QString Profile::getArchivesFileName() const -{ - return QDir::cleanPath(m_Directory.absoluteFilePath("archives.txt")); -} - -QString Profile::getIniFileName() const -{ - auto iniFiles = m_GamePlugin->iniFiles(); - if (iniFiles.isEmpty()) - return ""; - else - return MOBase::resolveFileCaseInsensitive( - m_Directory.absoluteFilePath(QFileInfo(iniFiles[0]).fileName())); -} - -QString Profile::absoluteIniFilePath(QString iniFile) const -{ - // This is the file to which the given iniFile would be mapped, as - // an absolute file path: - QFileInfo targetIniFile(m_GamePlugin->documentsDirectory(), iniFile); - - bool isGameIni = false; - for (auto gameIni : m_GamePlugin->iniFiles()) { - // We compare the target file, not the actual ones: - if (QFileInfo(m_GamePlugin->documentsDirectory(), gameIni) == targetIniFile) { - isGameIni = true; - break; - } - } - - // Local-settings are not enabled, or the iniFile is not in the list of INI - // files for the current game. - if (!localSettingsEnabled() || !isGameIni) { - return MOBase::resolveFileCaseInsensitive(targetIniFile.absoluteFilePath()); - } - - // If we reach here, the file is in the profile: - return MOBase::resolveFileCaseInsensitive( - m_Directory.absoluteFilePath(targetIniFile.fileName())); -} - -QString Profile::getProfileTweaks() const -{ - return QDir::cleanPath( - m_Directory.absoluteFilePath(ToQString(AppConfig::profileTweakIni()))); -} - -QString Profile::absolutePath() const -{ - return QDir::cleanPath(m_Directory.absolutePath()); -} - -QString Profile::savePath() const -{ - return QDir::cleanPath(m_Directory.absoluteFilePath("saves")); -} - -void Profile::rename(const QString& newName) -{ - QDir profileDir(Settings::instance().paths().profiles()); - profileDir.rename(name(), newName); - m_Directory.setPath(profileDir.absoluteFilePath(newName)); -} - -QString keyName(const QString& section, const QString& name) -{ - QString key = section; - - if (!name.isEmpty()) { - if (!key.isEmpty()) { - key += "/"; - } - - key += name; - } - - return key; -} - -QVariant Profile::setting(const QString& section, const QString& name, - const QVariant& fallback) const -{ - return m_Settings->value(keyName(section, name), fallback); -} - -void Profile::storeSetting(const QString& section, const QString& name, - const QVariant& value) -{ - m_Settings->setValue(keyName(section, name), value); -} - -void Profile::removeSetting(const QString& section, const QString& name) -{ - m_Settings->remove(keyName(section, name)); -} - -QVariantMap Profile::settingsByGroup(const QString& section) const -{ - QVariantMap results; - m_Settings->beginGroup(section); - for (auto key : m_Settings->childKeys()) { - results[key] = m_Settings->value(key); - } - m_Settings->endGroup(); - return results; -} - -void Profile::storeSettingsByGroup(const QString& section, const QVariantMap& values) -{ - m_Settings->beginGroup(section); - for (auto key : values.keys()) { - m_Settings->setValue(key, values[key]); - } - m_Settings->endGroup(); -} - -QList Profile::settingsByArray(const QString& prefix) const -{ - QList results; - int size = m_Settings->beginReadArray(prefix); - for (int i = 0; i < size; i++) { - m_Settings->setArrayIndex(i); - QVariantMap item; - for (auto key : m_Settings->childKeys()) { - item[key] = m_Settings->value(key); - } - results.append(item); - } - m_Settings->endArray(); - return results; -} - -void Profile::storeSettingsByArray(const QString& prefix, - const QList& values) -{ - m_Settings->beginWriteArray(prefix); - for (int i = 0; i < values.length(); i++) { - m_Settings->setArrayIndex(i); - for (auto key : values.at(i).keys()) { - m_Settings->setValue(key, values.at(i)[key]); - } - } - m_Settings->endArray(); -} - -bool Profile::forcedLibrariesEnabled(const QString& executable) const -{ - return setting("forced_libraries", executable + "/enabled", true).toBool(); -} - -void Profile::setForcedLibrariesEnabled(const QString& executable, bool enabled) -{ - storeSetting("forced_libraries", executable + "/enabled", enabled); -} - -QList -Profile::determineForcedLibraries(const QString& executable) const -{ - QList results; - - auto rawSettings = settingsByArray("forced_libraries/" + executable); - auto forcedLoads = m_GamePlugin->executableForcedLoads(); - - // look for enabled status on forced loads and add those - for (auto forcedLoad : forcedLoads) { - bool found = false; - for (auto rawSetting : rawSettings) { - if ((rawSetting.value("process").toString().compare(forcedLoad.process(), - Qt::CaseInsensitive) == 0) && - (rawSetting.value("library").toString().compare(forcedLoad.library(), - Qt::CaseInsensitive) == 0)) { - results.append( - forcedLoad.withEnabled(rawSetting.value("enabled", false).toBool())); - found = true; - } - } - if (!found) { - results.append(forcedLoad); - } - } - - // add everything else - for (auto rawSetting : rawSettings) { - bool add = true; - for (auto forcedLoad : forcedLoads) { - if ((rawSetting.value("process").toString().compare(forcedLoad.process(), - Qt::CaseInsensitive) == 0) && - (rawSetting.value("library").toString().compare(forcedLoad.library(), - Qt::CaseInsensitive) == 0)) { - add = false; - } - } - if (add) { - results.append(ExecutableForcedLoadSetting(rawSetting.value("process").toString(), - rawSetting.value("library").toString()) - .withEnabled(rawSetting.value("enabled", false).toBool())); - } - } - - return results; -} - -void Profile::storeForcedLibraries(const QString& executable, - const QList& values) -{ - QList rawSettings; - for (auto setting : values) { - QVariantMap rawSetting; - rawSetting["enabled"] = setting.enabled(); - rawSetting["process"] = setting.process(); - rawSetting["library"] = setting.library(); - rawSettings.append(rawSetting); - } - storeSettingsByArray("forced_libraries/" + executable, rawSettings); -} - -void Profile::removeForcedLibraries(const QString& executable) -{ - m_Settings->remove("forced_libraries/" + executable); -} - -void Profile::debugDump() const -{ - struct Pair - { - std::size_t enabled = 0; - std::size_t total = 0; - }; - - Pair total; - Pair real; - Pair backup; - Pair separators; - Pair dlc; - Pair cc; - Pair unmanaged; - - auto add = [](Pair& p, const ModStatus& status) { - ++p.total; - - if (status.m_Enabled) { - ++p.enabled; - } - }; - - for (const auto& status : m_ModStatus) { - auto index = m_ModIndexByPriority.find(status.m_Priority); - if (index == m_ModIndexByPriority.end()) { - log::error("mod with priority {} not in priority map", status.m_Priority); - continue; - } - - auto m = ModInfo::getByIndex(index->second); - if (!m) { - log::error("mod index {} with priority {} not found", index->second, - status.m_Priority); - continue; - } - - if (m->hasFlag(ModInfo::FLAG_OVERWRITE)) { - continue; - } - - add(total, status); - - if (m->hasFlag(ModInfo::FLAG_BACKUP)) { - add(backup, status); - } - - if (m->hasFlag(ModInfo::FLAG_SEPARATOR)) { - add(separators, status); - } - - if (m->hasFlag(ModInfo::FLAG_FOREIGN)) { - if (auto* f = dynamic_cast(m.get())) { - switch (f->modType()) { - case ModInfo::MOD_DLC: - add(dlc, status); - break; - - case ModInfo::MOD_CC: - add(cc, status); - break; - - default: - add(unmanaged, status); - break; - } - } - } - - if (!m->hasAnyOfTheseFlags({ModInfo::FLAG_BACKUP, ModInfo::FLAG_FOREIGN, - ModInfo::FLAG_SEPARATOR, ModInfo::FLAG_OVERWRITE})) { - add(real, status); - } - } - - log::debug("profile '{}' in '{}': " - "mods={}/{} backup={}/{} separators={}/{} real={}/{} dlc={}/{} " - "cc={}/{} unmanaged={}/{} localsaves={}, localsettings={}", - name(), absolutePath(), total.enabled, total.total, backup.enabled, - backup.total, separators.enabled, separators.total, real.enabled, - real.total, dlc.enabled, dlc.total, cc.enabled, cc.total, - unmanaged.enabled, unmanaged.total, localSavesEnabled() ? "yes" : "no", - localSettingsEnabled() ? "yes" : "no"); -} +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "profile.h" + +#include +#include "game_features.h" +#include "modinfo.h" +#include "modinfoforeign.h" +#include "registry.h" +#include "settings.h" +#include "shared/appconfig.h" +#include "shared/util.h" +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include // for QFile +#include // for operator|, QFlags +#include // for QIODevice, etc +#include +#include +#include // for QStringList +#include // for qUtf8Printable + +#include // for assert +#include // for UINT_MAX, INT_MAX, etc +#include // for size_t +#include // for wcslen + +#include // for max, min +#include // for exception +#include +#include // for set +#include +#include // for find + +using namespace MOBase; +using namespace MOShared; + +// MOBase::resolveFileCaseInsensitive moved to MOBase::resolveFileCaseInsensitive + +void Profile::touchFile(QString fileName) +{ + QFile modList(m_Directory.filePath(fileName)); + if (!modList.open(QIODevice::ReadWrite)) { + throw std::runtime_error(QObject::tr("failed to create %1") + .arg(m_Directory.filePath(fileName)) + .toUtf8() + .constData()); + } +} + +Profile::Profile(const QString& name, IPluginGame const* gamePlugin, + GameFeatures const& gameFeatures, bool useDefaultSettings) + : m_ModListWriter(std::bind(&Profile::doWriteModlist, this)), + m_GamePlugin(gamePlugin), m_GameFeatures(gameFeatures) +{ + QString profilesDir = Settings::instance().paths().profiles(); + QDir profileBase(profilesDir); + QString fixedName = name; + if (!fixDirectoryName(fixedName)) { + throw MyException(tr("invalid profile name: %1").arg(qUtf8Printable(name))); + } + + if (!profileBase.exists() || !profileBase.mkdir(fixedName)) { + throw MyException(tr("failed to create %1").arg(fixedName).toUtf8().constData()); + } + QString fullPath = profilesDir + "/" + fixedName; + m_Directory = QDir(fullPath); + m_Settings = + new QSettings(m_Directory.absoluteFilePath("settings.ini"), QSettings::IniFormat); + + try { + // create files. Needs to happen after m_Directory was set! + touchFile("modlist.txt"); + touchFile("archives.txt"); + + IPluginGame::ProfileSettings settings = + IPluginGame::CONFIGURATION | IPluginGame::MODS | IPluginGame::SAVEGAMES; + + if (useDefaultSettings) { + settings |= IPluginGame::PREFER_DEFAULTS; + } + + gamePlugin->initializeProfile(fullPath, settings); + findProfileSettings(); + } catch (...) { + // clean up in case of an error + shellDelete(QStringList(profileBase.absoluteFilePath(fixedName))); + throw; + } + refreshModStatus(); +} + +Profile::Profile(const QDir& directory, IPluginGame const* gamePlugin, + GameFeatures const& gameFeatures) + : m_Directory(directory), m_GamePlugin(gamePlugin), m_GameFeatures(gameFeatures), + m_ModListWriter(std::bind(&Profile::doWriteModlist, this)) +{ + assert(gamePlugin != nullptr); + + m_Settings = + new QSettings(directory.absoluteFilePath("settings.ini"), QSettings::IniFormat); + findProfileSettings(); + + if (!QFile::exists(m_Directory.filePath("modlist.txt"))) { + log::warn("missing modlist.txt in {}", directory.path()); + touchFile(m_Directory.filePath("modlist.txt")); + } + + IPluginGame::ProfileSettings settings = IPluginGame::MODS | IPluginGame::SAVEGAMES; + gamePlugin->initializeProfile(directory, settings); + + refreshModStatus(); +} + +Profile::Profile(const Profile& reference) + : m_Directory(reference.m_Directory), + m_ModListWriter(std::bind(&Profile::doWriteModlist, this)), + m_GamePlugin(reference.m_GamePlugin), m_GameFeatures(reference.m_GameFeatures) + +{ + m_Settings = + new QSettings(m_Directory.absoluteFilePath("settings.ini"), QSettings::IniFormat); + findProfileSettings(); + refreshModStatus(); +} + +Profile::~Profile() +{ + delete m_Settings; + m_ModListWriter.writeImmediately(true); +} + +void Profile::findProfileSettings() +{ + if (setting("", "LocalSaves") == QVariant()) { + if (m_Directory.exists("saves")) { + if (!Settings::instance().profileLocalSaves()) { + m_Directory.rename("saves", "_saves"); + storeSetting("", "LocalSaves", false); + } else { + storeSetting("", "LocalSaves", true); + } + } else { + if (m_Directory.exists("_saves")) { + if (Settings::instance().profileLocalSaves()) { + m_Directory.rename("_saves", "saves"); + storeSetting("", "LocalSaves", true); + } else { + storeSetting("", "LocalSaves", false); + } + } else { + storeSetting("", "LocalSaves", Settings::instance().profileLocalSaves()); + } + } + } + + if (setting("", "LocalSettings") == QVariant()) { + QString backupFile = getIniFileName() + "_"; + if (m_Directory.exists(backupFile)) { + storeSetting("", "LocalSettings", true); + m_Directory.rename(backupFile, getIniFileName()); + } else if (Settings::instance().profileLocalInis()) { + storeSetting("", "LocalSettings", true); + enableLocalSettings(true); + } else { + storeSetting("", "LocalSettings", false); + } + } + + if (setting("", "AutomaticArchiveInvalidation") == QVariant()) { + auto invalidation = m_GameFeatures.gameFeature(); + auto dataArchives = m_GameFeatures.gameFeature(); + bool found = false; + if ((invalidation != nullptr) && (dataArchives != nullptr)) { + for (const QString& archive : dataArchives->archives(this)) { + if (invalidation->isInvalidationBSA(archive)) { + found = true; + break; + } + } + } + if (found) { + if (!Settings::instance().profileArchiveInvalidation()) { + deactivateInvalidation(); + } else { + storeSetting("", "AutomaticArchiveInvalidation", true); + } + } else { + if (Settings::instance().profileArchiveInvalidation()) { + activateInvalidation(); + } else { + storeSetting("", "AutomaticArchiveInvalidation", false); + } + } + } +} + +bool Profile::exists() const +{ + return m_Directory.exists(); +} + +void Profile::writeModlist() +{ + m_ModListWriter.write(); +} + +void Profile::writeModlistNow(bool onlyIfPending) +{ + m_ModListWriter.writeImmediately(onlyIfPending); +} + +void Profile::cancelModlistWrite() +{ + m_ModListWriter.cancel(); +} + +void Profile::doWriteModlist() +{ + if (!m_Directory.exists()) + return; + + try { + QString fileName = getModlistFileName(); + SafeWriteFile file(fileName); + + file->write(QString("# This file was automatically generated by Mod Organizer.\r\n") + .toUtf8()); + if (m_ModStatus.empty()) { + return; + } + + for (auto iter = m_ModIndexByPriority.crbegin(); + iter != m_ModIndexByPriority.crend(); iter++) { + // the priority order was inverted on load so it has to be inverted again + const auto index = iter->second; + ModInfo::Ptr modInfo = ModInfo::getByIndex(index); + if (!modInfo->hasAutomaticPriority()) { + if (modInfo->isForeign()) { + file->write("*"); + } else if (m_ModStatus[index].m_Enabled) { + file->write("+"); + } else { + file->write("-"); + } + file->write(modInfo->name().toUtf8()); + file->write("\r\n"); + } + } + + file->commit(); + } catch (const std::exception& e) { + reportError(tr("failed to write mod list: %1").arg(e.what())); + return; + } +} + +void Profile::createTweakedIniFile() +{ + QString tweakedIni = m_Directory.absoluteFilePath("initweaks.ini"); + + if (QFile::exists(tweakedIni) && !shellDeleteQuiet(tweakedIni)) { + const auto e = GetLastError(); + reportError(tr("failed to update tweaked ini file, wrong settings may be used: %1") + .arg(QString::fromStdWString(formatSystemMessage(e)))); + return; + } + + for (const auto& [priority, index] : m_ModIndexByPriority) { + if (m_ModStatus[index].m_Enabled) { + ModInfo::Ptr modInfo = ModInfo::getByIndex(index); + mergeTweaks(modInfo, tweakedIni); + } + } + + mergeTweak(getProfileTweaks(), tweakedIni); + + bool error = false; + if (!MOBase::WriteRegistryValue( + QStringLiteral("Archive"), QStringLiteral("bInvalidateOlderFiles"), + QStringLiteral("1"), tweakedIni)) { + error = true; + } + + if (error) { + const auto e = ::GetLastError(); + reportError(tr("failed to create tweaked ini: %1") + .arg(QString::fromStdWString(formatSystemMessage(e)))); + } +} + +// static +void Profile::renameModInAllProfiles(const QString& oldName, const QString& newName) +{ + QDir profilesDir(Settings::instance().paths().profiles()); + profilesDir.setFilter(QDir::AllDirs | QDir::NoDotAndDotDot); + QDirIterator profileIter(profilesDir); + while (profileIter.hasNext()) { + profileIter.next(); + QFile modList(profileIter.filePath() + "/modlist.txt"); + if (modList.exists()) + renameModInList(modList, oldName, newName); + else + log::warn("Profile has no modlist.txt: {}", profileIter.filePath()); + } +} + +// static +void Profile::renameModInList(QFile& modList, const QString& oldName, + const QString& newName) +{ + if (!modList.open(QIODevice::ReadOnly)) { + reportError(tr("failed to open %1").arg(modList.fileName())); + return; + } + + QBuffer outBuffer; + outBuffer.open(QIODevice::WriteOnly); + + int renamed = 0; + while (!modList.atEnd()) { + QByteArray line = modList.readLine(); + + if (line.length() == 0) { + // ignore empty lines + log::warn("mod list contained invalid data: empty line"); + continue; + } + + char spec = line.at(0); + if (spec == '#') { + // don't touch comments + outBuffer.write(line); + continue; + } + + QString modName = QString::fromUtf8(line).mid(1).trimmed(); + + if (modName.isEmpty()) { + // file broken? + log::warn("mod list contained invalid data: missing mod name"); + continue; + } + + outBuffer.write(QByteArray(1, spec)); + if (modName == oldName) { + modName = newName; + ++renamed; + } + outBuffer.write(qUtf8Printable(modName)); + outBuffer.write("\r\n"); + } + modList.close(); + + if (renamed) { + if (!modList.open(QIODevice::WriteOnly)) { + log::error("failed to write modlist {}: {}", modList.fileName(), + modList.errorString()); + return; + } + modList.write(outBuffer.buffer()); + modList.close(); + } + + if (renamed) + log::debug(R"(Renamed {} "{}" mod to "{}" in {})", renamed, oldName, newName, + modList.fileName()); +} + +void Profile::refreshModStatus() +{ + // this function refreshes mod status (enabled/disabled) and priority + // using the profile mod list file and the mods in the mods folder using + // the following steps + // + // 1) the mod list file is read and mods status/priority are updated by + // considering the content of the file (for status) and the order (for + // priority), missing or invalid mods are discarded (with a warning) + // 2) the priority are reversed to match the plugin list (highest wins) + // since the mod list is written in reverse order + // 3) at the same time, new mods (not in the mod list file) are added + // - foreign mods are given low priority (below 0) + // - regular mods are given high priority (above mods from the mod list) + // 4) the priority are shifted to ensure that the minimum priority is 0 + // 5) the priority of backups are computed such that the first backup is + // above all regular mods + // + // in the context of the profile, "regular mods" means a mod whose priority + // can be set by the user (i.e. not a backup or overwrite) + // + // this method ensures that the mods priority is as follow + // + // 0 mod1 + // 1 mod2 + // ... + // K-1 modK (K = m_NumRegularMods) + // K backup1 + // K+1 backup2 + // ... + // N-2 backupX + // N-1 overwrite (N = number of mods) + // + + writeModlistNow(true); // if there are pending changes write them first + + QFile file(getModlistFileName()); + if (!file.open(QIODevice::ReadOnly)) { + throw MyException( + tr("\"%1\" is missing or inaccessible").arg(getModlistFileName())); + } + + bool modStatusModified = false; + m_ModStatus.clear(); + m_ModStatus.resize(ModInfo::getNumMods()); + log::debug("refreshModStatus: ModInfo has {} entries", ModInfo::getNumMods()); + + std::set namesRead; + + bool warnAboutOverwrite = false; + unsigned int modsNotFound = 0; + + // load mods from file and update enabled state and priority for them + int index = 0; + while (!file.atEnd()) { + QByteArray line = file.readLine().trimmed(); + + // find the mod name and the enabled status + bool enabled = true; + QString modName; + if (line.length() == 0) { + // empty line + continue; + } else if (line.at(0) == '#') { + // comment line + continue; + } else if (line.at(0) == '-') { + enabled = false; + modName = QString::fromUtf8(line.mid(1).trimmed().constData()); + } else if (line.at(0) == '+' || line.at(0) == '*') { + modName = QString::fromUtf8(line.mid(1).trimmed().constData()); + } else { + modName = QString::fromUtf8(line.trimmed().constData()); + } + + if (modName.isEmpty()) { + continue; + } + + if (modName.compare("overwrite", Qt::CaseInsensitive) == 0) { + warnAboutOverwrite = true; + } + + // check if the name was already read + if (namesRead.contains(modName)) { + continue; + } + namesRead.insert(modName); + + unsigned int modIndex = ModInfo::getIndex(modName); + if (modIndex == UINT_MAX) { + if (modsNotFound < 5) { + log::warn(R"(mod not found: "{}" (profile "{}"))", modName, m_Directory.path()); + } + ++modsNotFound; + // need to rewrite the modlist to fix this + modStatusModified = true; + continue; + } + + // find the mod and check that this is a regular mod (and not a backup) + ModInfo::Ptr info = ModInfo::getByIndex(modIndex); + if (modIndex < m_ModStatus.size() && !info->hasAutomaticPriority()) { + m_ModStatus[modIndex].m_Enabled = enabled; + if (m_ModStatus[modIndex].m_Priority == -1) { + if (static_cast(index) >= m_ModStatus.size()) { + throw Exception(tr("invalid mod index: %1").arg(index)); + } + m_ModStatus[modIndex].m_Priority = index++; + } + } else { + log::warn(R"(no mod state for "{}" (profile "{}"))", modName, + m_Directory.path()); + // need to rewrite the modlist to fix this + modStatusModified = true; + } + + } // while (!file.atEnd()) + + file.close(); + + if (modsNotFound > 0) { + log::error("refreshModStatus: {} mods from modlist.txt were not found in " + "ModInfo (total ModInfo entries: {})", + modsNotFound, ModInfo::getNumMods()); + } + + const int numKnownMods = index; + int topInsert = 0; + + // invert priority order to match that of the pluginlist, also + // give priorities to mods not referenced in the profile and + // count the number of regular mods + m_NumRegularMods = 0; + for (size_t i = 0; i < m_ModStatus.size(); ++i) { + ModInfo::Ptr modInfo = ModInfo::getByIndex(static_cast(i)); + if (modInfo->alwaysEnabled()) { + m_ModStatus[i].m_Enabled = true; + } + + if (modInfo->isOverwrite()) { + m_ModStatus[i].m_Priority = m_ModStatus.size() - 1; + continue; + } + + if (m_ModStatus[i].m_Priority != -1) { + m_ModStatus[i].m_Priority = numKnownMods - m_ModStatus[i].m_Priority - 1; + ++m_NumRegularMods; + } else { + if (static_cast(index) >= m_ModStatus.size()) { + throw Exception(tr("invalid mod index: %1").arg(index)); + } + + // skip backups on purpose to avoid inserting backups in-between + // regular mods + if (modInfo->isForeign()) { + m_ModStatus[i].m_Priority = --topInsert; + ++m_NumRegularMods; + } else if (!modInfo->isBackup()) { + m_ModStatus[i].m_Priority = index++; + ++m_NumRegularMods; + } + + // also, mark the mod-list as changed + modStatusModified = true; + } + } + + // to support insertion of new mods at the top we may now have mods with negative + // priority, so shift them all up to align priority with 0 + if (topInsert < 0) { + int offset = topInsert * -1; + for (size_t i = 0; i < m_ModStatus.size(); ++i) { + ModInfo::Ptr modInfo = ModInfo::getByIndex(static_cast(i)); + if (modInfo->hasAutomaticPriority()) { + continue; + } + + m_ModStatus[i].m_Priority += offset; + } + } + + // set the backups priority + int backupPriority = m_NumRegularMods; + for (size_t i = 0; i < m_ModStatus.size(); ++i) { + ModInfo::Ptr modInfo = ModInfo::getByIndex(static_cast(i)); + if (modInfo->isBackup()) { + m_ModStatus[i].m_Priority = backupPriority++; + } + } + + updateIndices(); + + // User has a mod named some variation of "overwrite". Tell them about it. + if (warnAboutOverwrite) { + reportError(tr("A mod named \"overwrite\" was detected, disabled, and moved to the " + "highest priority on the mod list. " + "You may want to rename this mod and enable it again.")); + // also, mark the mod-list as changed + modStatusModified = true; + } + + if (modStatusModified) { + m_ModListWriter.write(); + } +} + +void Profile::dumpModStatus() const +{ + for (unsigned int i = 0; i < m_ModStatus.size(); ++i) { + ModInfo::Ptr info = ModInfo::getByIndex(i); + log::warn("{}: {} - {} ({})", i, info->name(), m_ModStatus[i].m_Priority, + m_ModStatus[i].m_Enabled ? "enabled" : "disabled"); + } +} + +void Profile::updateIndices() +{ + m_ModIndexByPriority.clear(); + for (unsigned int i = 0; i < m_ModStatus.size(); ++i) { + int priority = m_ModStatus[i].m_Priority; + m_ModIndexByPriority[priority] = i; + } +} + +std::vector> Profile::getActiveMods() +{ + std::vector> result; + for (const auto& [priority, index] : m_ModIndexByPriority) { + if (m_ModStatus[index].m_Enabled) { + ModInfo::Ptr modInfo = ModInfo::getByIndex(index); + result.emplace_back(modInfo->internalName(), modInfo->absolutePath(), + m_ModStatus[index].m_Priority); + } + } + + return result; +} + +void Profile::setModEnabled(unsigned int index, bool enabled) +{ + if (index >= m_ModStatus.size()) { + throw MyException(tr("invalid mod index: %1").arg(index)); + } + + ModInfo::Ptr modInfo = ModInfo::getByIndex(index); + + // we could quit in the following case, this shouldn't be a change anyway, + // but at least this allows the situation to be fixed in case of an error + if (modInfo->alwaysEnabled()) { + enabled = true; + } + if (modInfo->alwaysDisabled()) { + enabled = false; + } + + if (enabled != m_ModStatus[index].m_Enabled) { + m_ModStatus[index].m_Enabled = enabled; + emit modStatusChanged(index); + } +} + +void Profile::setModsEnabled(const QList& modsToEnable, + const QList& modsToDisable) +{ + QList dirtyMods; + for (auto idx : modsToEnable) { + if (idx >= m_ModStatus.size()) { + log::error("invalid mod index: {}", idx); + continue; + } + if (ModInfo::getByIndex(idx)->alwaysDisabled()) { + continue; + } + if (!m_ModStatus[idx].m_Enabled) { + m_ModStatus[idx].m_Enabled = true; + dirtyMods.append(idx); + } + } + for (auto idx : modsToDisable) { + if (idx >= m_ModStatus.size()) { + log::error("invalid mod index: {}", idx); + continue; + } + if (ModInfo::getByIndex(idx)->alwaysEnabled()) { + continue; + } + if (m_ModStatus[idx].m_Enabled) { + m_ModStatus[idx].m_Enabled = false; + dirtyMods.append(idx); + } + } + if (!dirtyMods.isEmpty()) { + emit modStatusChanged(dirtyMods); + } +} + +bool Profile::modEnabled(unsigned int index) const +{ + if (index >= m_ModStatus.size()) { + throw MyException(tr("invalid mod index: %1").arg(index)); + } + + return m_ModStatus[index].m_Enabled; +} + +int Profile::getModPriority(unsigned int index) const +{ + if (index >= m_ModStatus.size()) { + throw MyException(tr("invalid mod index: %1").arg(index)); + } + + return m_ModStatus[index].m_Priority; +} + +bool Profile::setModPriority(unsigned int index, int& newPriority) +{ + if (ModInfo::getByIndex(index)->hasAutomaticPriority()) { + // can't change priority of overwrite/backups + return false; + } + + newPriority = std::clamp(newPriority, 0, static_cast(m_NumRegularMods) - 1); + + int oldPriority = m_ModStatus.at(index).m_Priority; + int lastPriority = INT_MIN; + + if (newPriority == oldPriority) { + // nothing to do + return false; + } + + for (const auto& [priority, index] : m_ModIndexByPriority) { + if (newPriority < oldPriority && priority >= newPriority && + priority < oldPriority) { + m_ModStatus.at(index).m_Priority += 1; + } else if (newPriority > oldPriority && priority <= newPriority && + priority > oldPriority) { + m_ModStatus.at(index).m_Priority -= 1; + } + lastPriority = std::max(lastPriority, priority); + } + + newPriority = std::min(newPriority, lastPriority); + m_ModStatus.at(index).m_Priority = std::min(newPriority, lastPriority); + + updateIndices(); + m_ModListWriter.write(); + + return true; +} + +Profile* Profile::createPtrFrom(const QString& name, const Profile& reference, + MOBase::IPluginGame const* gamePlugin) +{ + QString profileDirectory = Settings::instance().paths().profiles() + "/" + name; + reference.copyFilesTo(profileDirectory); + return new Profile(QDir(profileDirectory), gamePlugin, reference.m_GameFeatures); +} + +void Profile::copyFilesTo(QString& target) const +{ + copyDir(m_Directory.absolutePath(), target, false); +} + +std::vector Profile::splitDZString(const wchar_t* buffer) +{ + std::vector result; + const wchar_t* pos = buffer; + size_t length = wcslen(pos); + while (length != 0U) { + result.emplace_back(pos); + pos += length + 1; + length = wcslen(pos); + } + return result; +} + +void Profile::mergeTweak(const QString& tweakName, const QString& tweakedIni) +{ + // Parse the tweak INI file line-by-line and merge each key=value into the + // destination using WriteRegistryValue (which uses the safe line-by-line + // writer that does NOT interpret backslashes as line continuations or + // URL-encode spaces in keys). + QFile sourceFile(tweakName); + if (!sourceFile.open(QIODevice::ReadOnly | QIODevice::Text)) { + if (QFileInfo::exists(tweakName)) { + log::warn("mergeTweak: tweak file '{}' exists but could not be opened", tweakName); + } else { + log::debug("mergeTweak: tweak file '{}' does not exist, skipping", tweakName); + } + return; + } + + QString currentSection; + QTextStream stream(&sourceFile); + while (!stream.atEnd()) { + QString line = stream.readLine().trimmed(); + if (line.isEmpty() || line.startsWith(';') || line.startsWith('#')) { + continue; + } + if (line.startsWith('[') && line.endsWith(']')) { + currentSection = line.mid(1, line.length() - 2).trimmed(); + continue; + } + const int eqPos = line.indexOf('='); + if (eqPos > 0 && !currentSection.isEmpty()) { + const QString key = line.left(eqPos).trimmed(); + const QString value = line.mid(eqPos + 1).trimmed(); + MOBase::WriteRegistryValue(currentSection, key, value, tweakedIni); + } + } +} + +void Profile::mergeTweaks(ModInfo::Ptr modInfo, const QString& tweakedIni) const +{ + std::vector iniTweaks = modInfo->getIniTweaks(); + for (std::vector::iterator iter = iniTweaks.begin(); iter != iniTweaks.end(); + ++iter) { + mergeTweak(*iter, tweakedIni); + } +} + +bool Profile::invalidationActive(bool* supported) const +{ + auto invalidation = m_GameFeatures.gameFeature(); + auto dataArchives = m_GameFeatures.gameFeature(); + + if (supported != nullptr) { + *supported = ((invalidation != nullptr) && (dataArchives != nullptr)); + } + + return setting("", "AutomaticArchiveInvalidation", + Settings::instance().profileArchiveInvalidation()) + .toBool(); +} + +void Profile::deactivateInvalidation() +{ + auto invalidation = m_GameFeatures.gameFeature(); + + if (invalidation != nullptr) { + invalidation->deactivate(this); + } + + storeSetting("", "AutomaticArchiveInvalidation", false); +} + +void Profile::activateInvalidation() +{ + auto invalidation = m_GameFeatures.gameFeature(); + + if (invalidation != nullptr) { + invalidation->activate(this); + } + + storeSetting("", "AutomaticArchiveInvalidation", true); +} + +bool Profile::localSavesEnabled() const +{ + return setting("", "LocalSaves", Settings::instance().profileLocalSaves()).toBool(); +} + +bool Profile::enableLocalSaves(bool enable) +{ + if (enable) { + if (!m_Directory.exists("saves")) { + m_Directory.mkdir("saves"); + } + } else { + QDialogButtonBox::StandardButton res; + res = QuestionBoxMemory::query( + QApplication::activeModalWidget(), "deleteSavesQuery", + tr("Delete profile-specific save games?"), + tr("Do you want to delete the profile-specific save games? (If you select " + "\"No\", the " + "save games will show up again if you re-enable profile-specific save " + "games)"), + QDialogButtonBox::No | QDialogButtonBox::Yes | QDialogButtonBox::Cancel, + QDialogButtonBox::No); + if (res == QDialogButtonBox::Yes) { + shellDelete(QStringList(m_Directory.absoluteFilePath("saves"))); + } else if (res == QDialogButtonBox::No) { + // No action + } else { + return false; + } + } + storeSetting("", "LocalSaves", enable); + return true; +} + +bool Profile::localSettingsEnabled() const +{ + bool enabled = + setting("", "LocalSettings", Settings::instance().profileLocalInis()).toBool(); + if (enabled) { + QStringList missingFiles; + for (QString file : m_GamePlugin->iniFiles()) { + QString fileName = QFileInfo(file).fileName(); + // Use case-insensitive lookup on Linux — the file may exist with + // different casing (e.g. "skyrimprefs.ini" vs "SkyrimPrefs.ini"). + QString resolved = MOBase::resolveFileCaseInsensitive( + m_Directory.filePath(fileName)); + if (!QFile::exists(resolved)) { + log::warn("missing {} in {}", fileName, m_Directory.path()); + missingFiles << fileName; + } + } + if (!missingFiles.empty()) { + m_GamePlugin->initializeProfile(m_Directory, IPluginGame::CONFIGURATION); + QMessageBox::warning(QApplication::activeModalWidget(), + tr("Missing profile-specific game INI files!"), + tr("Some of your profile-specific game INI files were " + "missing. They will now be copied " + "from the vanilla game folder. You might want to " + "double-check your settings.\n\n" + "Missing files:\n") + + missingFiles.join("\n")); + } + } + return enabled; +} + +bool Profile::enableLocalSettings(bool enable) +{ + if (enable) { + m_GamePlugin->initializeProfile(m_Directory.absolutePath(), + IPluginGame::CONFIGURATION); + } else { + QDialogButtonBox::StandardButton res; + res = QuestionBoxMemory::query(QApplication::activeModalWidget(), "deleteINIQuery", + tr("Delete profile-specific game INI files?"), + tr("Do you want to delete the profile-specific game " + "INI files? (If you select \"No\", the " + "INI files will be used again if you re-enable " + "profile-specific game INI files.)"), + QDialogButtonBox::No | QDialogButtonBox::Yes | + QDialogButtonBox::Cancel, + QDialogButtonBox::No); + if (res == QDialogButtonBox::Yes) { + QStringList filesToDelete; + for (QString file : m_GamePlugin->iniFiles()) { + QString resolved = MOBase::resolveFileCaseInsensitive( + m_Directory.absoluteFilePath(QFileInfo(file).fileName())); + filesToDelete << resolved; + } + shellDelete(filesToDelete, true); + } else if (res == QDialogButtonBox::No) { + // No action + } else { + return false; + } + } + storeSetting("", "LocalSettings", enable); + return true; +} + +QString Profile::getModlistFileName() const +{ + return QDir::cleanPath(m_Directory.absoluteFilePath("modlist.txt")); +} + +QString Profile::getPluginsFileName() const +{ + return QDir::cleanPath(m_Directory.absoluteFilePath("plugins.txt")); +} + +QString Profile::getLoadOrderFileName() const +{ + return QDir::cleanPath(m_Directory.absoluteFilePath("loadorder.txt")); +} + +QString Profile::getLockedOrderFileName() const +{ + return QDir::cleanPath(m_Directory.absoluteFilePath("lockedorder.txt")); +} + +QString Profile::getArchivesFileName() const +{ + return QDir::cleanPath(m_Directory.absoluteFilePath("archives.txt")); +} + +QString Profile::getIniFileName() const +{ + auto iniFiles = m_GamePlugin->iniFiles(); + if (iniFiles.isEmpty()) + return ""; + else + return MOBase::resolveFileCaseInsensitive( + m_Directory.absoluteFilePath(QFileInfo(iniFiles[0]).fileName())); +} + +QString Profile::absoluteIniFilePath(QString iniFile) const +{ + // This is the file to which the given iniFile would be mapped, as + // an absolute file path: + QFileInfo targetIniFile(m_GamePlugin->documentsDirectory(), iniFile); + + bool isGameIni = false; + for (auto gameIni : m_GamePlugin->iniFiles()) { + // We compare the target file, not the actual ones: + if (QFileInfo(m_GamePlugin->documentsDirectory(), gameIni) == targetIniFile) { + isGameIni = true; + break; + } + } + + // Local-settings are not enabled, or the iniFile is not in the list of INI + // files for the current game. + if (!localSettingsEnabled() || !isGameIni) { + return MOBase::resolveFileCaseInsensitive(targetIniFile.absoluteFilePath()); + } + + // If we reach here, the file is in the profile: + return MOBase::resolveFileCaseInsensitive( + m_Directory.absoluteFilePath(targetIniFile.fileName())); +} + +QString Profile::getProfileTweaks() const +{ + return QDir::cleanPath( + m_Directory.absoluteFilePath(ToQString(AppConfig::profileTweakIni()))); +} + +QString Profile::absolutePath() const +{ + return QDir::cleanPath(m_Directory.absolutePath()); +} + +QString Profile::savePath() const +{ + return QDir::cleanPath(m_Directory.absoluteFilePath("saves")); +} + +void Profile::rename(const QString& newName) +{ + QDir profileDir(Settings::instance().paths().profiles()); + profileDir.rename(name(), newName); + m_Directory.setPath(profileDir.absoluteFilePath(newName)); +} + +QString keyName(const QString& section, const QString& name) +{ + QString key = section; + + if (!name.isEmpty()) { + if (!key.isEmpty()) { + key += "/"; + } + + key += name; + } + + return key; +} + +QVariant Profile::setting(const QString& section, const QString& name, + const QVariant& fallback) const +{ + return m_Settings->value(keyName(section, name), fallback); +} + +void Profile::storeSetting(const QString& section, const QString& name, + const QVariant& value) +{ + m_Settings->setValue(keyName(section, name), value); +} + +void Profile::removeSetting(const QString& section, const QString& name) +{ + m_Settings->remove(keyName(section, name)); +} + +QVariantMap Profile::settingsByGroup(const QString& section) const +{ + QVariantMap results; + m_Settings->beginGroup(section); + for (auto key : m_Settings->childKeys()) { + results[key] = m_Settings->value(key); + } + m_Settings->endGroup(); + return results; +} + +void Profile::storeSettingsByGroup(const QString& section, const QVariantMap& values) +{ + m_Settings->beginGroup(section); + for (auto key : values.keys()) { + m_Settings->setValue(key, values[key]); + } + m_Settings->endGroup(); +} + +QList Profile::settingsByArray(const QString& prefix) const +{ + QList results; + int size = m_Settings->beginReadArray(prefix); + for (int i = 0; i < size; i++) { + m_Settings->setArrayIndex(i); + QVariantMap item; + for (auto key : m_Settings->childKeys()) { + item[key] = m_Settings->value(key); + } + results.append(item); + } + m_Settings->endArray(); + return results; +} + +void Profile::storeSettingsByArray(const QString& prefix, + const QList& values) +{ + m_Settings->beginWriteArray(prefix); + for (int i = 0; i < values.length(); i++) { + m_Settings->setArrayIndex(i); + for (auto key : values.at(i).keys()) { + m_Settings->setValue(key, values.at(i)[key]); + } + } + m_Settings->endArray(); +} + +bool Profile::forcedLibrariesEnabled(const QString& executable) const +{ + return setting("forced_libraries", executable + "/enabled", true).toBool(); +} + +void Profile::setForcedLibrariesEnabled(const QString& executable, bool enabled) +{ + storeSetting("forced_libraries", executable + "/enabled", enabled); +} + +QList +Profile::determineForcedLibraries(const QString& executable) const +{ + QList results; + + auto rawSettings = settingsByArray("forced_libraries/" + executable); + auto forcedLoads = m_GamePlugin->executableForcedLoads(); + + // look for enabled status on forced loads and add those + for (auto forcedLoad : forcedLoads) { + bool found = false; + for (auto rawSetting : rawSettings) { + if ((rawSetting.value("process").toString().compare(forcedLoad.process(), + Qt::CaseInsensitive) == 0) && + (rawSetting.value("library").toString().compare(forcedLoad.library(), + Qt::CaseInsensitive) == 0)) { + results.append( + forcedLoad.withEnabled(rawSetting.value("enabled", false).toBool())); + found = true; + } + } + if (!found) { + results.append(forcedLoad); + } + } + + // add everything else + for (auto rawSetting : rawSettings) { + bool add = true; + for (auto forcedLoad : forcedLoads) { + if ((rawSetting.value("process").toString().compare(forcedLoad.process(), + Qt::CaseInsensitive) == 0) && + (rawSetting.value("library").toString().compare(forcedLoad.library(), + Qt::CaseInsensitive) == 0)) { + add = false; + } + } + if (add) { + results.append(ExecutableForcedLoadSetting(rawSetting.value("process").toString(), + rawSetting.value("library").toString()) + .withEnabled(rawSetting.value("enabled", false).toBool())); + } + } + + return results; +} + +void Profile::storeForcedLibraries(const QString& executable, + const QList& values) +{ + QList rawSettings; + for (auto setting : values) { + QVariantMap rawSetting; + rawSetting["enabled"] = setting.enabled(); + rawSetting["process"] = setting.process(); + rawSetting["library"] = setting.library(); + rawSettings.append(rawSetting); + } + storeSettingsByArray("forced_libraries/" + executable, rawSettings); +} + +void Profile::removeForcedLibraries(const QString& executable) +{ + m_Settings->remove("forced_libraries/" + executable); +} + +void Profile::debugDump() const +{ + struct Pair + { + std::size_t enabled = 0; + std::size_t total = 0; + }; + + Pair total; + Pair real; + Pair backup; + Pair separators; + Pair dlc; + Pair cc; + Pair unmanaged; + + auto add = [](Pair& p, const ModStatus& status) { + ++p.total; + + if (status.m_Enabled) { + ++p.enabled; + } + }; + + for (const auto& status : m_ModStatus) { + auto index = m_ModIndexByPriority.find(status.m_Priority); + if (index == m_ModIndexByPriority.end()) { + log::error("mod with priority {} not in priority map", status.m_Priority); + continue; + } + + auto m = ModInfo::getByIndex(index->second); + if (!m) { + log::error("mod index {} with priority {} not found", index->second, + status.m_Priority); + continue; + } + + if (m->hasFlag(ModInfo::FLAG_OVERWRITE)) { + continue; + } + + add(total, status); + + if (m->hasFlag(ModInfo::FLAG_BACKUP)) { + add(backup, status); + } + + if (m->hasFlag(ModInfo::FLAG_SEPARATOR)) { + add(separators, status); + } + + if (m->hasFlag(ModInfo::FLAG_FOREIGN)) { + if (auto* f = dynamic_cast(m.get())) { + switch (f->modType()) { + case ModInfo::MOD_DLC: + add(dlc, status); + break; + + case ModInfo::MOD_CC: + add(cc, status); + break; + + default: + add(unmanaged, status); + break; + } + } + } + + if (!m->hasAnyOfTheseFlags({ModInfo::FLAG_BACKUP, ModInfo::FLAG_FOREIGN, + ModInfo::FLAG_SEPARATOR, ModInfo::FLAG_OVERWRITE})) { + add(real, status); + } + } + + log::debug("profile '{}' in '{}': " + "mods={}/{} backup={}/{} separators={}/{} real={}/{} dlc={}/{} " + "cc={}/{} unmanaged={}/{} localsaves={}, localsettings={}", + name(), absolutePath(), total.enabled, total.total, backup.enabled, + backup.total, separators.enabled, separators.total, real.enabled, + real.total, dlc.enabled, dlc.total, cc.enabled, cc.total, + unmanaged.enabled, unmanaged.total, localSavesEnabled() ? "yes" : "no", + localSettingsEnabled() ? "yes" : "no"); +} diff --git a/src/src/protonlauncher.cpp b/src/src/protonlauncher.cpp index 0e66e80..9c6bba8 100644 --- a/src/src/protonlauncher.cpp +++ b/src/src/protonlauncher.cpp @@ -23,7 +23,7 @@ void cleanAppImageEnv(QProcessEnvironment& env) // Remove Fluorine/AppImage-specific vars that should never leak to game processes. env.remove("QT_QPA_PLATFORM_PLUGIN_PATH"); env.remove("MO2_PLUGINS_DIR"); - env.remove("MO2_DLLS_DIR"); + env.remove("MO2_LIBS_DIR"); env.remove("MO2_PYTHON_DIR"); env.remove("MO2_BASE_DIR"); @@ -342,7 +342,7 @@ bool parseEnvAssignment(const QString& token, QString& keyOut, QString& valueOut } // namespace ProtonLauncher::ProtonLauncher() - + = default; ProtonLauncher& ProtonLauncher::setBinary(const QString& path) diff --git a/src/src/qt.conf b/src/src/qt.conf index f834a22..cc9c926 100644 --- a/src/src/qt.conf +++ b/src/src/qt.conf @@ -1,3 +1,3 @@ -[Paths] -Prefix=. -Plugins=dlls +[Paths] +Prefix=. +Plugins=plugins diff --git a/src/src/qtgroupingproxy.cpp b/src/src/qtgroupingproxy.cpp index 5235d6f..f261d63 100644 --- a/src/src/qtgroupingproxy.cpp +++ b/src/src/qtgroupingproxy.cpp @@ -1,948 +1,946 @@ -/**************************************************************************************** - * Copyright (c) 2007-2011 Bart Cerneels * - * * - * This program is free software; you can redistribute it and/or modify it under * the - *terms of the GNU General Public License as published by the Free Software * - * Foundation; either version 2 of the License, or (at your option) any later * version. - ** - * * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY * - * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A * - * PARTICULAR PURPOSE. See the GNU General Public License for more details. * - * * - * You should have received a copy of the GNU General Public License along with * this - *program. If not, see . * - ****************************************************************************************/ - -// Modifications 2013-03-27 to 2013-03-29 by Sebastian Herbord - -#include "qtgroupingproxy.h" -#include - -#include -#include -#include - -using namespace MOBase; - -/*! - \class QtGroupingProxy - \brief The QtGroupingProxy class will group source model rows by adding a new top - tree-level. The source model can be flat or tree organized, but only the original top - level rows are used for determining the grouping. \ingroup model-view -*/ - -QtGroupingProxy::QtGroupingProxy(QModelIndex rootNode, int groupedColumn, - int groupedRole, unsigned int flags, int aggregateRole) - : m_rootNode(rootNode), - m_groupedRole(groupedRole), m_aggregateRole(aggregateRole), m_flags(flags) -{ - if (groupedColumn != -1) { - setGroupedColumn(groupedColumn); - } -} - -QtGroupingProxy::~QtGroupingProxy() = default; - -void QtGroupingProxy::setSourceModel(QAbstractItemModel* model) -{ - if (sourceModel()) { - disconnect(sourceModel(), nullptr, this, nullptr); - } - - QAbstractProxyModel::setSourceModel(model); - - if (sourceModel()) { - // signal proxies - connect(sourceModel(), SIGNAL(rowsInserted(const QModelIndex&, int, int)), - SLOT(modelRowsInserted(const QModelIndex&, int, int))); - connect(sourceModel(), SIGNAL(rowsAboutToBeInserted(const QModelIndex&, int, int)), - SLOT(modelRowsAboutToBeInserted(const QModelIndex&, int, int))); - connect(sourceModel(), SIGNAL(rowsRemoved(const QModelIndex&, int, int)), - SLOT(modelRowsRemoved(const QModelIndex&, int, int))); - connect(sourceModel(), SIGNAL(rowsAboutToBeRemoved(const QModelIndex&, int, int)), - SLOT(modelRowsAboutToBeRemoved(QModelIndex, int, int))); - connect(sourceModel(), SIGNAL(layoutChanged()), SLOT(buildTree())); - connect(sourceModel(), SIGNAL(dataChanged(QModelIndex, QModelIndex)), - SLOT(modelDataChanged(QModelIndex, QModelIndex))); - connect(sourceModel(), SIGNAL(modelReset()), this, SLOT(resetModel())); - - buildTree(); - } -} - -void QtGroupingProxy::setGroupedColumn(int groupedColumn) -{ - m_groupedColumn = groupedColumn; - buildTree(); -} - -/** Maps to what groups the source row belongs by returning the data of those groups. - * - * @returns a list of data for the rows the argument belongs to. In common cases this - * list will contain only one entry. An empty list means that the source item will be - * placed in the root of this proxyModel. There is no support for hiding source items. - * - * Group data can be pre-loaded in the return value so it's added to the cache - * maintained by this class. This is required if you want to have data that is not - * present in the source model. - */ -QList QtGroupingProxy::belongsTo(const QModelIndex& idx) -{ - QList rowDataList; - - // get all the data for this index from the model - ItemData itemData = sourceModel()->itemData(idx); - if (m_groupedRole != Qt::DisplayRole) { - itemData[Qt::DisplayRole] = itemData[m_groupedRole]; - } - - // invalid value in grouped role -> ungrouped - if (!itemData[Qt::DisplayRole].isValid()) { - return rowDataList; - } - - QMapIterator i(itemData); - while (i.hasNext()) { - i.next(); - int role = i.key(); - QVariant variant = i.value(); - - if (variant.typeId() == QMetaType::QVariantList) { - // a list of variants get's expanded to multiple rows - QVariantList list = variant.toList(); - for (int i = 0; i < list.length(); i++) { - // take an existing row data or create a new one - RowData rowData = (rowDataList.count() > i) ? rowDataList.takeAt(i) : RowData(); - - // we only gather data for the first column - ItemData indexData = rowData.contains(0) ? rowData.take(0) : ItemData(); - indexData.insert(role, list.value(i)); - rowData.insert(0, indexData); - // for the grouped column the data should not be gathered from the children - // this will allow filtering on the content of this column with a - // QSortFilterProxyModel - rowData.insert(m_groupedColumn, indexData); - rowDataList.insert(i, rowData); - } - break; - } else if (!variant.isNull()) { - // it's just a normal item. Copy all the data and break this loop. - RowData rowData; - rowData.insert(0, itemData); - rowDataList << rowData; - break; - } - } - - return rowDataList; -} - -/* m_groupMap layout - * key : index of the group in m_groupMaps - * value : a QList of the original rows in sourceModel() for the children of this group - * - * key = -1 contains a QList of the non-grouped indexes - * - * TODO: sub-groups - */ -void QtGroupingProxy::buildTree() -{ - if (!sourceModel()) - return; - beginResetModel(); - - m_groupMap.clear(); - // don't clear the data maps since most of it will probably be needed again. - m_parentCreateList.clear(); - - int max = sourceModel()->rowCount(m_rootNode); - - // WARNING: these have to be added in order because the addToGroups function is - // optimized for modelRowsInserted(). Failure to do so will result in wrong data shown - // in the view at best. - for (int row = 0; row < max; row++) { - QModelIndex idx = sourceModel()->index(row, m_groupedColumn, m_rootNode); - addSourceRow(idx); - } - // dumpGroups(); - - if (m_flags & FLAG_NOSINGLE) { - // awkward: flatten single-item groups as a post-processing steps. - - int currentKey = 0; - quint32 quint32max = std::numeric_limits::max(); - std::vector rmgroups; - - QMap> temp; - - for (auto iter = m_groupMap.begin(); iter != m_groupMap.end(); ++iter) { - if ((iter.key() == quint32max) || (iter->count() < 2)) { - temp[quint32max].append(iter.value()); - if (iter.key() != quint32max) { - rmgroups.push_back(iter.key()); - } - } else { - temp[currentKey++] = *iter; - } - } - m_groupMap = temp; - - // second loop is necessary because qt containers can't be iterated from end to - // front and removing by index from begin to end is ugly - std::sort(rmgroups.begin(), rmgroups.end(), [](int lhs, int rhs) { - return rhs < lhs; - }); - for (auto iter = rmgroups.begin(); iter != rmgroups.end(); ++iter) { - m_groupMaps.removeAt(*iter); - } - } - - endResetModel(); -} - -QList QtGroupingProxy::addSourceRow(const QModelIndex& idx) -{ - QList updatedGroups; - QList groupData = belongsTo(idx); - - // an empty list here means it's supposed to go in root. - if (groupData.isEmpty()) { - updatedGroups << -1; - if (!m_groupMap.keys().contains(std::numeric_limits::max())) - m_groupMap.insert(std::numeric_limits::max(), - QList()); // add an empty placeholder - } - - // an item can be in multiple groups - foreach (RowData data, groupData) { - int updatedGroup = -1; - if (!data.isEmpty()) { - foreach (const RowData& cachedData, m_groupMaps) { - // when this matches the index belongs to an existing group - if (data[0][Qt::DisplayRole] == cachedData[0][Qt::DisplayRole]) { - data = cachedData; - break; - } - } - - updatedGroup = m_groupMaps.indexOf(data); - //-1 means not found - if (updatedGroup == -1) { - // new groups are added to the end of the existing list - m_groupMaps << data; - updatedGroup = m_groupMaps.count() - 1; - } - - if (!m_groupMap.keys().contains(updatedGroup)) - m_groupMap.insert(updatedGroup, QList()); // add an empty placeholder - } - - if (!updatedGroups.contains(updatedGroup)) - updatedGroups << updatedGroup; - } - - // update m_groupMap to the new source-model layout (one row added) - QMutableMapIterator> i(m_groupMap); - while (i.hasNext()) { - i.next(); - QList& groupList = i.value(); - int insertedProxyRow = groupList.count(); - for (; insertedProxyRow > 0; insertedProxyRow--) { - int& rowValue = groupList[insertedProxyRow - 1]; - if (idx.row() <= rowValue) { - // increment the rows that come after the new row since they moved one place up. - rowValue++; - } else { - break; - } - } - - if (updatedGroups.contains(i.key())) { - // the row needs to be added to this group - groupList.insert(insertedProxyRow, idx.row()); - } - } - - return updatedGroups; -} - -/** Each ModelIndex has in it's internalId a position in the parentCreateList. - * struct ParentCreate are the instructions to recreate the parent index. - * It contains the proxy row number of the parent and the postion in this list of the - * grandfather. This function creates the ParentCreate structs and saves them in a list. - */ -int QtGroupingProxy::indexOfParentCreate(const QModelIndex& parent) const -{ - if (!parent.isValid()) - return -1; - - struct ParentCreate pc; - for (int i = 0; i < m_parentCreateList.size(); i++) { - pc = m_parentCreateList[i]; - if (pc.parentCreateIndex == parent.internalId() && pc.row == parent.row()) - return i; - } - // there is no parentCreate yet for this index, so let's create one. - pc.parentCreateIndex = parent.internalId(); - pc.row = parent.row(); - m_parentCreateList << pc; - - return m_parentCreateList.size() - 1; -} - -QModelIndex QtGroupingProxy::index(int row, int column, const QModelIndex& parent) const -{ - if (!hasIndex(row, column, parent)) { - return {}; - } - - if (parent.column() > 0) { - return {}; - } - - /* We save the instructions to make the parent of the index in a struct. - * The place of the struct in the list is stored in the internalId - */ - int parentCreateIndex = indexOfParentCreate(parent); - - return createIndex(row, column, parentCreateIndex); -} - -QModelIndex QtGroupingProxy::parent(const QModelIndex& index) const -{ - if (!index.isValid()) - return {}; - - int parentCreateIndex = index.internalId(); - if (parentCreateIndex == -1 || parentCreateIndex >= m_parentCreateList.count()) - return {}; - - struct ParentCreate pc = m_parentCreateList[parentCreateIndex]; - - // only items at column 0 have children - return createIndex(pc.row, 0, pc.parentCreateIndex); -} - -int QtGroupingProxy::rowCount(const QModelIndex& index) const -{ - if (!index.isValid()) { - // the number of top level groups + the number of non-grouped items - int rows = m_groupMaps.count() + - m_groupMap.value(std::numeric_limits::max()).count(); - return rows; - } - - // TODO:group in group support. - if (isGroup(index)) { - qint64 groupIndex = index.row(); - int rows = m_groupMap.value(groupIndex).count(); - return rows; - } else { - QModelIndex originalIndex = mapToSource(index); - int rowCount = sourceModel()->rowCount(originalIndex); - return rowCount; - } -} - -int QtGroupingProxy::columnCount(const QModelIndex& index) const -{ - if (!index.isValid()) - return sourceModel()->columnCount(m_rootNode); - - if (index.column() != 0) - return 0; - - return sourceModel()->columnCount(mapToSource(index)); -} - -static bool variantLess(const QVariant& LHS, const QVariant& RHS) -{ - if ((LHS.typeId() == RHS.typeId()) && - ((LHS.typeId() == QMetaType::Int) || (LHS.typeId() == QMetaType::UInt))) { - return LHS.toInt() < RHS.toInt(); - } - - // this should always work (comparing empty strings in the worst case) but - // the results may be wrong - return LHS.toString() < RHS.toString(); -} - -static QVariant variantMax(const QVariantList& variants) -{ - QVariant result = variants.first(); - foreach (const QVariant& iter, variants) { - if (variantLess(result, iter)) { - result = iter; - } - } - return result; -} - -static QVariant variantMin(const QVariantList& variants) -{ - QVariant result = variants.first(); - foreach (const QVariant& iter, variants) { - if (variantLess(iter, result)) { - result = iter; - } - } - return result; -} - -QVariant QtGroupingProxy::data(const QModelIndex& index, int role) const -{ - if (!index.isValid()) - return {}; - - int row = index.row(); - int column = index.column(); - if (isGroup(index)) { - if ((role != Qt::DisplayRole) && (role != Qt::EditRole)) { - switch (role) { - case Qt::ForegroundRole: { - return QBrush(Qt::gray); - } break; - case Qt::FontRole: { - QFont font(m_groupMaps[row][column].value(Qt::FontRole).value()); - font.setItalic(true); - return font; - } break; - case Qt::TextAlignmentRole: { - return Qt::AlignHCenter; - } break; - case Qt::UserRole: { - return m_groupMaps[row][column].value(Qt::DisplayRole).toString(); - } break; - case Qt::CheckStateRole: { - if (column != 0) - return {}; - int childCount = m_groupMap.value(row).count(); - int checked = 0; - QModelIndex parentIndex = this->index(row, 0, index.parent()); - for (int childRow = 0; childRow < childCount; ++childRow) { - QModelIndex childIndex = this->index(childRow, 0, parentIndex); - QVariant data = mapToSource(childIndex).data(Qt::CheckStateRole); - if (data.toInt() == 2) - ++checked; - } - if (checked == childCount) - return Qt::Checked; - else if (checked == 0) - return Qt::Unchecked; - else - return Qt::PartiallyChecked; - } break; - default: { - QModelIndex parentIndex = this->index(row, 0, index.parent()); - if (m_groupMap.value(row).count() > 0) { - return this->index(0, column, parentIndex).data(role); - } else { - return {}; - } - // return m_groupMaps[row][column].value( role ); - } break; - } - } - - // use cached or precalculated data - if (m_groupMaps[row][column].contains(Qt::DisplayRole)) { - if ((m_flags & FLAG_NOGROUPNAME) != 0) { - QModelIndex parentIndex = this->index(row, 0, index.parent()); - QModelIndex childIndex = this->index(0, column, parentIndex); - return childIndex.data(role).toString(); - } else { - return m_groupMaps[row][column].value(role).toString(); - } - } - - // for column 0 we gather data from the grouped column instead - if (column == 0) - column = m_groupedColumn; - - // map all data from children to columns of group to allow grouping one level up - QVariantList variantsOfChildren; - int childCount = m_groupMap.value(row).count(); - if (childCount == 0) - return {}; - - int function = AGGR_NONE; - if (m_aggregateRole >= Qt::UserRole) { - QModelIndex parentIndex = this->index(row, 0, index.parent()); - QModelIndex childIndex = this->index(0, column, parentIndex); - function = mapToSource(childIndex).data(m_aggregateRole).toInt(); - } - - // Need a parentIndex with column == 0 because only those have children. - QModelIndex parentIndex = this->index(row, 0, index.parent()); - for (int childRow = 0; childRow < childCount; childRow++) { - QModelIndex childIndex = this->index(childRow, column, parentIndex); - QVariant data = mapToSource(childIndex).data(role); - - if (data.isValid() && !variantsOfChildren.contains(data)) - variantsOfChildren << data; - } - - // saving in cache - ItemData roleMap = m_groupMaps[row].value(column); - foreach (const QVariant& variant, variantsOfChildren) { - if (roleMap[role] != variant) { - roleMap.insert(role, variantsOfChildren); - } - } - - if (variantsOfChildren.count() == 0) - return {}; - - // only one unique variant? No need to return a list - switch (function) { - case AGGR_EMPTY: - return {}; - case AGGR_FIRST: - return variantsOfChildren.first(); - case AGGR_MAX: - return variantMax(variantsOfChildren); - case AGGR_MIN: - return variantMin(variantsOfChildren); - default: { - if (variantsOfChildren.count() == 1) - return variantsOfChildren.first(); - - return variantsOfChildren; - } break; - } - } - - return mapToSource(index).data(role); -} - -bool QtGroupingProxy::setData(const QModelIndex& idx, const QVariant& value, int role) -{ - if (!idx.isValid()) - return false; - - // no need to set data to exactly the same value - if (idx.data(role) == value) - return false; - - if (isGroup(idx)) { - ItemData columnData = m_groupMaps[idx.row()][idx.column()]; - - columnData.insert(role, value); - // QItemDelegate will always use Qt::EditRole - if (role == Qt::EditRole) - columnData.insert(Qt::DisplayRole, value); - - // and make sure it's stored in the map - m_groupMaps[idx.row()].insert(idx.column(), columnData); - - int columnToChange = idx.column() ? idx.column() : m_groupedColumn; - foreach (int originalRow, m_groupMap.value(idx.row())) { - QModelIndex childIdx = - sourceModel()->index(originalRow, columnToChange, m_rootNode); - if (childIdx.isValid()) - sourceModel()->setData(childIdx, value, role); - } - // TODO: we might need to reload the data from the children at this point - - emit dataChanged(idx, idx); - return true; - } - - return sourceModel()->setData(mapToSource(idx), value, role); -} - -bool QtGroupingProxy::isGroup(const QModelIndex& index) const -{ - int parentCreateIndex = index.internalId(); - if (parentCreateIndex == -1 && index.row() < m_groupMaps.count()) - return true; - return false; -} - -QModelIndex QtGroupingProxy::mapToSource(const QModelIndex& index) const -{ - if (!index.isValid()) { - return m_rootNode; - } - - if (isGroup(index)) { - return m_rootNode; - } - - QModelIndex proxyParent = index.parent(); - QModelIndex originalParent = mapToSource(proxyParent); - - int originalRow = index.row(); - if (originalParent == m_rootNode) { - int indexInGroup = index.row(); - if (!proxyParent.isValid()) - indexInGroup -= m_groupMaps.count(); - - QList childRows = m_groupMap.value(proxyParent.row()); - if (childRows.isEmpty() || indexInGroup >= childRows.count() || indexInGroup < 0) - return {}; - - originalRow = childRows.at(indexInGroup); - } - return sourceModel()->index(originalRow, index.column(), originalParent); -} - -QModelIndexList QtGroupingProxy::mapToSource(const QModelIndexList& list) const -{ - QModelIndexList originalList; - foreach (const QModelIndex& index, list) { - QModelIndex originalIndex = mapToSource(index); - if (originalIndex.isValid()) - originalList << originalIndex; - } - return originalList; -} - -QModelIndex QtGroupingProxy::mapFromSource(const QModelIndex& idx) const -{ - if (!idx.isValid()) - return {}; - - QModelIndex proxyParent; - QModelIndex sourceParent = idx.parent(); - - int proxyRow = idx.row(); - int sourceRow = idx.row(); - - if (sourceParent.isValid() && (sourceParent != m_rootNode)) { - // idx is a child of one of the items in the source model - proxyParent = mapFromSource(sourceParent); - } else { - // idx is an item in the top level of the source model (child of the rootnode) - int groupRow = -1; - QMapIterator> iterator(m_groupMap); - while (iterator.hasNext()) { - iterator.next(); - if (iterator.value().contains(sourceRow)) { - groupRow = iterator.key(); - break; - } - } - - if (groupRow != -1) // it's in a group, let's find the correct row. - { - proxyParent = this->index(groupRow, 0, QModelIndex()); - proxyRow = m_groupMap.value(groupRow).indexOf(sourceRow); - } else { - proxyParent = QModelIndex(); - // if the proxy item is not in a group it will be below the groups. - int groupLength = m_groupMaps.count(); - int i = m_groupMap.value(std::numeric_limits::max()).indexOf(sourceRow); - - proxyRow = groupLength + i; - } - } - - return this->index(proxyRow, idx.column(), proxyParent); -} - -Qt::ItemFlags QtGroupingProxy::flags(const QModelIndex& idx) const -{ - if (!idx.isValid()) { - Qt::ItemFlags rootFlags = sourceModel()->flags(m_rootNode); - if (rootFlags.testFlag(Qt::ItemIsDropEnabled)) - return {Qt::ItemIsDropEnabled}; - - return {0}; - } - - // only if the grouped column has the editable flag set allow the - // actions leading to setData on the source (edit & drop) - if (isGroup(idx)) { - // dumpGroups(); - Qt::ItemFlags defaultFlags(Qt::ItemIsEnabled | Qt::ItemIsSelectable); - // Qt::ItemFlags defaultFlags(Qt::ItemIsEnabled); - bool groupIsEditable = true; - - if (idx.column() == 0) { - bool checkable = true; - foreach (int originalRow, m_groupMap.value(idx.row())) { - QModelIndex originalIdx = - sourceModel()->index(originalRow, 0, m_rootNode.parent()); - if ((originalIdx.flags() & Qt::ItemIsUserCheckable) == 0) { - checkable = false; - } - } - - if (checkable) { - defaultFlags |= Qt::ItemIsUserCheckable; - } - } - - // it's possible to have empty groups - if (m_groupMap.value(idx.row()).count() == 0) { - // check the flags of this column with the root node - QModelIndex originalRootNode = - sourceModel()->index(m_rootNode.row(), m_groupedColumn, m_rootNode.parent()); - groupIsEditable = originalRootNode.flags().testFlag(Qt::ItemIsEditable); - } else { - foreach (int originalRow, m_groupMap.value(idx.row())) { - QModelIndex originalIdx = - sourceModel()->index(originalRow, m_groupedColumn, m_rootNode); - - groupIsEditable = - groupIsEditable ? originalIdx.flags().testFlag(Qt::ItemIsEditable) : false; - if (!groupIsEditable) // all children need to have an editable grouped column - break; - } - } - if (groupIsEditable) - return (defaultFlags | Qt::ItemIsEditable | Qt::ItemIsDropEnabled); - return defaultFlags; - } - - QModelIndex originalIdx = mapToSource(idx); - Qt::ItemFlags originalItemFlags = sourceModel()->flags(originalIdx); - - // check the source model to see if the grouped column is editable; - QModelIndex groupedColumnIndex = - sourceModel()->index(originalIdx.row(), m_groupedColumn, originalIdx.parent()); - bool groupIsEditable = - sourceModel()->flags(groupedColumnIndex).testFlag(Qt::ItemIsEditable); - - if (groupIsEditable) - return originalItemFlags | Qt::ItemIsDragEnabled; - return originalItemFlags; -} - -QVariant QtGroupingProxy::headerData(int section, Qt::Orientation orientation, - int role) const -{ - return sourceModel()->headerData(section, orientation, role); -} - -bool QtGroupingProxy::canFetchMore(const QModelIndex& parent) const -{ - if (!parent.isValid()) - return false; - - if (isGroup(parent)) - return false; - - return sourceModel()->canFetchMore(mapToSource(parent)); -} - -void QtGroupingProxy::fetchMore(const QModelIndex& parent) -{ - if (!parent.isValid()) - return; - - if (isGroup(parent)) - return; - - return sourceModel()->fetchMore(mapToSource(parent)); -} - -QModelIndex QtGroupingProxy::addEmptyGroup(const RowData& data) -{ - int newRow = m_groupMaps.count(); - beginInsertRows(QModelIndex(), newRow, newRow); - m_groupMaps << data; - endInsertRows(); - return index(newRow, 0, QModelIndex()); -} - -bool QtGroupingProxy::removeGroup(const QModelIndex& idx) -{ - beginRemoveRows(idx.parent(), idx.row(), idx.row()); - m_groupMap.remove(idx.row()); - m_groupMaps.removeAt(idx.row()); - m_parentCreateList.removeAt(idx.internalId()); - endRemoveRows(); - - // TODO: only true if all data could be unset. - return true; -} - -bool QtGroupingProxy::hasChildren(const QModelIndex& parent) const -{ - if (!parent.isValid()) { - return true; - } - - if (isGroup(parent)) { - return !m_groupMap.value(parent.row()).isEmpty(); - } - - return sourceModel()->hasChildren(mapToSource(parent)); -} - -bool QtGroupingProxy::dropMimeData(const QMimeData* data, Qt::DropAction action, - int row, int column, const QModelIndex& parent) -{ - QModelIndex idx = index(row, column, parent); - if (isGroup(idx)) { - QList childRows = m_groupMap.value(idx.row()); - int max = *std::max_element(childRows.begin(), childRows.end()); - - QModelIndex newIdx = mapToSource(index(max, column, idx)); - return sourceModel()->dropMimeData(data, action, max, column, newIdx); - } else { - if (row == -1) { - return sourceModel()->dropMimeData(data, action, -1, -1, mapToSource(parent)); - } else { - QModelIndex idx = mapToSource(index(row, column, parent)); - return sourceModel()->dropMimeData(data, action, idx.row(), idx.column(), - idx.parent()); - } - } -} - -void QtGroupingProxy::modelRowsAboutToBeInserted(const QModelIndex& parent, int start, - int end) -{ - if (parent != m_rootNode) { - // an item will be added to an original index, remap and pass it on - QModelIndex proxyParent = mapFromSource(parent); - beginInsertRows(proxyParent, start, end); - } -} - -void QtGroupingProxy::modelRowsInserted(const QModelIndex& parent, int start, int end) -{ - if (parent == m_rootNode) { - // top level of the model changed, these new rows need to be put in groups - for (int modelRow = start; modelRow <= end; modelRow++) { - addSourceRow(sourceModel()->index(modelRow, m_groupedColumn, m_rootNode)); - } - } else { - // an item was added to an original index, remap and pass it on - QModelIndex proxyParent = mapFromSource(parent); - - QString s; - QDebug debug(&s); - debug << proxyParent; - log::debug("{}", s); - - // beginInsertRows had to be called in modelRowsAboutToBeInserted() - endInsertRows(); - } -} - -void QtGroupingProxy::modelRowsAboutToBeRemoved(const QModelIndex& parent, int start, - int end) -{ - if (parent == m_rootNode) { - QMap>::const_iterator i; - // HACK, we are going to call beginRemoveRows() multiple times without - // endRemoveRows() if a source index is in multiple groups. - // This can be a problem for some views/proxies, but Q*Views can handle it. - // TODO: investigate a queue for applying proxy model changes in the correct order - for (i = m_groupMap.constBegin(); i != m_groupMap.constEnd(); ++i) { - int groupIndex = i.key(); - const QList& groupList = i.value(); - QModelIndex proxyParent = index(groupIndex, 0); - foreach (int originalRow, groupList) { - if (originalRow >= start && originalRow <= end) { - int proxyRow = groupList.indexOf(originalRow); - if (groupIndex == -1) // adjust for non-grouped (root level) original items - proxyRow += m_groupMaps.count(); - // TODO: optimize for continues original rows in the same group - beginRemoveRows(proxyParent, proxyRow, proxyRow); - } - } - } - } else { - // child item(s) of an original item will be removed, remap and pass it on - QModelIndex proxyParent = mapFromSource(parent); - beginRemoveRows(proxyParent, start, end); - } -} - -void QtGroupingProxy::modelRowsRemoved(const QModelIndex& parent, int start, int end) -{ - if (parent == m_rootNode) { - // TODO: can be optimised by iterating over m_groupMap and checking start <= r < end - - // rather than increasing i we change the stored sourceRows in-place and reuse - // argument start X-times (where X = end - start). - for (int i = start; i <= end; i++) { - // HACK: we are going to iterate the hash in reverse so calls to endRemoveRows() - // are matched up with the beginRemoveRows() in modelRowsAboutToBeRemoved() - // NOTE: easier to do reverse with java style iterator - QMutableMapIterator> iter(m_groupMap); - iter.toBack(); - while (iter.hasPrevious()) { - iter.previous(); - int groupIndex = iter.key(); - // has to be a modifiable reference for remove and replace operations - QList& groupList = iter.value(); - int rowIndex = groupList.indexOf(start); - if (rowIndex != -1) { - QModelIndex proxyParent = index(groupIndex, 0); - groupList.removeAt(rowIndex); - } - // Now decrement all source rows that are after the removed row - for (int j = 0; j < groupList.count(); j++) { - int sourceRow = groupList.at(j); - if (sourceRow > start) - groupList.replace(j, sourceRow - 1); - } - if (rowIndex != -1) - endRemoveRows(); // end remove operation only after group was updated. - } - } - - return; - } - - // beginRemoveRows had to be called in modelRowsAboutToBeRemoved(); - endRemoveRows(); -} - -void QtGroupingProxy::resetModel() -{ - buildTree(); -} - -void QtGroupingProxy::modelDataChanged(const QModelIndex& topLeft, - const QModelIndex& bottomRight) -{ - // TODO: need to look in the groupedColumn and see if it changed and changed grouping - // accordingly - QModelIndex proxyTopLeft = mapFromSource(topLeft); - if (!proxyTopLeft.isValid()) - return; - - if (topLeft == bottomRight) { - emit dataChanged(proxyTopLeft, proxyTopLeft); - } else { - QModelIndex proxyBottomRight = mapFromSource(bottomRight); - emit dataChanged(proxyTopLeft, proxyBottomRight); - } -} - -bool QtGroupingProxy::isAGroupSelected(const QModelIndexList& list) const -{ - foreach (const QModelIndex& index, list) { - if (isGroup(index)) - return true; - } - return false; -} - -void QtGroupingProxy::dumpGroups() const -{ - QString s; - QDebug debug(&s); - - debug << "m_groupMap:\n"; - for (int groupIndex = -1; groupIndex < m_groupMap.keys().count() - 1; groupIndex++) { - debug << groupIndex << " : " << m_groupMap.value(groupIndex) << "\n"; - } - - debug << "m_groupMaps:\n"; - for (int groupIndex = 0; groupIndex < m_groupMaps.count(); groupIndex++) { - debug << m_groupMaps[groupIndex] << ": " << m_groupMap.value(groupIndex) << "\n"; - } - - debug << m_groupMap.value(std::numeric_limits::max()); - - log::debug("{}", s); -} +/**************************************************************************************** + * Copyright (c) 2007-2011 Bart Cerneels * + * * + * This program is free software; you can redistribute it and/or modify it under * the + *terms of the GNU General Public License as published by the Free Software * + * Foundation; either version 2 of the License, or (at your option) any later * version. + ** + * * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY * + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A * + * PARTICULAR PURPOSE. See the GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License along with * this + *program. If not, see . * + ****************************************************************************************/ + +// Modifications 2013-03-27 to 2013-03-29 by Sebastian Herbord + +#include "qtgroupingproxy.h" +#include + +#include +#include +#include + +using namespace MOBase; + +/*! + \class QtGroupingProxy + \brief The QtGroupingProxy class will group source model rows by adding a new top + tree-level. The source model can be flat or tree organized, but only the original top + level rows are used for determining the grouping. \ingroup model-view +*/ + +QtGroupingProxy::QtGroupingProxy(QModelIndex rootNode, int groupedColumn, + int groupedRole, unsigned int flags, int aggregateRole) + : m_rootNode(rootNode), + m_groupedRole(groupedRole), m_aggregateRole(aggregateRole), m_flags(flags) +{ + if (groupedColumn != -1) { + setGroupedColumn(groupedColumn); + } +} + +QtGroupingProxy::~QtGroupingProxy() = default; + +void QtGroupingProxy::setSourceModel(QAbstractItemModel* model) +{ + if (sourceModel()) { + disconnect(sourceModel(), nullptr, this, nullptr); + } + + QAbstractProxyModel::setSourceModel(model); + + if (sourceModel()) { + // signal proxies + connect(sourceModel(), SIGNAL(rowsInserted(const QModelIndex&, int, int)), + SLOT(modelRowsInserted(const QModelIndex&, int, int))); + connect(sourceModel(), SIGNAL(rowsAboutToBeInserted(const QModelIndex&, int, int)), + SLOT(modelRowsAboutToBeInserted(const QModelIndex&, int, int))); + connect(sourceModel(), SIGNAL(rowsRemoved(const QModelIndex&, int, int)), + SLOT(modelRowsRemoved(const QModelIndex&, int, int))); + connect(sourceModel(), SIGNAL(rowsAboutToBeRemoved(const QModelIndex&, int, int)), + SLOT(modelRowsAboutToBeRemoved(QModelIndex, int, int))); + connect(sourceModel(), SIGNAL(layoutChanged()), SLOT(buildTree())); + connect(sourceModel(), SIGNAL(dataChanged(QModelIndex, QModelIndex)), + SLOT(modelDataChanged(QModelIndex, QModelIndex))); + connect(sourceModel(), SIGNAL(modelReset()), this, SLOT(resetModel())); + + buildTree(); + } +} + +void QtGroupingProxy::setGroupedColumn(int groupedColumn) +{ + m_groupedColumn = groupedColumn; + buildTree(); +} + +/** Maps to what groups the source row belongs by returning the data of those groups. + * + * @returns a list of data for the rows the argument belongs to. In common cases this + * list will contain only one entry. An empty list means that the source item will be + * placed in the root of this proxyModel. There is no support for hiding source items. + * + * Group data can be pre-loaded in the return value so it's added to the cache + * maintained by this class. This is required if you want to have data that is not + * present in the source model. + */ +QList QtGroupingProxy::belongsTo(const QModelIndex& idx) +{ + QList rowDataList; + + // get all the data for this index from the model + ItemData itemData = sourceModel()->itemData(idx); + if (m_groupedRole != Qt::DisplayRole) { + itemData[Qt::DisplayRole] = itemData[m_groupedRole]; + } + + // invalid value in grouped role -> ungrouped + if (!itemData[Qt::DisplayRole].isValid()) { + return rowDataList; + } + + QMapIterator i(itemData); + while (i.hasNext()) { + i.next(); + int role = i.key(); + QVariant variant = i.value(); + + if (variant.typeId() == QMetaType::QVariantList) { + // a list of variants get's expanded to multiple rows + QVariantList list = variant.toList(); + for (int i = 0; i < list.length(); i++) { + // take an existing row data or create a new one + RowData rowData = (rowDataList.count() > i) ? rowDataList.takeAt(i) : RowData(); + + // we only gather data for the first column + ItemData indexData = rowData.contains(0) ? rowData.take(0) : ItemData(); + indexData.insert(role, list.value(i)); + rowData.insert(0, indexData); + // for the grouped column the data should not be gathered from the children + // this will allow filtering on the content of this column with a + // QSortFilterProxyModel + rowData.insert(m_groupedColumn, indexData); + rowDataList.insert(i, rowData); + } + break; + } else if (!variant.isNull()) { + // it's just a normal item. Copy all the data and break this loop. + RowData rowData; + rowData.insert(0, itemData); + rowDataList << rowData; + break; + } + } + + return rowDataList; +} + +/* m_groupMap layout + * key : index of the group in m_groupMaps + * value : a QList of the original rows in sourceModel() for the children of this group + * + * key = -1 contains a QList of the non-grouped indexes + * + * TODO: sub-groups + */ +void QtGroupingProxy::buildTree() +{ + if (!sourceModel()) + return; + beginResetModel(); + + m_groupMap.clear(); + // don't clear the data maps since most of it will probably be needed again. + m_parentCreateList.clear(); + + int max = sourceModel()->rowCount(m_rootNode); + + // WARNING: these have to be added in order because the addToGroups function is + // optimized for modelRowsInserted(). Failure to do so will result in wrong data shown + // in the view at best. + for (int row = 0; row < max; row++) { + QModelIndex idx = sourceModel()->index(row, m_groupedColumn, m_rootNode); + addSourceRow(idx); + } + // dumpGroups(); + + if (m_flags & FLAG_NOSINGLE) { + // awkward: flatten single-item groups as a post-processing steps. + + int currentKey = 0; + quint32 quint32max = std::numeric_limits::max(); + std::vector rmgroups; + + QMap> temp; + + for (auto iter = m_groupMap.begin(); iter != m_groupMap.end(); ++iter) { + if ((iter.key() == quint32max) || (iter->count() < 2)) { + temp[quint32max].append(iter.value()); + if (iter.key() != quint32max) { + rmgroups.push_back(iter.key()); + } + } else { + temp[currentKey++] = *iter; + } + } + m_groupMap = temp; + + // second loop is necessary because qt containers can't be iterated from end to + // front and removing by index from begin to end is ugly + std::sort(rmgroups.begin(), rmgroups.end(), [](int lhs, int rhs) { + return rhs < lhs; + }); + for (auto iter = rmgroups.begin(); iter != rmgroups.end(); ++iter) { + m_groupMaps.removeAt(*iter); + } + } + + endResetModel(); +} + +QList QtGroupingProxy::addSourceRow(const QModelIndex& idx) +{ + QList updatedGroups; + QList groupData = belongsTo(idx); + + // an empty list here means it's supposed to go in root. + if (groupData.isEmpty()) { + updatedGroups << -1; + if (!m_groupMap.keys().contains(std::numeric_limits::max())) + m_groupMap.insert(std::numeric_limits::max(), + QList()); // add an empty placeholder + } + + // an item can be in multiple groups + foreach (RowData data, groupData) { + int updatedGroup = -1; + if (!data.isEmpty()) { + foreach (const RowData& cachedData, m_groupMaps) { + // when this matches the index belongs to an existing group + if (data[0][Qt::DisplayRole] == cachedData[0][Qt::DisplayRole]) { + data = cachedData; + break; + } + } + + updatedGroup = m_groupMaps.indexOf(data); + //-1 means not found + if (updatedGroup == -1) { + // new groups are added to the end of the existing list + m_groupMaps << data; + updatedGroup = m_groupMaps.count() - 1; + } + + if (!m_groupMap.keys().contains(updatedGroup)) + m_groupMap.insert(updatedGroup, QList()); // add an empty placeholder + } + + if (!updatedGroups.contains(updatedGroup)) + updatedGroups << updatedGroup; + } + + // update m_groupMap to the new source-model layout (one row added) + QMutableMapIterator> i(m_groupMap); + while (i.hasNext()) { + i.next(); + QList& groupList = i.value(); + int insertedProxyRow = groupList.count(); + for (; insertedProxyRow > 0; insertedProxyRow--) { + int& rowValue = groupList[insertedProxyRow - 1]; + if (idx.row() <= rowValue) { + // increment the rows that come after the new row since they moved one place up. + rowValue++; + } else { + break; + } + } + + if (updatedGroups.contains(i.key())) { + // the row needs to be added to this group + groupList.insert(insertedProxyRow, idx.row()); + } + } + + return updatedGroups; +} + +/** Each ModelIndex has in it's internalId a position in the parentCreateList. + * struct ParentCreate are the instructions to recreate the parent index. + * It contains the proxy row number of the parent and the postion in this list of the + * grandfather. This function creates the ParentCreate structs and saves them in a list. + */ +int QtGroupingProxy::indexOfParentCreate(const QModelIndex& parent) const +{ + if (!parent.isValid()) + return -1; + + struct ParentCreate pc; + for (int i = 0; i < m_parentCreateList.size(); i++) { + pc = m_parentCreateList[i]; + if (pc.parentCreateIndex == parent.internalId() && pc.row == parent.row()) + return i; + } + // there is no parentCreate yet for this index, so let's create one. + pc.parentCreateIndex = parent.internalId(); + pc.row = parent.row(); + m_parentCreateList << pc; + + return m_parentCreateList.size() - 1; +} + +QModelIndex QtGroupingProxy::index(int row, int column, const QModelIndex& parent) const +{ + if (!hasIndex(row, column, parent)) { + return {}; + } + + if (parent.column() > 0) { + return {}; + } + + /* We save the instructions to make the parent of the index in a struct. + * The place of the struct in the list is stored in the internalId + */ + int parentCreateIndex = indexOfParentCreate(parent); + + return createIndex(row, column, parentCreateIndex); +} + +QModelIndex QtGroupingProxy::parent(const QModelIndex& index) const +{ + if (!index.isValid()) + return {}; + + int parentCreateIndex = index.internalId(); + if (parentCreateIndex == -1 || parentCreateIndex >= m_parentCreateList.count()) + return {}; + + struct ParentCreate pc = m_parentCreateList[parentCreateIndex]; + + // only items at column 0 have children + return createIndex(pc.row, 0, pc.parentCreateIndex); +} + +int QtGroupingProxy::rowCount(const QModelIndex& index) const +{ + if (!index.isValid()) { + // the number of top level groups + the number of non-grouped items + int rows = m_groupMaps.count() + + m_groupMap.value(std::numeric_limits::max()).count(); + return rows; + } + + // TODO:group in group support. + if (isGroup(index)) { + qint64 groupIndex = index.row(); + int rows = m_groupMap.value(groupIndex).count(); + return rows; + } else { + QModelIndex originalIndex = mapToSource(index); + int rowCount = sourceModel()->rowCount(originalIndex); + return rowCount; + } +} + +int QtGroupingProxy::columnCount(const QModelIndex& index) const +{ + if (!index.isValid()) + return sourceModel()->columnCount(m_rootNode); + + if (index.column() != 0) + return 0; + + return sourceModel()->columnCount(mapToSource(index)); +} + +static bool variantLess(const QVariant& LHS, const QVariant& RHS) +{ + if ((LHS.typeId() == RHS.typeId()) && + ((LHS.typeId() == QMetaType::Int) || (LHS.typeId() == QMetaType::UInt))) { + return LHS.toInt() < RHS.toInt(); + } + + // this should always work (comparing empty strings in the worst case) but + // the results may be wrong + return LHS.toString() < RHS.toString(); +} + +static QVariant variantMax(const QVariantList& variants) +{ + QVariant result = variants.first(); + foreach (const QVariant& iter, variants) { + if (variantLess(result, iter)) { + result = iter; + } + } + return result; +} + +static QVariant variantMin(const QVariantList& variants) +{ + QVariant result = variants.first(); + foreach (const QVariant& iter, variants) { + if (variantLess(iter, result)) { + result = iter; + } + } + return result; +} + +QVariant QtGroupingProxy::data(const QModelIndex& index, int role) const +{ + if (!index.isValid()) + return {}; + + int row = index.row(); + int column = index.column(); + if (isGroup(index)) { + if ((role != Qt::DisplayRole) && (role != Qt::EditRole)) { + switch (role) { + case Qt::ForegroundRole: { + return QBrush(Qt::gray); + } break; + case Qt::FontRole: { + QFont font(m_groupMaps[row][column].value(Qt::FontRole).value()); + font.setItalic(true); + return font; + } break; + case Qt::TextAlignmentRole: { + return Qt::AlignHCenter; + } break; + case Qt::UserRole: { + return m_groupMaps[row][column].value(Qt::DisplayRole).toString(); + } break; + case Qt::CheckStateRole: { + if (column != 0) + return {}; + int childCount = m_groupMap.value(row).count(); + int checked = 0; + QModelIndex parentIndex = this->index(row, 0, index.parent()); + for (int childRow = 0; childRow < childCount; ++childRow) { + QModelIndex childIndex = this->index(childRow, 0, parentIndex); + QVariant data = mapToSource(childIndex).data(Qt::CheckStateRole); + if (data.toInt() == 2) + ++checked; + } + if (checked == childCount) + return Qt::Checked; + else if (checked == 0) + return Qt::Unchecked; + else + return Qt::PartiallyChecked; + } break; + default: { + QModelIndex parentIndex = this->index(row, 0, index.parent()); + if (m_groupMap.value(row).count() > 0) { + return this->index(0, column, parentIndex).data(role); + } else { + return {}; + } + // return m_groupMaps[row][column].value( role ); + } break; + } + } + + // use cached or precalculated data + if (m_groupMaps[row][column].contains(Qt::DisplayRole)) { + if ((m_flags & FLAG_NOGROUPNAME) != 0) { + QModelIndex parentIndex = this->index(row, 0, index.parent()); + QModelIndex childIndex = this->index(0, column, parentIndex); + return childIndex.data(role).toString(); + } else { + return m_groupMaps[row][column].value(role).toString(); + } + } + + // for column 0 we gather data from the grouped column instead + if (column == 0) + column = m_groupedColumn; + + // map all data from children to columns of group to allow grouping one level up + QVariantList variantsOfChildren; + int childCount = m_groupMap.value(row).count(); + if (childCount == 0) + return {}; + + int function = AGGR_NONE; + if (m_aggregateRole >= Qt::UserRole) { + QModelIndex parentIndex = this->index(row, 0, index.parent()); + QModelIndex childIndex = this->index(0, column, parentIndex); + function = mapToSource(childIndex).data(m_aggregateRole).toInt(); + } + + // Need a parentIndex with column == 0 because only those have children. + QModelIndex parentIndex = this->index(row, 0, index.parent()); + for (int childRow = 0; childRow < childCount; childRow++) { + QModelIndex childIndex = this->index(childRow, column, parentIndex); + QVariant data = mapToSource(childIndex).data(role); + + if (data.isValid() && !variantsOfChildren.contains(data)) + variantsOfChildren << data; + } + + // saving in cache + ItemData roleMap = m_groupMaps[row].value(column); + foreach (const QVariant& variant, variantsOfChildren) { + if (roleMap[role] != variant) { + roleMap.insert(role, variantsOfChildren); + } + } + + if (variantsOfChildren.count() == 0) + return {}; + + // only one unique variant? No need to return a list + switch (function) { + case AGGR_EMPTY: + return {}; + case AGGR_FIRST: + return variantsOfChildren.first(); + case AGGR_MAX: + return variantMax(variantsOfChildren); + case AGGR_MIN: + return variantMin(variantsOfChildren); + default: { + if (variantsOfChildren.count() == 1) + return variantsOfChildren.first(); + + return variantsOfChildren; + } break; + } + } + + return mapToSource(index).data(role); +} + +bool QtGroupingProxy::setData(const QModelIndex& idx, const QVariant& value, int role) +{ + if (!idx.isValid()) + return false; + + // no need to set data to exactly the same value + if (idx.data(role) == value) + return false; + + if (isGroup(idx)) { + ItemData columnData = m_groupMaps[idx.row()][idx.column()]; + + columnData.insert(role, value); + // QItemDelegate will always use Qt::EditRole + if (role == Qt::EditRole) + columnData.insert(Qt::DisplayRole, value); + + // and make sure it's stored in the map + m_groupMaps[idx.row()].insert(idx.column(), columnData); + + int columnToChange = idx.column() ? idx.column() : m_groupedColumn; + foreach (int originalRow, m_groupMap.value(idx.row())) { + QModelIndex childIdx = + sourceModel()->index(originalRow, columnToChange, m_rootNode); + if (childIdx.isValid()) + sourceModel()->setData(childIdx, value, role); + } + // TODO: we might need to reload the data from the children at this point + + emit dataChanged(idx, idx); + return true; + } + + return sourceModel()->setData(mapToSource(idx), value, role); +} + +bool QtGroupingProxy::isGroup(const QModelIndex& index) const +{ + int parentCreateIndex = index.internalId(); + return parentCreateIndex == -1 && index.row() < m_groupMaps.count(); +} + +QModelIndex QtGroupingProxy::mapToSource(const QModelIndex& index) const +{ + if (!index.isValid()) { + return m_rootNode; + } + + if (isGroup(index)) { + return m_rootNode; + } + + QModelIndex proxyParent = index.parent(); + QModelIndex originalParent = mapToSource(proxyParent); + + int originalRow = index.row(); + if (originalParent == m_rootNode) { + int indexInGroup = index.row(); + if (!proxyParent.isValid()) + indexInGroup -= m_groupMaps.count(); + + QList childRows = m_groupMap.value(proxyParent.row()); + if (childRows.isEmpty() || indexInGroup >= childRows.count() || indexInGroup < 0) + return {}; + + originalRow = childRows.at(indexInGroup); + } + return sourceModel()->index(originalRow, index.column(), originalParent); +} + +QModelIndexList QtGroupingProxy::mapToSource(const QModelIndexList& list) const +{ + QModelIndexList originalList; + foreach (const QModelIndex& index, list) { + QModelIndex originalIndex = mapToSource(index); + if (originalIndex.isValid()) + originalList << originalIndex; + } + return originalList; +} + +QModelIndex QtGroupingProxy::mapFromSource(const QModelIndex& idx) const +{ + if (!idx.isValid()) + return {}; + + QModelIndex proxyParent; + QModelIndex sourceParent = idx.parent(); + + int proxyRow = idx.row(); + int sourceRow = idx.row(); + + if (sourceParent.isValid() && (sourceParent != m_rootNode)) { + // idx is a child of one of the items in the source model + proxyParent = mapFromSource(sourceParent); + } else { + // idx is an item in the top level of the source model (child of the rootnode) + int groupRow = -1; + QMapIterator> iterator(m_groupMap); + while (iterator.hasNext()) { + iterator.next(); + if (iterator.value().contains(sourceRow)) { + groupRow = iterator.key(); + break; + } + } + + if (groupRow != -1) // it's in a group, let's find the correct row. + { + proxyParent = this->index(groupRow, 0, QModelIndex()); + proxyRow = m_groupMap.value(groupRow).indexOf(sourceRow); + } else { + proxyParent = QModelIndex(); + // if the proxy item is not in a group it will be below the groups. + int groupLength = m_groupMaps.count(); + int i = m_groupMap.value(std::numeric_limits::max()).indexOf(sourceRow); + + proxyRow = groupLength + i; + } + } + + return this->index(proxyRow, idx.column(), proxyParent); +} + +Qt::ItemFlags QtGroupingProxy::flags(const QModelIndex& idx) const +{ + if (!idx.isValid()) { + Qt::ItemFlags rootFlags = sourceModel()->flags(m_rootNode); + if (rootFlags.testFlag(Qt::ItemIsDropEnabled)) + return {Qt::ItemIsDropEnabled}; + + return {0}; + } + + // only if the grouped column has the editable flag set allow the + // actions leading to setData on the source (edit & drop) + if (isGroup(idx)) { + // dumpGroups(); + Qt::ItemFlags defaultFlags(Qt::ItemIsEnabled | Qt::ItemIsSelectable); + // Qt::ItemFlags defaultFlags(Qt::ItemIsEnabled); + bool groupIsEditable = true; + + if (idx.column() == 0) { + bool checkable = true; + foreach (int originalRow, m_groupMap.value(idx.row())) { + QModelIndex originalIdx = + sourceModel()->index(originalRow, 0, m_rootNode.parent()); + if ((originalIdx.flags() & Qt::ItemIsUserCheckable) == 0) { + checkable = false; + } + } + + if (checkable) { + defaultFlags |= Qt::ItemIsUserCheckable; + } + } + + // it's possible to have empty groups + if (m_groupMap.value(idx.row()).count() == 0) { + // check the flags of this column with the root node + QModelIndex originalRootNode = + sourceModel()->index(m_rootNode.row(), m_groupedColumn, m_rootNode.parent()); + groupIsEditable = originalRootNode.flags().testFlag(Qt::ItemIsEditable); + } else { + foreach (int originalRow, m_groupMap.value(idx.row())) { + QModelIndex originalIdx = + sourceModel()->index(originalRow, m_groupedColumn, m_rootNode); + + groupIsEditable = + groupIsEditable ? originalIdx.flags().testFlag(Qt::ItemIsEditable) : false; + if (!groupIsEditable) // all children need to have an editable grouped column + break; + } + } + if (groupIsEditable) + return (defaultFlags | Qt::ItemIsEditable | Qt::ItemIsDropEnabled); + return defaultFlags; + } + + QModelIndex originalIdx = mapToSource(idx); + Qt::ItemFlags originalItemFlags = sourceModel()->flags(originalIdx); + + // check the source model to see if the grouped column is editable; + QModelIndex groupedColumnIndex = + sourceModel()->index(originalIdx.row(), m_groupedColumn, originalIdx.parent()); + bool groupIsEditable = + sourceModel()->flags(groupedColumnIndex).testFlag(Qt::ItemIsEditable); + + if (groupIsEditable) + return originalItemFlags | Qt::ItemIsDragEnabled; + return originalItemFlags; +} + +QVariant QtGroupingProxy::headerData(int section, Qt::Orientation orientation, + int role) const +{ + return sourceModel()->headerData(section, orientation, role); +} + +bool QtGroupingProxy::canFetchMore(const QModelIndex& parent) const +{ + if (!parent.isValid()) + return false; + + if (isGroup(parent)) + return false; + + return sourceModel()->canFetchMore(mapToSource(parent)); +} + +void QtGroupingProxy::fetchMore(const QModelIndex& parent) +{ + if (!parent.isValid()) + return; + + if (isGroup(parent)) + return; + + return sourceModel()->fetchMore(mapToSource(parent)); +} + +QModelIndex QtGroupingProxy::addEmptyGroup(const RowData& data) +{ + int newRow = m_groupMaps.count(); + beginInsertRows(QModelIndex(), newRow, newRow); + m_groupMaps << data; + endInsertRows(); + return index(newRow, 0, QModelIndex()); +} + +bool QtGroupingProxy::removeGroup(const QModelIndex& idx) +{ + beginRemoveRows(idx.parent(), idx.row(), idx.row()); + m_groupMap.remove(idx.row()); + m_groupMaps.removeAt(idx.row()); + m_parentCreateList.removeAt(idx.internalId()); + endRemoveRows(); + + // TODO: only true if all data could be unset. + return true; +} + +bool QtGroupingProxy::hasChildren(const QModelIndex& parent) const +{ + if (!parent.isValid()) { + return true; + } + + if (isGroup(parent)) { + return !m_groupMap.value(parent.row()).isEmpty(); + } + + return sourceModel()->hasChildren(mapToSource(parent)); +} + +bool QtGroupingProxy::dropMimeData(const QMimeData* data, Qt::DropAction action, + int row, int column, const QModelIndex& parent) +{ + QModelIndex idx = index(row, column, parent); + if (isGroup(idx)) { + QList childRows = m_groupMap.value(idx.row()); + int max = *std::max_element(childRows.begin(), childRows.end()); + + QModelIndex newIdx = mapToSource(index(max, column, idx)); + return sourceModel()->dropMimeData(data, action, max, column, newIdx); + } else { + if (row == -1) { + return sourceModel()->dropMimeData(data, action, -1, -1, mapToSource(parent)); + } else { + QModelIndex idx = mapToSource(index(row, column, parent)); + return sourceModel()->dropMimeData(data, action, idx.row(), idx.column(), + idx.parent()); + } + } +} + +void QtGroupingProxy::modelRowsAboutToBeInserted(const QModelIndex& parent, int start, + int end) +{ + if (parent != m_rootNode) { + // an item will be added to an original index, remap and pass it on + QModelIndex proxyParent = mapFromSource(parent); + beginInsertRows(proxyParent, start, end); + } +} + +void QtGroupingProxy::modelRowsInserted(const QModelIndex& parent, int start, int end) +{ + if (parent == m_rootNode) { + // top level of the model changed, these new rows need to be put in groups + for (int modelRow = start; modelRow <= end; modelRow++) { + addSourceRow(sourceModel()->index(modelRow, m_groupedColumn, m_rootNode)); + } + } else { + // an item was added to an original index, remap and pass it on + QModelIndex proxyParent = mapFromSource(parent); + + QString s; + QDebug debug(&s); + debug << proxyParent; + log::debug("{}", s); + + // beginInsertRows had to be called in modelRowsAboutToBeInserted() + endInsertRows(); + } +} + +void QtGroupingProxy::modelRowsAboutToBeRemoved(const QModelIndex& parent, int start, + int end) +{ + if (parent == m_rootNode) { + QMap>::const_iterator i; + // HACK, we are going to call beginRemoveRows() multiple times without + // endRemoveRows() if a source index is in multiple groups. + // This can be a problem for some views/proxies, but Q*Views can handle it. + // TODO: investigate a queue for applying proxy model changes in the correct order + for (i = m_groupMap.constBegin(); i != m_groupMap.constEnd(); ++i) { + int groupIndex = i.key(); + const QList& groupList = i.value(); + QModelIndex proxyParent = index(groupIndex, 0); + foreach (int originalRow, groupList) { + if (originalRow >= start && originalRow <= end) { + int proxyRow = groupList.indexOf(originalRow); + if (groupIndex == -1) // adjust for non-grouped (root level) original items + proxyRow += m_groupMaps.count(); + // TODO: optimize for continues original rows in the same group + beginRemoveRows(proxyParent, proxyRow, proxyRow); + } + } + } + } else { + // child item(s) of an original item will be removed, remap and pass it on + QModelIndex proxyParent = mapFromSource(parent); + beginRemoveRows(proxyParent, start, end); + } +} + +void QtGroupingProxy::modelRowsRemoved(const QModelIndex& parent, int start, int end) +{ + if (parent == m_rootNode) { + // TODO: can be optimised by iterating over m_groupMap and checking start <= r < end + + // rather than increasing i we change the stored sourceRows in-place and reuse + // argument start X-times (where X = end - start). + for (int i = start; i <= end; i++) { + // HACK: we are going to iterate the hash in reverse so calls to endRemoveRows() + // are matched up with the beginRemoveRows() in modelRowsAboutToBeRemoved() + // NOTE: easier to do reverse with java style iterator + QMutableMapIterator> iter(m_groupMap); + iter.toBack(); + while (iter.hasPrevious()) { + iter.previous(); + int groupIndex = iter.key(); + // has to be a modifiable reference for remove and replace operations + QList& groupList = iter.value(); + int rowIndex = groupList.indexOf(start); + if (rowIndex != -1) { + QModelIndex proxyParent = index(groupIndex, 0); + groupList.removeAt(rowIndex); + } + // Now decrement all source rows that are after the removed row + for (int j = 0; j < groupList.count(); j++) { + int sourceRow = groupList.at(j); + if (sourceRow > start) + groupList.replace(j, sourceRow - 1); + } + if (rowIndex != -1) + endRemoveRows(); // end remove operation only after group was updated. + } + } + + return; + } + + // beginRemoveRows had to be called in modelRowsAboutToBeRemoved(); + endRemoveRows(); +} + +void QtGroupingProxy::resetModel() +{ + buildTree(); +} + +void QtGroupingProxy::modelDataChanged(const QModelIndex& topLeft, + const QModelIndex& bottomRight) +{ + // TODO: need to look in the groupedColumn and see if it changed and changed grouping + // accordingly + QModelIndex proxyTopLeft = mapFromSource(topLeft); + if (!proxyTopLeft.isValid()) + return; + + if (topLeft == bottomRight) { + emit dataChanged(proxyTopLeft, proxyTopLeft); + } else { + QModelIndex proxyBottomRight = mapFromSource(bottomRight); + emit dataChanged(proxyTopLeft, proxyBottomRight); + } +} + +bool QtGroupingProxy::isAGroupSelected(const QModelIndexList& list) const +{ + foreach (const QModelIndex& index, list) { + if (isGroup(index)) + return true; + } + return false; +} + +void QtGroupingProxy::dumpGroups() const +{ + QString s; + QDebug debug(&s); + + debug << "m_groupMap:\n"; + for (int groupIndex = -1; groupIndex < m_groupMap.keys().count() - 1; groupIndex++) { + debug << groupIndex << " : " << m_groupMap.value(groupIndex) << "\n"; + } + + debug << "m_groupMaps:\n"; + for (int groupIndex = 0; groupIndex < m_groupMaps.count(); groupIndex++) { + debug << m_groupMaps[groupIndex] << ": " << m_groupMap.value(groupIndex) << "\n"; + } + + debug << m_groupMap.value(std::numeric_limits::max()); + + log::debug("{}", s); +} diff --git a/src/src/savestab.cpp b/src/src/savestab.cpp index 0798033..d757a83 100644 --- a/src/src/savestab.cpp +++ b/src/src/savestab.cpp @@ -95,7 +95,7 @@ bool isLikelyCorruptSaveText(QString const& in) SavesTab::SavesTab(QWidget* window, OrganizerCore& core, Ui::MainWindow* mwui) : m_window(window), m_core(core), m_CurrentSaveView(nullptr), - ui{mwui->tabWidget, mwui->savesTab, mwui->savegameList} + ui{.mainTabs=mwui->tabWidget, .tab=mwui->savesTab, .list=mwui->savegameList} { m_SavesWatcherTimer.setSingleShot(true); m_SavesWatcherTimer.setInterval(500); diff --git a/src/src/selfupdater.cpp b/src/src/selfupdater.cpp index 1a01771..9243084 100644 --- a/src/src/selfupdater.cpp +++ b/src/src/selfupdater.cpp @@ -1,354 +1,354 @@ -/* -Copyright (C) 2012 Sebastian Herbord. All rights reserved. - -This file is part of Mod Organizer. - -Mod Organizer is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -Mod Organizer is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with Mod Organizer. If not, see . -*/ - -#include "selfupdater.h" - -#include "bbcode.h" -#include "downloadmanager.h" -#include "iplugingame.h" -#include "messagedialog.h" -#include "nexusinterface.h" -#include "nxmaccessmanager.h" -#include "organizercore.h" -#include "plugincontainer.h" -#include "settings.h" -#include "shared/util.h" -#include "updatedialog.h" -#include "utility.h" -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include - -#include - -#include -#include -#include //for size_t -#include - -using namespace MOBase; -using namespace MOShared; - -SelfUpdater::SelfUpdater(NexusInterface* nexusInterface) - : m_Parent(nullptr), m_MOVersion(createVersionInfo()), m_Interface(nexusInterface), - m_Reply(nullptr), m_Attempts(3) -{} - -SelfUpdater::~SelfUpdater() = default; - -void SelfUpdater::setUserInterface(QWidget* widget) -{ - m_Parent = widget; -} - -void SelfUpdater::setPluginContainer(PluginContainer* pluginContainer) -{ - m_Interface->setPluginContainer(pluginContainer); -} - -void SelfUpdater::testForUpdate(const Settings& settings) -{ - // Fluorine Manager has its own update mechanism — skip the MO2 GitHub check. - (void)settings; - return; - - if (settings.network().offlineMode()) { - log::debug("not checking for updates, in offline mode"); - return; - } - - if (!settings.checkForUpdates()) { - log::debug("not checking for updates, disabled"); - return; - } - - // TODO: if prereleases are disabled we could just request the latest release - // directly - try { - m_GitHub.releases( - GitHub::Repository("Modorganizer2", "modorganizer"), - [this](const QJsonArray& releases) { - if (releases.isEmpty()) { - // error message already logged - return; - } - - // We store all releases: - CandidatesMap mreleases; - for (const QJsonValue& releaseVal : releases) { - QJsonObject release = releaseVal.toObject(); - if (!release["draft"].toBool() && (Settings::instance().usePrereleases() || - !release["prerelease"].toBool())) { - auto version = Version::parse(release["tag_name"].toString(), - Version::ParseMode::MO2); - mreleases[version] = release; - } - } - - if (!mreleases.empty()) { - auto lastKey = mreleases.begin()->first; - if (lastKey > this->m_MOVersion) { - - // Fill m_UpdateCandidates with version strictly greater than the - // current version: - m_UpdateCandidates.clear(); - for (auto p : mreleases) { - if (p.first > this->m_MOVersion) { - m_UpdateCandidates.insert(p); - } - } - log::info("update available: {} -> {}", this->m_MOVersion, lastKey); - emit updateAvailable(); - } else if (lastKey < this->m_MOVersion) { - // this could happen if the user switches from using prereleases to - // stable builds. Should we downgrade? - log::debug("This version is newer than the latest released one: {} -> {}", - this->m_MOVersion, lastKey); - } - } - }); - } - // Catch all is bad by design, should be improved - catch (...) { - log::debug("Unable to connect to github.com to check version"); - } -} - -void SelfUpdater::startUpdate() -{ - // the button can't be pressed if there isn't an update candidate - Q_ASSERT(!m_UpdateCandidates.empty()); - - auto latestRelease = m_UpdateCandidates.begin()->second; - - UpdateDialog dialog(m_Parent); - dialog.setVersions(MOShared::createVersionInfo().string(), - latestRelease["tag_name"].toString()); - - // We concatenate release details. We only include pre-release if those are - // the latest release: - QString details; - bool includePreRelease = true; - for (auto& p : m_UpdateCandidates) { - auto& release = p.second; - - // Ignore details for pre-release after a release has been found: - if (release["prerelease"].toBool() && !includePreRelease) { - continue; - } - - // Stop including pre-release as soon as we find a non-prerelease: - if (!release["prerelease"].toBool()) { - includePreRelease = false; - } - - details += "\n## " + release["name"].toString() + "\n---\n"; - details += release["body"].toString(); - } - - // Need to call setDetailedText to create the QTextEdit and then be able to retrieve - // it: - dialog.setChangeLogs(details); - - int res = dialog.exec(); - - if (dialog.result() == QDialog::Accepted) { - bool found = false; - for (const QJsonValue& assetVal : latestRelease["assets"].toArray()) { - QJsonObject asset = assetVal.toObject(); - if (asset["content_type"].toString() == "application/x-msdownload") { - openOutputFile(asset["name"].toString()); - download(asset["browser_download_url"].toString()); - found = true; - break; - } - } - if (!found) { - QMessageBox::warning( - m_Parent, tr("Download failed"), - tr("Failed to find correct download, please try again later.")); - } - } -} - -void SelfUpdater::showProgress() -{ - if (m_Progress == nullptr) { - m_Progress = new QProgressDialog(m_Parent, Qt::Dialog); - connect(m_Progress, SIGNAL(canceled()), this, SLOT(downloadCancel())); - } - m_Progress->setModal(true); - m_Progress->show(); - m_Progress->setValue(0); - m_Progress->setWindowTitle(tr("Update")); - m_Progress->setLabelText(tr("Download in progress")); -} - -void SelfUpdater::closeProgress() -{ - if (m_Progress != nullptr) { - m_Progress->hide(); - m_Progress->deleteLater(); - m_Progress = nullptr; - } -} - -void SelfUpdater::openOutputFile(const QString& fileName) -{ - QString outputPath = - QDir::fromNativeSeparators(qApp->property("dataPath").toString()) + "/" + - fileName; - log::debug("downloading to {}", outputPath); - m_UpdateFile.setFileName(outputPath); - if (!m_UpdateFile.open(QIODevice::WriteOnly)) { - log::error("failed to open update output file '{}': {}", outputPath, - m_UpdateFile.errorString()); - } -} - -void SelfUpdater::download(const QString& downloadLink) -{ - QNetworkAccessManager* accessManager = m_Interface->getAccessManager(); - QUrl dlUrl(downloadLink); - QNetworkRequest request(dlUrl); - m_Canceled = false; - m_Reply = accessManager->get(request); - showProgress(); - - connect(m_Reply, SIGNAL(downloadProgress(qint64, qint64)), this, - SLOT(downloadProgress(qint64, qint64))); - connect(m_Reply, SIGNAL(finished()), this, SLOT(downloadFinished())); - connect(m_Reply, SIGNAL(readyRead()), this, SLOT(downloadReadyRead())); -} - -void SelfUpdater::downloadProgress(qint64 bytesReceived, qint64 bytesTotal) -{ - if (m_Reply != nullptr) { - if (m_Canceled) { - m_Reply->abort(); - } else { - if (bytesTotal != 0) { - if (m_Progress != nullptr) { - m_Progress->setValue((bytesReceived * 100) / bytesTotal); - } - } - } - } -} - -void SelfUpdater::downloadReadyRead() -{ - if (m_Reply != nullptr) { - m_UpdateFile.write(m_Reply->readAll()); - } -} - -void SelfUpdater::downloadFinished() -{ - int error = QNetworkReply::NoError; - - if (m_Reply != nullptr) { - if (m_Reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() == 302) { - QUrl url = - m_Reply->attribute(QNetworkRequest::RedirectionTargetAttribute).toUrl(); - m_UpdateFile.reset(); - download(url.toString()); - return; - } - m_UpdateFile.write(m_Reply->readAll()); - - error = m_Reply->error(); - - if (m_Reply->header(QNetworkRequest::ContentTypeHeader) - .toString() - .startsWith("text", Qt::CaseInsensitive)) { - m_Canceled = true; - } - - closeProgress(); - - m_Reply->close(); - m_Reply->deleteLater(); - m_Reply = nullptr; - } - - m_UpdateFile.close(); - - if ((m_UpdateFile.size() == 0) || (error != QNetworkReply::NoError) || m_Canceled) { - if (!m_Canceled) { - reportError(tr("Download failed: %1").arg(error)); - } - m_UpdateFile.remove(); - return; - } - - log::debug("download: {}", m_UpdateFile.fileName()); - - try { - installUpdate(); - } catch (const std::exception& e) { - reportError(tr("Failed to install update: %1").arg(e.what())); - } -} - -void SelfUpdater::downloadCancel() -{ - m_Canceled = true; -} - -void SelfUpdater::installUpdate() -{ - const QString parameters = "/DIR=\"" + qApp->applicationDirPath() + "\" "; - const auto r = shell::Execute(m_UpdateFile.fileName(), parameters); - - if (r.success()) { - QCoreApplication::quit(); - } else { - reportError( - tr("Failed to start %1: %2").arg(m_UpdateFile.fileName()).arg(r.toString())); - } - - m_UpdateFile.remove(); -} - -void SelfUpdater::report7ZipError(QString const& errorMessage) -{ - QMessageBox::critical(m_Parent, tr("Error"), errorMessage); -} +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "selfupdater.h" + +#include "bbcode.h" +#include "downloadmanager.h" +#include "iplugingame.h" +#include "messagedialog.h" +#include "nexusinterface.h" +#include "nxmaccessmanager.h" +#include "organizercore.h" +#include "plugincontainer.h" +#include "settings.h" +#include "shared/util.h" +#include "updatedialog.h" +#include "utility.h" +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include + +#include +#include +#include //for size_t +#include + +using namespace MOBase; +using namespace MOShared; + +SelfUpdater::SelfUpdater(NexusInterface* nexusInterface) + : m_Parent(nullptr), m_MOVersion(createVersionInfo()), m_Interface(nexusInterface), + m_Reply(nullptr), m_Attempts(3) +{} + +SelfUpdater::~SelfUpdater() = default; + +void SelfUpdater::setUserInterface(QWidget* widget) +{ + m_Parent = widget; +} + +void SelfUpdater::setPluginContainer(PluginContainer* pluginContainer) +{ + m_Interface->setPluginContainer(pluginContainer); +} + +void SelfUpdater::testForUpdate(const Settings& settings) +{ + // Fluorine Manager has its own update mechanism — skip the MO2 GitHub check. + (void)settings; + return; + + if (settings.network().offlineMode()) { + log::debug("not checking for updates, in offline mode"); + return; + } + + if (!settings.checkForUpdates()) { + log::debug("not checking for updates, disabled"); + return; + } + + // TODO: if prereleases are disabled we could just request the latest release + // directly + try { + m_GitHub.releases( + GitHub::Repository("Modorganizer2", "modorganizer"), + [this](const QJsonArray& releases) { + if (releases.isEmpty()) { + // error message already logged + return; + } + + // We store all releases: + CandidatesMap mreleases; + for (const QJsonValue& releaseVal : releases) { + QJsonObject release = releaseVal.toObject(); + if (!release["draft"].toBool() && (Settings::instance().usePrereleases() || + !release["prerelease"].toBool())) { + auto version = Version::parse(release["tag_name"].toString(), + Version::ParseMode::MO2); + mreleases[version] = release; + } + } + + if (!mreleases.empty()) { + auto lastKey = mreleases.begin()->first; + if (lastKey > this->m_MOVersion) { + + // Fill m_UpdateCandidates with version strictly greater than the + // current version: + m_UpdateCandidates.clear(); + for (auto p : mreleases) { + if (p.first > this->m_MOVersion) { + m_UpdateCandidates.insert(p); + } + } + log::info("update available: {} -> {}", this->m_MOVersion, lastKey); + emit updateAvailable(); + } else if (lastKey < this->m_MOVersion) { + // this could happen if the user switches from using prereleases to + // stable builds. Should we downgrade? + log::debug("This version is newer than the latest released one: {} -> {}", + this->m_MOVersion, lastKey); + } + } + }); + } + // Catch all is bad by design, should be improved + catch (...) { + log::debug("Unable to connect to github.com to check version"); + } +} + +void SelfUpdater::startUpdate() +{ + // the button can't be pressed if there isn't an update candidate + Q_ASSERT(!m_UpdateCandidates.empty()); + + auto latestRelease = m_UpdateCandidates.begin()->second; + + UpdateDialog dialog(m_Parent); + dialog.setVersions(MOShared::createVersionInfo().string(), + latestRelease["tag_name"].toString()); + + // We concatenate release details. We only include pre-release if those are + // the latest release: + QString details; + bool includePreRelease = true; + for (auto& p : m_UpdateCandidates) { + auto& release = p.second; + + // Ignore details for pre-release after a release has been found: + if (release["prerelease"].toBool() && !includePreRelease) { + continue; + } + + // Stop including pre-release as soon as we find a non-prerelease: + if (!release["prerelease"].toBool()) { + includePreRelease = false; + } + + details += "\n## " + release["name"].toString() + "\n---\n"; + details += release["body"].toString(); + } + + // Need to call setDetailedText to create the QTextEdit and then be able to retrieve + // it: + dialog.setChangeLogs(details); + + int res = dialog.exec(); + + if (dialog.result() == QDialog::Accepted) { + bool found = false; + for (const QJsonValue& assetVal : latestRelease["assets"].toArray()) { + QJsonObject asset = assetVal.toObject(); + if (asset["content_type"].toString() == "application/x-msdownload") { + openOutputFile(asset["name"].toString()); + download(asset["browser_download_url"].toString()); + found = true; + break; + } + } + if (!found) { + QMessageBox::warning( + m_Parent, tr("Download failed"), + tr("Failed to find correct download, please try again later.")); + } + } +} + +void SelfUpdater::showProgress() +{ + if (m_Progress == nullptr) { + m_Progress = new QProgressDialog(m_Parent, Qt::Dialog); + connect(m_Progress, SIGNAL(canceled()), this, SLOT(downloadCancel())); + } + m_Progress->setModal(true); + m_Progress->show(); + m_Progress->setValue(0); + m_Progress->setWindowTitle(tr("Update")); + m_Progress->setLabelText(tr("Download in progress")); +} + +void SelfUpdater::closeProgress() +{ + if (m_Progress != nullptr) { + m_Progress->hide(); + m_Progress->deleteLater(); + m_Progress = nullptr; + } +} + +void SelfUpdater::openOutputFile(const QString& fileName) +{ + QString outputPath = + QDir::fromNativeSeparators(qApp->property("dataPath").toString()) + "/" + + fileName; + log::debug("downloading to {}", outputPath); + m_UpdateFile.setFileName(outputPath); + if (!m_UpdateFile.open(QIODevice::WriteOnly)) { + log::error("failed to open update output file '{}': {}", outputPath, + m_UpdateFile.errorString()); + } +} + +void SelfUpdater::download(const QString& downloadLink) +{ + QNetworkAccessManager* accessManager = m_Interface->getAccessManager(); + QUrl dlUrl(downloadLink); + QNetworkRequest request(dlUrl); + m_Canceled = false; + m_Reply = accessManager->get(request); + showProgress(); + + connect(m_Reply, SIGNAL(downloadProgress(qint64, qint64)), this, + SLOT(downloadProgress(qint64, qint64))); + connect(m_Reply, SIGNAL(finished()), this, SLOT(downloadFinished())); + connect(m_Reply, SIGNAL(readyRead()), this, SLOT(downloadReadyRead())); +} + +void SelfUpdater::downloadProgress(qint64 bytesReceived, qint64 bytesTotal) +{ + if (m_Reply != nullptr) { + if (m_Canceled) { + m_Reply->abort(); + } else { + if (bytesTotal != 0) { + if (m_Progress != nullptr) { + m_Progress->setValue((bytesReceived * 100) / bytesTotal); + } + } + } + } +} + +void SelfUpdater::downloadReadyRead() +{ + if (m_Reply != nullptr) { + m_UpdateFile.write(m_Reply->readAll()); + } +} + +void SelfUpdater::downloadFinished() +{ + int error = QNetworkReply::NoError; + + if (m_Reply != nullptr) { + if (m_Reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() == 302) { + QUrl url = + m_Reply->attribute(QNetworkRequest::RedirectionTargetAttribute).toUrl(); + m_UpdateFile.reset(); + download(url.toString()); + return; + } + m_UpdateFile.write(m_Reply->readAll()); + + error = m_Reply->error(); + + if (m_Reply->header(QNetworkRequest::ContentTypeHeader) + .toString() + .startsWith("text", Qt::CaseInsensitive)) { + m_Canceled = true; + } + + closeProgress(); + + m_Reply->close(); + m_Reply->deleteLater(); + m_Reply = nullptr; + } + + m_UpdateFile.close(); + + if ((m_UpdateFile.size() == 0) || (error != QNetworkReply::NoError) || m_Canceled) { + if (!m_Canceled) { + reportError(tr("Download failed: %1").arg(error)); + } + m_UpdateFile.remove(); + return; + } + + log::debug("download: {}", m_UpdateFile.fileName()); + + try { + installUpdate(); + } catch (const std::exception& e) { + reportError(tr("Failed to install update: %1").arg(e.what())); + } +} + +void SelfUpdater::downloadCancel() +{ + m_Canceled = true; +} + +void SelfUpdater::installUpdate() +{ + const QString parameters = "/DIR=\"" + qApp->applicationDirPath() + "\" "; + const auto r = shell::Execute(m_UpdateFile.fileName(), parameters); + + if (r.success()) { + QCoreApplication::quit(); + } else { + reportError( + tr("Failed to start %1: %2").arg(m_UpdateFile.fileName()).arg(r.toString())); + } + + m_UpdateFile.remove(); +} + +void SelfUpdater::report7ZipError(QString const& errorMessage) +{ + QMessageBox::critical(m_Parent, tr("Error"), errorMessage); +} diff --git a/src/src/settings.cpp b/src/src/settings.cpp index 9d86150..4a67666 100644 --- a/src/src/settings.cpp +++ b/src/src/settings.cpp @@ -1,2592 +1,2592 @@ -/* -Copyright (C) 2012 Sebastian Herbord. All rights reserved. - -This file is part of Mod Organizer. - -Mod Organizer is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -Mod Organizer is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with Mod Organizer. If not, see . -*/ - -#include "settings.h" -#include -#include "env.h" -#include "envmetrics.h" -#include "executableslist.h" -#include "instancemanager.h" -#include "modelutils.h" -#include "nxmhandler_linux.h" -#include "serverinfo.h" -#include "settingsutilities.h" -#include "shared/appconfig.h" -#include -#include -#include - -using namespace MOBase; -using namespace MOShared; - -namespace -{ -bool usesBaseDirVariable(const QString& path) -{ - return path.contains(PathSettings::BaseDirVariable, Qt::CaseInsensitive); -} - -QString loadStoredPath(const QString& path) -{ - QString value = QDir::fromNativeSeparators(path); - if (!usesBaseDirVariable(value)) { - value = MOBase::normalizePathForHost(value); - } - return value; -} - -QString storePathForIni(const QString& path) -{ - if (path.isEmpty()) { - return path; - } - - if (usesBaseDirVariable(path)) { - return QDir::fromNativeSeparators(path); - } - - return MOBase::normalizePathForWine(path); -} -} // namespace - -EndorsementState endorsementStateFromString(const QString& s) -{ - if (s == "Endorsed") { - return EndorsementState::Accepted; - } else if (s == "Abstained") { - return EndorsementState::Refused; - } else { - return EndorsementState::NoDecision; - } -} - -QString toString(EndorsementState s) -{ - switch (s) { - case EndorsementState::Accepted: - return "Endorsed"; - - case EndorsementState::Refused: - return "Abstained"; - - case EndorsementState::NoDecision: // fall-through - default: - return {}; - } -} - -Settings* Settings::s_Instance = nullptr; - -Settings::Settings(const QString& path, bool globalInstance) - : m_Settings(path, QSettings::IniFormat), m_Game(m_Settings), - m_Geometry(m_Settings), m_Widgets(m_Settings, globalInstance), - m_Colors(m_Settings), m_Plugins(m_Settings), m_Paths(m_Settings), - m_Network(m_Settings, globalInstance), m_Nexus(*this, m_Settings), - m_Steam(*this, m_Settings), m_Interface(m_Settings), m_Diagnostics(m_Settings) -{ - if (globalInstance) { - if (s_Instance != nullptr) { - throw std::runtime_error("second instance of \"Settings\" created"); - } else { - s_Instance = this; - } - } -} - -Settings::~Settings() -{ - if (s_Instance == this) { - MOBase::QuestionBoxMemory::setCallbacks({}, {}, {}); - s_Instance = nullptr; - } -} - -Settings& Settings::instance() -{ - if (s_Instance == nullptr) { - throw std::runtime_error("no instance of \"Settings\""); - } - - return *s_Instance; -} - -Settings* Settings::maybeInstance() -{ - return s_Instance; -} - -void Settings::processUpdates(const QVersionNumber& currentVersion, - const QVersionNumber& lastVersion) -{ - if (firstStart()) { - set(m_Settings, "General", "version", currentVersion.toString()); - return; - } - - if (currentVersion == lastVersion) { - return; - } - - log::info("updating from {} to {}", lastVersion.toString(), - currentVersion.toString()); - - auto version = [&](const QVersionNumber& v, auto&& f) { - if (lastVersion < v) { - log::debug("processing updates for {}", v.toString()); - f(); - } - }; - - version({2, 2, 0}, [&] { - remove(m_Settings, "Settings", "steam_password"); - remove(m_Settings, "Settings", "nexus_username"); - remove(m_Settings, "Settings", "nexus_password"); - remove(m_Settings, "Settings", "nexus_login"); - remove(m_Settings, "Settings", "nexus_api_key"); - remove(m_Settings, "Settings", "ask_for_nexuspw"); - remove(m_Settings, "Settings", "nmm_version"); - - removeSection(m_Settings, "Servers"); - }); - - version({2, 2, 1}, [&] { - remove(m_Settings, "General", "mod_info_tabs"); - remove(m_Settings, "General", "mod_info_conflict_expanders"); - remove(m_Settings, "General", "mod_info_conflicts"); - remove(m_Settings, "General", "mod_info_advanced_conflicts"); - remove(m_Settings, "General", "mod_info_conflicts_overwrite"); - remove(m_Settings, "General", "mod_info_conflicts_noconflict"); - remove(m_Settings, "General", "mod_info_conflicts_overwritten"); - }); - - version({2, 2, 2}, [&] { - // log splitter is gone, it's a dock now - remove(m_Settings, "General", "log_split"); - - // moved to widgets - remove(m_Settings, "General", "mod_info_conflicts_tab"); - remove(m_Settings, "General", "mod_info_conflicts_general_expanders"); - remove(m_Settings, "General", "mod_info_conflicts_general_overwrite"); - remove(m_Settings, "General", "mod_info_conflicts_general_noconflict"); - remove(m_Settings, "General", "mod_info_conflicts_general_overwritten"); - remove(m_Settings, "General", "mod_info_conflicts_advanced_list"); - remove(m_Settings, "General", "mod_info_conflicts_advanced_options"); - remove(m_Settings, "General", "mod_info_tab_order"); - remove(m_Settings, "General", "mod_info_dialog_images_show_dds"); - - // moved to geometry - remove(m_Settings, "General", "window_geometry"); - remove(m_Settings, "General", "window_state"); - remove(m_Settings, "General", "toolbar_size"); - remove(m_Settings, "General", "toolbar_button_style"); - remove(m_Settings, "General", "menubar_visible"); - remove(m_Settings, "General", "statusbar_visible"); - remove(m_Settings, "General", "window_split"); - remove(m_Settings, "General", "window_monitor"); - remove(m_Settings, "General", "browser_geometry"); - remove(m_Settings, "General", "filters_visible"); - - // this was supposed to have been removed above when updating from 2.2.0, - // but it wasn't in Settings, it was in General - remove(m_Settings, "General", "ask_for_nexuspw"); - - m_Network.updateFromOldMap(); - }); - - version({2, 4, 0}, [&] { - // removed - remove(m_Settings, "Settings", "hide_unchecked_plugins"); - remove(m_Settings, "Settings", "load_mechanism"); - }); - - // save version in all case - set(m_Settings, "General", "version", currentVersion.toString()); - - log::debug("updating done"); -} - -QString Settings::filename() const -{ - return m_Settings.fileName(); -} - -bool Settings::checkForUpdates() const -{ - return get(m_Settings, "Settings", "check_for_updates", true); -} - -void Settings::setCheckForUpdates(bool b) -{ - set(m_Settings, "Settings", "check_for_updates", b); -} - -bool Settings::usePrereleases() const -{ - return get(m_Settings, "Settings", "use_prereleases", false); -} - -void Settings::setUsePrereleases(bool b) -{ - set(m_Settings, "Settings", "use_prereleases", b); -} - -QString Settings::fluorineUpdateChannel() const -{ -#if FLUORINE_IS_BETA_BUILD - const QString defaultChannel = QStringLiteral("beta"); -#else - const QString defaultChannel = QStringLiteral("stable"); -#endif - return get(m_Settings, "Settings", "fluorine_update_channel", - defaultChannel); -} - -void Settings::setFluorineUpdateChannel(const QString& channel) -{ - set(m_Settings, "Settings", "fluorine_update_channel", channel); -} - -bool Settings::profileLocalInis() const -{ - return get(m_Settings, "Settings", "profile_local_inis", true); -} - -void Settings::setProfileLocalInis(bool b) -{ - set(m_Settings, "Settings", "profile_local_inis", b); -} - -bool Settings::profileLocalSaves() const -{ - return get(m_Settings, "Settings", "profile_local_saves", false); -} - -void Settings::setProfileLocalSaves(bool b) -{ - set(m_Settings, "Settings", "profile_local_saves", b); -} - -bool Settings::profileArchiveInvalidation() const -{ - return get(m_Settings, "Settings", "profile_archive_invalidation", false); -} - -void Settings::setProfileArchiveInvalidation(bool b) -{ - set(m_Settings, "Settings", "profile_archive_invalidation", b); -} - -bool Settings::useSplash() const -{ - return get(m_Settings, "Settings", "use_splash", true); -} - -void Settings::setUseSplash(bool b) -{ - set(m_Settings, "Settings", "use_splash", b); -} - -std::size_t Settings::refreshThreadCount() const -{ - return get(m_Settings, "Settings", "refresh_thread_count", 10); -} - -void Settings::setRefreshThreadCount(std::size_t n) const -{ - return set(m_Settings, "Settings", "refresh_thread_count", n); -} - -std::optional Settings::version() const -{ - if (auto v = getOptional(m_Settings, "General", "version")) { - return QVersionNumber::fromString(*v).normalized(); - } - - return {}; -} - -bool Settings::firstStart() const -{ - return get(m_Settings, "General", "first_start", true); -} - -void Settings::setFirstStart(bool b) -{ - set(m_Settings, "General", "first_start", b); -} - -QString Settings::executablesBlacklist() const -{ - static const QString def = (QStringList() << "Chrome.exe" - << "Firefox.exe" - << "TSVNCache.exe" - << "TGitCache.exe" - << "Steam.exe" - << "GameOverlayUI.exe" - << "Discord.exe" - << "GalaxyClient.exe" - << "Spotify.exe" - << "Brave.exe") - .join(";"); - - return get(m_Settings, "Settings", "executable_blacklist", def); -} - -bool Settings::isExecutableBlacklisted(const QString& s) const -{ - for (auto exec : executablesBlacklist().split(";")) { - if (exec.compare(s, Qt::CaseInsensitive) == 0) { - return true; - } - } - - return false; -} - -void Settings::setExecutablesBlacklist(const QString& s) -{ - set(m_Settings, "Settings", "executable_blacklist", s); -} - -QStringList Settings::skipFileSuffixes() const -{ - static const QStringList def = QStringList() << ".mohidden"; - - auto setting = get(m_Settings, "Settings", "skip_file_suffixes", def); - - return setting; -} - -void Settings::setSkipFileSuffixes(const QStringList& s) -{ - set(m_Settings, "Settings", "skip_file_suffixes", s); -} - -QStringList Settings::skipDirectories() const -{ - static const QStringList def = QStringList() << ".git"; - - auto setting = get(m_Settings, "Settings", "skip_directories", def); - - return setting; -} - -void Settings::setSkipDirectories(const QStringList& s) -{ - set(m_Settings, "Settings", "skip_directories", s); -} - -void Settings::setMotdHash(uint hash) -{ - set(m_Settings, "General", "motd_hash", hash); -} - -unsigned int Settings::motdHash() const -{ - return get(m_Settings, "General", "motd_hash", 0); -} - -bool Settings::archiveParsing() const -{ - return get(m_Settings, "Settings", "archive_parsing_experimental", false); -} - -void Settings::setArchiveParsing(bool b) -{ - set(m_Settings, "Settings", "archive_parsing_experimental", b); -} - -std::vector> Settings::executables() const -{ - ScopedReadArray sra(m_Settings, "customExecutables"); - std::vector> v; - - sra.for_each([&] { - std::map map; - - for (auto&& key : sra.keys()) { - map[key] = sra.get(key); - } - if (map.contains("binary")) { - map["binary"] = loadStoredPath(map["binary"].toString()); - } - if (map.contains("workingDirectory")) { - map["workingDirectory"] = - loadStoredPath(map["workingDirectory"].toString()); - } - - v.push_back(map); - }); - - return v; -} - -void Settings::setExecutables(const std::vector>& v) -{ - const auto current = executables(); - - if (current == v) { - // no change - return; - } - - if (current.size() > v.size()) { - // Qt can't remove array elements, the section must be cleared - removeSection(m_Settings, "customExecutables"); - } - - ScopedWriteArray swa(m_Settings, "customExecutables", v.size()); - - for (const auto& map : v) { - swa.next(); - - for (auto&& p : map) { - if ((p.first == "binary") || (p.first == "workingDirectory")) { - swa.set(p.first, storePathForIni(p.second.toString())); - } else { - swa.set(p.first, p.second); - } - } - } -} - -bool Settings::keepBackupOnInstall() const -{ - return get(m_Settings, "General", "backup_install", false); -} - -void Settings::setKeepBackupOnInstall(bool b) -{ - set(m_Settings, "General", "backup_install", b); -} - -GameSettings& Settings::game() -{ - return m_Game; -} - -const GameSettings& Settings::game() const -{ - return m_Game; -} - -GeometrySettings& Settings::geometry() -{ - return m_Geometry; -} - -const GeometrySettings& Settings::geometry() const -{ - return m_Geometry; -} - -WidgetSettings& Settings::widgets() -{ - return m_Widgets; -} - -const WidgetSettings& Settings::widgets() const -{ - return m_Widgets; -} - -ColorSettings& Settings::colors() -{ - return m_Colors; -} - -const ColorSettings& Settings::colors() const -{ - return m_Colors; -} - -PluginSettings& Settings::plugins() -{ - return m_Plugins; -} - -const PluginSettings& Settings::plugins() const -{ - return m_Plugins; -} - -PathSettings& Settings::paths() -{ - return m_Paths; -} - -const PathSettings& Settings::paths() const -{ - return m_Paths; -} - -NetworkSettings& Settings::network() -{ - return m_Network; -} - -const NetworkSettings& Settings::network() const -{ - return m_Network; -} - -NexusSettings& Settings::nexus() -{ - return m_Nexus; -} - -const NexusSettings& Settings::nexus() const -{ - return m_Nexus; -} - -SteamSettings& Settings::steam() -{ - return m_Steam; -} - -const SteamSettings& Settings::steam() const -{ - return m_Steam; -} - -InterfaceSettings& Settings::interface() -{ - return m_Interface; -} - -const InterfaceSettings& Settings::interface() const -{ - return m_Interface; -} - -DiagnosticsSettings& Settings::diagnostics() -{ - return m_Diagnostics; -} - -const DiagnosticsSettings& Settings::diagnostics() const -{ - return m_Diagnostics; -} - -QSettings::Status Settings::sync() const -{ - m_Settings.sync(); - - const auto s = m_Settings.status(); - - // there's a bug in Qt at least until 5.15.0 where a utf-8 bom in the ini is - // handled correctly but still sets FormatError - // - // see qsettings.cpp, in QConfFileSettingsPrivate::readIniFile(), there's a - // specific check for utf-8, which adjusts `dataPos` so it's skipped, but - // the FLUSH_CURRENT_SECTION() macro uses `currentSectionStart`, and that one - // isn't adjusted when changing `dataPos` on the first line and so stays 0 - // - // this puts the bom in `unparsedIniSections` and eventually sets FormatError - // somewhere - // - // - // the other problem is that the status is never reset, not even when calling - // sync(), so the FormatError that's returned here is actually from reading - // the ini, not writing it - // - // - // since it's impossible to get a FormatError on write, it's considered to - // be a NoError here - - if (s == QSettings::FormatError) { - return QSettings::NoError; - } else { - return s; - } -} - -QSettings::Status Settings::iniStatus() const -{ - return m_Settings.status(); -} - -void Settings::dump() const -{ - static const QStringList ignore({"username", "password", "nexus_api_key", - "nexus_username", "nexus_password", - "steam_username"}); - - log::debug("settings:"); - - { - ScopedGroup sg(m_Settings, "Settings"); - - for (auto k : m_Settings.allKeys()) { - if (ignore.contains(k, Qt::CaseInsensitive)) { - continue; - } - - log::debug(" . {}={}", k, m_Settings.value(k).toString()); - } - } - - m_Network.dump(); - m_Nexus.dump(); -} - -void Settings::managedGameChanged(IPluginGame const* gamePlugin) -{ - m_Game.setPlugin(gamePlugin); -} - -GameSettings::GameSettings(QSettings& settings) - : m_Settings(settings) -{} - -const MOBase::IPluginGame* GameSettings::plugin() -{ - return m_GamePlugin; -} - -void GameSettings::setPlugin(const MOBase::IPluginGame* gamePlugin) -{ - m_GamePlugin = gamePlugin; -} - -bool GameSettings::forceEnableCoreFiles() const -{ - return get(m_Settings, "Settings", "force_enable_core_files", true); -} - -void GameSettings::setForceEnableCoreFiles(bool b) -{ - set(m_Settings, "Settings", "force_enable_core_files", b); -} - -std::optional GameSettings::directory() const -{ - if (auto v = getOptional(m_Settings, "General", "gamePath")) { - return loadStoredPath(QString::fromUtf8(*v)); - } - - return {}; -} - -void GameSettings::setDirectory(const QString& path) -{ - set(m_Settings, "General", "gamePath", storePathForIni(path).toUtf8()); -} - -std::optional GameSettings::name() const -{ - return getOptional(m_Settings, "General", "gameName"); -} - -void GameSettings::setName(const QString& name) -{ - set(m_Settings, "General", "gameName", name); -} - -std::optional GameSettings::edition() const -{ - return getOptional(m_Settings, "General", "game_edition"); -} - -void GameSettings::setEdition(const QString& name) -{ - set(m_Settings, "General", "game_edition", name); -} - -std::optional GameSettings::selectedProfileName() const -{ - if (auto v = getOptional(m_Settings, "General", "selected_profile")) { - return QString::fromUtf8(*v); - } - - return {}; -} - -void GameSettings::setSelectedProfileName(const QString& name) -{ - set(m_Settings, "General", "selected_profile", name.toUtf8()); -} - -GeometrySettings::GeometrySettings(QSettings& s) : m_Settings(s) {} - -void GeometrySettings::requestReset() -{ - m_Reset = true; -} - -void GeometrySettings::resetIfNeeded() -{ - if (!m_Reset) { - return; - } - - removeSection(m_Settings, "Geometry"); -} - -void GeometrySettings::saveGeometry(const QMainWindow* w) -{ - saveWindowGeometry(w); -} - -bool GeometrySettings::restoreGeometry(QMainWindow* w) const -{ - return restoreWindowGeometry(w); -} - -void GeometrySettings::saveGeometry(const QDialog* d) -{ - saveWindowGeometry(d); -} - -bool GeometrySettings::restoreGeometry(QDialog* d) const -{ - const auto r = restoreWindowGeometry(d); - - if (centerDialogs()) { - centerOnParent(d); - } - - return r; -} - -void GeometrySettings::saveWindowGeometry(const QWidget* w) -{ - set(m_Settings, "Geometry", geoSettingName(w), w->saveGeometry()); -} - -bool GeometrySettings::restoreWindowGeometry(QWidget* w) const -{ - if (auto v = getOptional(m_Settings, "Geometry", geoSettingName(w))) { - w->restoreGeometry(*v); - ensureWindowOnScreen(w); - return true; - } - - return false; -} - -void GeometrySettings::ensureWindowOnScreen(QWidget* w) -{ - // users report that the main window and/or dialogs are displayed off-screen; - // the usual workaround is keyboard navigation to move it - // - // qt should have code that deals with multiple monitors and off-screen - // geometries, but there seems to be bugs or inconsistencies that can't be - // reproduced - // - // the closest would probably be https://bugreports.qt.io/browse/QTBUG-64498, - // which is about multiple monitors and high dpi, but it seems fixed as of - // 5.12.4, which is shipped with 2.2.1 - // - // without being to reproduce the problem, some simple checks are made in a - // timer, which may mitigate the issues - - QTimer::singleShot(100, w, [w] { - const auto borders = 20; - - // desktop geometry, made smaller to make sure there isn't just a few pixels - const auto originalDg = env::Environment().metrics().desktopGeometry(); - const auto dg = originalDg.adjusted(borders, borders, -borders, -borders); - - const auto g = w->geometry(); - - if (!dg.intersects(g)) { - log::warn("window '{}' is offscreen, moving to main monitor; geo={}, desktop={}", - w->objectName(), g, originalDg); - - // widget is off-screen, center it on main monitor - centerOnMonitor(w, -1); - - log::warn("window '{}' now at {}", w->objectName(), w->geometry()); - } - }); -} - -void GeometrySettings::saveState(const QMainWindow* w) -{ - set(m_Settings, "Geometry", stateSettingName(w), w->saveState()); -} - -bool GeometrySettings::restoreState(QMainWindow* w) const -{ - if (auto v = getOptional(m_Settings, "Geometry", stateSettingName(w))) { - w->restoreState(*v); - return true; - } - - return false; -} - -void GeometrySettings::saveState(const QHeaderView* w) -{ - set(m_Settings, "Geometry", stateSettingName(w), w->saveState()); -} - -bool GeometrySettings::restoreState(QHeaderView* w) const -{ - if (auto v = getOptional(m_Settings, "Geometry", stateSettingName(w))) { - w->restoreState(*v); - return true; - } - - return false; -} - -void GeometrySettings::saveState(const QSplitter* w) -{ - set(m_Settings, "Geometry", stateSettingName(w), w->saveState()); -} - -bool GeometrySettings::restoreState(QSplitter* w) const -{ - if (auto v = getOptional(m_Settings, "Geometry", stateSettingName(w))) { - w->restoreState(*v); - return true; - } - - return false; -} - -void GeometrySettings::saveState(const ExpanderWidget* expander) -{ - set(m_Settings, "Geometry", stateSettingName(expander), expander->saveState()); -} - -bool GeometrySettings::restoreState(ExpanderWidget* expander) const -{ - if (auto v = - getOptional(m_Settings, "Geometry", stateSettingName(expander))) { - expander->restoreState(*v); - return true; - } - - return false; -} - -void GeometrySettings::saveVisibility(const QWidget* w) -{ - set(m_Settings, "Geometry", visibilitySettingName(w), w->isVisible()); -} - -bool GeometrySettings::restoreVisibility(QWidget* w, std::optional def) const -{ - if (auto v = - getOptional(m_Settings, "Geometry", visibilitySettingName(w), def)) { - w->setVisible(*v); - return true; - } - - return false; -} - -void GeometrySettings::restoreToolbars(QMainWindow* w) const -{ - // all toolbars have the same size and button style settings - const auto size = getOptional(m_Settings, "Geometry", "toolbar_size"); - const auto style = getOptional(m_Settings, "Geometry", "toolbar_button_style"); - - for (auto* tb : w->findChildren()) { - if (size) { - tb->setIconSize(*size); - } - - if (style) { - tb->setToolButtonStyle(static_cast(*style)); - } - - restoreVisibility(tb); - } -} - -void GeometrySettings::saveToolbars(const QMainWindow* w) -{ - const auto tbs = w->findChildren(); - - // save visibility for all - for (auto* tb : tbs) { - saveVisibility(tb); - } - - // all toolbars have the same size and button style settings, just save the - // first one - if (!tbs.isEmpty()) { - const auto* tb = tbs[0]; - - set(m_Settings, "Geometry", "toolbar_size", tb->iconSize()); - set(m_Settings, "Geometry", "toolbar_button_style", - static_cast(tb->toolButtonStyle())); - } -} - -QStringList GeometrySettings::modInfoTabOrder() const -{ - QStringList v; - - if (m_Settings.contains("mod_info_tabs")) { - // old byte array from 2.2.0 - QDataStream stream(m_Settings.value("mod_info_tabs").toByteArray()); - - int count = 0; - stream >> count; - - for (int i = 0; i < count; ++i) { - QString s; - stream >> s; - v.push_back(s); - } - } else { - // string list since 2.2.1 - QString string = get(m_Settings, "Widgets", "ModInfoTabOrder", ""); - QTextStream stream(&string); - - while (!stream.atEnd()) { - QString s; - stream >> s; - v.push_back(s); - } - } - - return v; -} - -void GeometrySettings::setModInfoTabOrder(const QString& names) -{ - set(m_Settings, "Widgets", "ModInfoTabOrder", names); -} - -bool GeometrySettings::centerDialogs() const -{ - return get(m_Settings, "Settings", "center_dialogs", false); -} - -void GeometrySettings::setCenterDialogs(bool b) -{ - set(m_Settings, "Settings", "center_dialogs", b); -} - -void GeometrySettings::centerOnMainWindowMonitor(QWidget* w) const -{ - const auto monitor = - getOptional(m_Settings, "Geometry", "MainWindow_monitor").value_or(-1); - - centerOnMonitor(w, monitor); -} - -void GeometrySettings::centerOnMonitor(QWidget* w, int monitor) -{ - QPoint center; - - if (monitor >= 0 && monitor < QGuiApplication::screens().size()) { - center = QGuiApplication::screens().at(monitor)->geometry().center(); - } else { - center = QGuiApplication::primaryScreen()->geometry().center(); - } - - w->move(center - w->rect().center()); -} - -void GeometrySettings::centerOnParent(QWidget* w, QWidget* parent) -{ - if (!parent) { - parent = w->parentWidget(); - - if (!parent) { - parent = qApp->activeWindow(); - } - } - - if (parent && parent->isVisible()) { - const auto pr = parent->geometry(); - w->move(pr.center() - w->rect().center()); - } -} - -void GeometrySettings::saveMainWindowMonitor(const QMainWindow* w) -{ - if (auto* handle = w->windowHandle()) { - if (auto* screen = handle->screen()) { - const int screenId = QGuiApplication::screens().indexOf(screen); - set(m_Settings, "Geometry", "MainWindow_monitor", screenId); - } - } -} - -Qt::Orientation dockOrientation(const QMainWindow* mw, const QDockWidget* d) -{ - // docks in these areas are horizontal - const auto horizontalAreas = Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea; - - if (mw->dockWidgetArea(const_cast(d)) & horizontalAreas) { - return Qt::Horizontal; - } else { - return Qt::Vertical; - } -} - -void GeometrySettings::saveDocks(const QMainWindow* mw) -{ - // this attempts to fix https://bugreports.qt.io/browse/QTBUG-46620 where dock - // sizes are not restored when the main window is maximized; it is used in - // MainWindow::readSettings() and MainWindow::storeSettings() - // - // there's also https://stackoverflow.com/questions/44005852, which has what - // seems to be a popular fix, but it breaks the restored size of the window - // by setting it to the desktop's resolution, so that doesn't work - // - // the only fix I could find is to remember the sizes of the docks and manually - // setting them back; saving is straightforward, but restoring is messy - // - // this also depends on the window being visible before the timer in restore() - // is fired and the timer must be processed by application.exec(); therefore, - // the splash screen _must_ be closed before readSettings() is called, because - // it has its own event loop, which seems to interfere with this - // - // all of this should become unnecessary when QTBUG-46620 is fixed - // - - // saves the size of each dock - for (const auto* dock : mw->findChildren()) { - int size = 0; - - // save the width for horizontal docks, or the height for vertical - if (dockOrientation(mw, dock) == Qt::Horizontal) { - size = dock->size().width(); - } else { - size = dock->size().height(); - } - - set(m_Settings, "Geometry", dockSettingName(dock), size); - } -} - -void GeometrySettings::restoreDocks(QMainWindow* mw) const -{ - struct DockInfo - { - QDockWidget* d; - int size = 0; - Qt::Orientation ori; - }; - - std::vector dockInfos; - - // for each dock - for (auto* dock : mw->findChildren()) { - if (auto size = getOptional(m_Settings, "Geometry", dockSettingName(dock))) { - // remember this dock, its size and orientation - dockInfos.push_back({dock, *size, dockOrientation(mw, dock)}); - } - } - - // the main window must have had time to process the settings from - // readSettings() or it seems to override whatever is set here - // - // some people said a single processEvents() call is enough, but it doesn't - // look like it - QTimer::singleShot(5, [=] { - for (const auto& info : dockInfos) { - mw->resizeDocks({info.d}, {info.size}, info.ori); - } - }); -} - -WidgetSettings::WidgetSettings(QSettings& s, bool globalInstance) : m_Settings(s) -{ - if (globalInstance) { - MOBase::QuestionBoxMemory::setCallbacks( - [this](auto&& w, auto&& f) { - return questionButton(w, f); - }, - [this](auto&& w, auto&& b) { - setQuestionWindowButton(w, b); - }, - [this](auto&& w, auto&& f, auto&& b) { - setQuestionFileButton(w, f, b); - }); - } -} - -void WidgetSettings::saveTreeCheckState(const QTreeView* tv, int role) -{ - QVariantList data; - for (auto index : flatIndex(tv->model())) { - data.append(index.data(role)); - } - set(m_Settings, "Widgets", indexSettingName(tv), data); -} - -void WidgetSettings::restoreTreeCheckState(QTreeView* tv, int role) const -{ - if (auto states = - getOptional(m_Settings, "Widgets", indexSettingName(tv))) { - auto allIndex = flatIndex(tv->model()); - MOBase::log::debug("restoreTreeCheckState: {}, {}", states->size(), - allIndex.size()); - if (states->size() != allIndex.size()) { - return; - } - for (int i = 0; i < states->size(); ++i) { - tv->model()->setData(allIndex[i], states->at(i), role); - } - } -} - -void WidgetSettings::saveTreeExpandState(const QTreeView* tv, int role) -{ - QVariantList expanded; - for (auto index : flatIndex(tv->model())) { - if (tv->isExpanded(index)) { - expanded.append(index.data(role)); - } - } - set(m_Settings, "Widgets", indexSettingName(tv), expanded); -} - -void WidgetSettings::restoreTreeExpandState(QTreeView* tv, int role) const -{ - if (auto expanded = - getOptional(m_Settings, "Widgets", indexSettingName(tv))) { - tv->collapseAll(); - for (auto index : flatIndex(tv->model())) { - if (expanded->contains(index.data(role))) { - tv->expand(index); - } - } - } -} - -std::optional WidgetSettings::index(const QComboBox* cb) const -{ - return getOptional(m_Settings, "Widgets", indexSettingName(cb)); -} - -void WidgetSettings::saveIndex(const QComboBox* cb) -{ - set(m_Settings, "Widgets", indexSettingName(cb), cb->currentIndex()); -} - -void WidgetSettings::restoreIndex(QComboBox* cb, std::optional def) const -{ - if (auto v = getOptional(m_Settings, "Widgets", indexSettingName(cb), def)) { - cb->setCurrentIndex(*v); - } -} - -std::optional WidgetSettings::index(const QTabWidget* w) const -{ - return getOptional(m_Settings, "Widgets", indexSettingName(w)); -} - -void WidgetSettings::saveIndex(const QTabWidget* w) -{ - set(m_Settings, "Widgets", indexSettingName(w), w->currentIndex()); -} - -void WidgetSettings::restoreIndex(QTabWidget* w, std::optional def) const -{ - if (auto v = getOptional(m_Settings, "Widgets", indexSettingName(w), def)) { - w->setCurrentIndex(*v); - } -} - -std::optional WidgetSettings::checked(const QAbstractButton* w) const -{ - warnIfNotCheckable(w); - return getOptional(m_Settings, "Widgets", checkedSettingName(w)); -} - -void WidgetSettings::saveChecked(const QAbstractButton* w) -{ - warnIfNotCheckable(w); - set(m_Settings, "Widgets", checkedSettingName(w), w->isChecked()); -} - -void WidgetSettings::restoreChecked(QAbstractButton* w, std::optional def) const -{ - warnIfNotCheckable(w); - - if (auto v = getOptional(m_Settings, "Widgets", checkedSettingName(w), def)) { - w->setChecked(*v); - } -} - -QuestionBoxMemory::Button WidgetSettings::questionButton(const QString& windowName, - const QString& filename) const -{ - const QString sectionName("DialogChoices"); - - if (!filename.isEmpty()) { - const auto fileSetting = windowName + "/" + filename; - if (auto v = getOptional(m_Settings, sectionName, fileSetting)) { - return static_cast(*v); - } - } - - if (auto v = getOptional(m_Settings, sectionName, windowName)) { - return static_cast(*v); - } - - return QuestionBoxMemory::NoButton; -} - -void WidgetSettings::setQuestionWindowButton(const QString& windowName, - QuestionBoxMemory::Button button) -{ - const QString sectionName("DialogChoices"); - - if (button == QuestionBoxMemory::NoButton) { - remove(m_Settings, sectionName, windowName); - } else { - set(m_Settings, sectionName, windowName, button); - } -} - -void WidgetSettings::setQuestionFileButton(const QString& windowName, - const QString& filename, - QuestionBoxMemory::Button button) -{ - const QString sectionName("DialogChoices"); - const QString settingName(windowName + "/" + filename); - - if (button == QuestionBoxMemory::NoButton) { - remove(m_Settings, sectionName, settingName); - } else { - set(m_Settings, sectionName, settingName, button); - } -} - -void WidgetSettings::resetQuestionButtons() -{ - removeSection(m_Settings, "DialogChoices"); -} - -ColorSettings::ColorSettings(QSettings& s) : m_Settings(s) {} - -QColor ColorSettings::modlistOverwrittenLoose() const -{ - return get(m_Settings, "Settings", "overwrittenLooseFilesColor", - QColor(0, 255, 0, 64)); -} - -void ColorSettings::setModlistOverwrittenLoose(const QColor& c) -{ - set(m_Settings, "Settings", "overwrittenLooseFilesColor", c); -} - -QColor ColorSettings::modlistOverwritingLoose() const -{ - return get(m_Settings, "Settings", "overwritingLooseFilesColor", - QColor(255, 0, 0, 64)); -} - -void ColorSettings::setModlistOverwritingLoose(const QColor& c) -{ - set(m_Settings, "Settings", "overwritingLooseFilesColor", c); -} - -QColor ColorSettings::modlistOverwrittenArchive() const -{ - return get(m_Settings, "Settings", "overwrittenArchiveFilesColor", - QColor(0, 255, 255, 64)); -} - -void ColorSettings::setModlistOverwrittenArchive(const QColor& c) -{ - set(m_Settings, "Settings", "overwrittenArchiveFilesColor", c); -} - -QColor ColorSettings::modlistOverwritingArchive() const -{ - return get(m_Settings, "Settings", "overwritingArchiveFilesColor", - QColor(255, 0, 255, 64)); -} - -void ColorSettings::setModlistOverwritingArchive(const QColor& c) -{ - set(m_Settings, "Settings", "overwritingArchiveFilesColor", c); -} - -QColor ColorSettings::modlistContainsFile() const -{ - return get(m_Settings, "Settings", "containsFileColor", - QColor(0, 0, 255, 64)); -} - -void ColorSettings::setModlistContainsFile(const QColor& c) -{ - set(m_Settings, "Settings", "containsFileColor", c); -} - -QColor ColorSettings::pluginListContained() const -{ - return get(m_Settings, "Settings", "containedColor", QColor(0, 0, 255, 64)); -} - -void ColorSettings::setPluginListContained(const QColor& c) -{ - set(m_Settings, "Settings", "containedColor", c); -} - -QColor ColorSettings::pluginListMaster() const -{ - return get(m_Settings, "Settings", "masterColor", QColor(255, 255, 0, 64)); -} - -void ColorSettings::setPluginListMaster(const QColor& c) -{ - set(m_Settings, "Settings", "masterColor", c); -} - -std::optional ColorSettings::previousSeparatorColor() const -{ - const auto c = getOptional(m_Settings, "General", "previousSeparatorColor"); - if (c && c->isValid()) { - return c; - } - - return {}; -} - -void ColorSettings::setPreviousSeparatorColor(const QColor& c) const -{ - set(m_Settings, "General", "previousSeparatorColor", c); -} - -void ColorSettings::removePreviousSeparatorColor() -{ - remove(m_Settings, "General", "previousSeparatorColor"); -} - -bool ColorSettings::colorSeparatorScrollbar() const -{ - return get(m_Settings, "Settings", "colorSeparatorScrollbars", true); -} - -void ColorSettings::setColorSeparatorScrollbar(bool b) -{ - set(m_Settings, "Settings", "colorSeparatorScrollbars", b); -} - -QColor ColorSettings::idealTextColor(const QColor& rBackgroundColor) -{ - if (rBackgroundColor.alpha() < 50) - return {Qt::black}; - - // "inverse' of luminance of the background - int iLuminance = (rBackgroundColor.red() * 0.299) + - (rBackgroundColor.green() * 0.587) + - (rBackgroundColor.blue() * 0.114); - return {iLuminance >= 128 ? Qt::black : Qt::white}; -} - -PluginSettings::PluginSettings(QSettings& settings) : m_Settings(settings) {} - -void PluginSettings::clearPlugins() -{ - m_Plugins.clear(); - m_PluginSettings.clear(); - m_PluginBlacklist.clear(); - - m_PluginBlacklist = readBlacklist(); -} - -void PluginSettings::registerPlugin(IPlugin* plugin) -{ - m_Plugins.push_back(plugin); - m_PluginSettings.insert(plugin->name(), QVariantMap()); - m_PluginDescriptions.insert(plugin->name(), QVariantMap()); - - for (const PluginSetting& setting : plugin->settings()) { - const QString settingName = plugin->name() + "/" + setting.key; - - QVariant temp = get(m_Settings, "Plugins", settingName, QVariant()); - - // No previous enabled? Skip. - if (setting.key == "enabled" && (!temp.isValid() || !temp.canConvert())) { - continue; - } - - if (!temp.isValid()) { - temp = setting.defaultValue; - } else if (!temp.convert(setting.defaultValue.metaType())) { - log::warn("failed to interpret \"{}\" as correct type for \"{}\" in plugin " - "\"{}\", using default", - temp.toString(), setting.key, plugin->name()); - - temp = setting.defaultValue; - } - - m_PluginSettings[plugin->name()][setting.key] = temp; - - m_PluginDescriptions[plugin->name()][setting.key] = - QString("%1 (default: %2)") - .arg(setting.description) - .arg(setting.defaultValue.toString()); - } - - // Handle previous "enabled" settings: - if (m_PluginSettings[plugin->name()].contains("enabled")) { - setPersistent(plugin->name(), "enabled", - m_PluginSettings[plugin->name()]["enabled"].toBool(), true); - m_PluginSettings[plugin->name()].remove("enabled"); - m_PluginDescriptions[plugin->name()].remove("enabled"); - - // We need to drop it manually in Settings since it is not possible to remove plugin - // settings: - remove(m_Settings, "Plugins", plugin->name() + "/enabled"); - } -} - -void PluginSettings::unregisterPlugin(IPlugin* plugin) -{ - auto it = std::find(m_Plugins.begin(), m_Plugins.end(), plugin); - if (it != m_Plugins.end()) { - m_Plugins.erase(it); - } - m_PluginSettings.remove(plugin->name()); - m_PluginDescriptions.remove(plugin->name()); -} - -std::vector PluginSettings::plugins() const -{ - return m_Plugins; -} - -QVariant PluginSettings::setting(const QString& pluginName, const QString& key) const -{ - auto iterPlugin = m_PluginSettings.find(pluginName); - if (iterPlugin == m_PluginSettings.end()) { - return {}; - } - - auto iterSetting = iterPlugin->find(key); - if (iterSetting == iterPlugin->end()) { - return {}; - } - - return *iterSetting; -} - -void PluginSettings::setSetting(const QString& pluginName, const QString& key, - const QVariant& value) -{ - auto iterPlugin = m_PluginSettings.find(pluginName); - - if (iterPlugin == m_PluginSettings.end()) { - throw MyException(QObject::tr("attempt to store setting for unknown plugin \"%1\"") - .arg(pluginName)); - } - - QVariant oldValue = m_PluginSettings[pluginName][key]; - - // store the new setting both in memory and in the ini - m_PluginSettings[pluginName][key] = value; - set(m_Settings, "Plugins", pluginName + "/" + key, value); - - // emit signal: - emit pluginSettingChanged(pluginName, key, oldValue, value); -} - -QVariantMap PluginSettings::settings(const QString& pluginName) const -{ - return m_PluginSettings[pluginName]; -} - -void PluginSettings::setSettings(const QString& pluginName, const QVariantMap& map) -{ - auto iterPlugin = m_PluginSettings.find(pluginName); - - if (iterPlugin == m_PluginSettings.end()) { - throw MyException(QObject::tr("attempt to store setting for unknown plugin \"%1\"") - .arg(pluginName)); - } - - QVariantMap oldSettings = m_PluginSettings[pluginName]; - m_PluginSettings[pluginName] = map; - - // Emit signals for settings that have been changed or added: - for (auto& k : map.keys()) { - // .value() return a default-constructed QVariant if k is not in oldSettings: - QVariant oldValue = oldSettings.value(k); - if (oldValue != map[k]) { - emit pluginSettingChanged(pluginName, k, oldSettings.value(k), map[k]); - } - } - - // Emit signals for settings that have been removed: - for (auto& k : oldSettings.keys()) { - if (!map.contains(k)) { - emit pluginSettingChanged(pluginName, k, oldSettings[k], QVariant()); - } - } -} - -QVariantMap PluginSettings::descriptions(const QString& pluginName) const -{ - return m_PluginDescriptions[pluginName]; -} - -void PluginSettings::setDescriptions(const QString& pluginName, const QVariantMap& map) -{ - m_PluginDescriptions[pluginName] = map; -} - -QVariant PluginSettings::persistent(const QString& pluginName, const QString& key, - const QVariant& def) const -{ - if (!m_PluginSettings.contains(pluginName)) { - return def; - } - - return get(m_Settings, "PluginPersistance", pluginName + "/" + key, def); -} - -void PluginSettings::setPersistent(const QString& pluginName, const QString& key, - const QVariant& value, bool sync) -{ - if (!m_PluginSettings.contains(pluginName)) { - throw MyException(QObject::tr("attempt to store setting for unknown plugin \"%1\"") - .arg(pluginName)); - } - - set(m_Settings, "PluginPersistance", pluginName + "/" + key, value); - - if (sync) { - m_Settings.sync(); - } -} - -void PluginSettings::addBlacklist(const QString& fileName) -{ - m_PluginBlacklist.insert(fileName); - writeBlacklist(); -} - -bool PluginSettings::blacklisted(const QString& fileName) const -{ - return m_PluginBlacklist.contains(fileName); -} - -void PluginSettings::setBlacklist(const QStringList& pluginNames) -{ - m_PluginBlacklist.clear(); - - for (const auto& name : pluginNames) { - m_PluginBlacklist.insert(name); - } -} - -const QSet& PluginSettings::blacklist() const -{ - return m_PluginBlacklist; -} - -void PluginSettings::save() -{ - for (auto iterPlugins = m_PluginSettings.begin(); - iterPlugins != m_PluginSettings.end(); ++iterPlugins) { - for (auto iterSettings = iterPlugins->begin(); iterSettings != iterPlugins->end(); - ++iterSettings) { - const auto key = iterPlugins.key() + "/" + iterSettings.key(); - set(m_Settings, "Plugins", key, iterSettings.value()); - } - } - - writeBlacklist(); -} - -void PluginSettings::writeBlacklist() -{ - const auto current = readBlacklist(); - - if (current.size() > m_PluginBlacklist.size()) { - // Qt can't remove array elements, the section must be cleared - removeSection(m_Settings, "pluginBlacklist"); - } - - ScopedWriteArray swa(m_Settings, "pluginBlacklist", m_PluginBlacklist.size()); - - for (const QString& plugin : m_PluginBlacklist) { - swa.next(); - swa.set("name", plugin); - } -} - -QSet PluginSettings::readBlacklist() const -{ - QSet set; - - ScopedReadArray sra(m_Settings, "pluginBlacklist"); - sra.for_each([&] { - set.insert(sra.get("name")); - }); - - return set; -} - -const QString PathSettings::BaseDirVariable = "%BASE_DIR%"; - -PathSettings::PathSettings(QSettings& settings) : m_Settings(settings) {} - -std::map PathSettings::recent() const -{ - std::map map; - - ScopedReadArray sra(m_Settings, "recentDirectories"); - - sra.for_each([&] { - const QVariant name = sra.get("name"); - const QVariant dir = sra.get("directory"); - - if (name.isValid() && dir.isValid()) { - map.emplace(name.toString(), loadStoredPath(dir.toString())); - } - }); - - return map; -} - -void PathSettings::setRecent(const std::map& map) -{ - const auto current = recent(); - - if (current.size() > map.size()) { - // Qt can't remove array elements, the section must be cleared - removeSection(m_Settings, "recentDirectories"); - } - - ScopedWriteArray swa(m_Settings, "recentDirectories", map.size()); - - for (auto&& p : map) { - swa.next(); - - swa.set("name", p.first); - swa.set("directory", storePathForIni(p.second)); - } -} - -QString PathSettings::getConfigurablePath(const QString& key, const QString& def, - bool resolve) const -{ - QString result = - loadStoredPath(get(m_Settings, "Settings", key, makeDefaultPath(def))); - - if (resolve) { - result = PathSettings::resolve(result, base()); - } - - return result; -} - -void PathSettings::setConfigurablePath(const QString& key, const QString& path) -{ - if (path.isEmpty()) { - remove(m_Settings, "Settings", key); - } else { - set(m_Settings, "Settings", key, storePathForIni(path)); - } -} - -QString PathSettings::resolve(const QString& path, const QString& baseDir) -{ - QString s = path; - s.replace(BaseDirVariable, baseDir); - return s; -} - -QString PathSettings::makeDefaultPath(const QString dirName) -{ - return BaseDirVariable + "/" + dirName; -} - -QString PathSettings::base() const -{ - const QString dataPath = QFileInfo(m_Settings.fileName()).dir().path(); - - return loadStoredPath(get(m_Settings, "Settings", "base_directory", - dataPath)); -} - -QString PathSettings::downloads(bool resolve) const -{ - return getConfigurablePath("download_directory", ToQString(AppConfig::downloadPath()), - resolve); -} - -QString PathSettings::cache(bool resolve) const -{ - return getConfigurablePath("cache_directory", ToQString(AppConfig::cachePath()), - resolve); -} - -QString PathSettings::mods(bool resolve) const -{ - return getConfigurablePath("mod_directory", ToQString(AppConfig::modsPath()), - resolve); -} - -QString PathSettings::profiles(bool resolve) const -{ - return getConfigurablePath("profiles_directory", ToQString(AppConfig::profilesPath()), - resolve); -} - -QString PathSettings::overwrite(bool resolve) const -{ - return getConfigurablePath("overwrite_directory", - ToQString(AppConfig::overwritePath()), resolve); -} - -void PathSettings::setBase(const QString& path) -{ - if (path.isEmpty()) { - remove(m_Settings, "Settings", "base_directory"); - } else { - set(m_Settings, "Settings", "base_directory", storePathForIni(path)); - } -} - -void PathSettings::setDownloads(const QString& path) -{ - setConfigurablePath("download_directory", path); -} - -void PathSettings::setMods(const QString& path) -{ - setConfigurablePath("mod_directory", path); -} - -void PathSettings::setCache(const QString& path) -{ - setConfigurablePath("cache_directory", path); -} - -void PathSettings::setProfiles(const QString& path) -{ - setConfigurablePath("profiles_directory", path); -} - -void PathSettings::setOverwrite(const QString& path) -{ - setConfigurablePath("overwrite_directory", path); -} - -NetworkSettings::NetworkSettings(QSettings& settings, bool globalInstance) - : m_Settings(settings) -{ - if (globalInstance) { - updateCustomBrowser(); - } -} - -void NetworkSettings::updateCustomBrowser() const -{ - if (useCustomBrowser()) { - MOBase::shell::SetUrlHandler(customBrowserCommand()); - } else { - MOBase::shell::SetUrlHandler(""); - } -} - -bool NetworkSettings::offlineMode() const -{ - return get(m_Settings, "Settings", "offline_mode", false); -} - -void NetworkSettings::setOfflineMode(bool b) -{ - set(m_Settings, "Settings", "offline_mode", b); -} - -bool NetworkSettings::useProxy() const -{ - return get(m_Settings, "Settings", "use_proxy", false); -} - -void NetworkSettings::setUseProxy(bool b) -{ - set(m_Settings, "Settings", "use_proxy", b); -} - -void NetworkSettings::setDownloadSpeed(const QString& name, int bytesPerSecond) -{ - auto current = servers(); - - for (auto& server : current) { - if (server.name() == name) { - server.addDownload(bytesPerSecond); - updateServers(current); - return; - } - } - - log::error("server '{}' not found while trying to add a download with bps {}", name, - bytesPerSecond); -} - -ServerList NetworkSettings::servers() const -{ - ServerList list; - - { - ScopedReadArray sra(m_Settings, "Servers"); - - sra.for_each([&] { - ServerInfo::SpeedList lastDownloads; - - const auto lastDownloadsString = sra.get("lastDownloads", ""); - - for (const auto& s : lastDownloadsString.split(" ")) { - const auto bytesPerSecond = s.toInt(); - if (bytesPerSecond > 0) { - lastDownloads.push_back(bytesPerSecond); - } - } - - ServerInfo server( - sra.get("name", ""), sra.get("premium", false), - QDate::fromString(sra.get("lastSeen", ""), Qt::ISODate), - sra.get("preferred", 0), lastDownloads); - - list.add(std::move(server)); - }); - } - - return list; -} - -void NetworkSettings::updateServers(ServerList newServers) -{ - // clean up unavailable servers - newServers.cleanup(); - - const auto current = servers(); - - if (current.size() > newServers.size()) { - // Qt can't remove array elements, the section must be cleared - removeSection(m_Settings, "Servers"); - } - - ScopedWriteArray swa(m_Settings, "Servers", newServers.size()); - - for (const auto& server : newServers) { - swa.next(); - - swa.set("name", server.name()); - swa.set("premium", server.isPremium()); - swa.set("lastSeen", server.lastSeen().toString(Qt::ISODate)); - swa.set("preferred", server.preferred()); - - QString lastDownloads; - for (const auto& speed : server.lastDownloads()) { - if (speed > 0) { - lastDownloads += QString("%1 ").arg(speed); - } - } - - swa.set("lastDownloads", lastDownloads.trimmed()); - } -} - -void NetworkSettings::updateFromOldMap() -{ - // servers used to be a map of byte arrays until 2.2.1, it's now an array of - // individual values instead - // - // so post 2.2.1, only one key is returned: "size", the size of the arrays; - // in 2.2.1, one key per server is returned - - // sanity check that this is really 2.2.1 - { - const QStringList keys = ScopedGroup(m_Settings, "Servers").keys(); - - for (auto&& k : keys) { - if (k == "size") { - // this looks like an array, so the upgrade was probably already done - return; - } - } - } - - const auto servers = serversFromOldMap(); - removeSection(m_Settings, "Servers"); - updateServers(servers); -} - -bool NetworkSettings::useCustomBrowser() const -{ - return get(m_Settings, "Settings", "use_custom_browser", false); -} - -void NetworkSettings::setUseCustomBrowser(bool b) -{ - set(m_Settings, "Settings", "use_custom_browser", b); - updateCustomBrowser(); -} - -QString NetworkSettings::customBrowserCommand() const -{ - return get(m_Settings, "Settings", "custom_browser", ""); -} - -void NetworkSettings::setCustomBrowserCommand(const QString& s) -{ - set(m_Settings, "Settings", "custom_browser", s); - updateCustomBrowser(); -} - -ServerList NetworkSettings::serversFromOldMap() const -{ - // for 2.2.1 and before - - ServerList list; - const ScopedGroup sg(m_Settings, "Servers"); - - sg.for_each([&](auto&& serverKey) { - QVariantMap data = sg.get(serverKey); - - ServerInfo server(serverKey, data["premium"].toBool(), data["lastSeen"].toDate(), - data["preferred"].toInt(), {}); - - // ignoring download count and speed, it's now a list of values instead of - // a total - - list.add(std::move(server)); - }); - - return list; -} - -void NetworkSettings::dump() const -{ - log::debug("servers:"); - - for (const auto& server : servers()) { - QString lastDownloads; - for (auto speed : server.lastDownloads()) { - lastDownloads += QString("%1 ").arg(speed); - } - - log::debug(" . {} premium={} lastSeen={} preferred={} lastDownloads={}", - server.name(), server.isPremium() ? "yes" : "no", - server.lastSeen().toString(Qt::ISODate), server.preferred(), - lastDownloads.trimmed()); - } -} - -NexusSettings::NexusSettings(Settings& parent, QSettings& settings) - : m_Parent(parent), m_Settings(settings) -{} - -bool NexusSettings::endorsementIntegration() const -{ - return get(m_Settings, "Settings", "endorsement_integration", true); -} - -void NexusSettings::setEndorsementIntegration(bool b) const -{ - set(m_Settings, "Settings", "endorsement_integration", b); -} - -EndorsementState NexusSettings::endorsementState() const -{ - return endorsementStateFromString( - get(m_Settings, "General", "endorse_state", "")); -} - -void NexusSettings::setEndorsementState(EndorsementState s) -{ - const auto v = toString(s); - - if (v.isEmpty()) { - remove(m_Settings, "General", "endorse_state"); - } else { - set(m_Settings, "General", "endorse_state", v); - } -} - -bool NexusSettings::trackedIntegration() const -{ - return get(m_Settings, "Settings", "tracked_integration", true); -} - -void NexusSettings::setTrackedIntegration(bool b) const -{ - set(m_Settings, "Settings", "tracked_integration", b); -} - -bool NexusSettings::categoryMappings() const -{ - return get(m_Settings, "Settings", "category_mappings", true); -} - -void NexusSettings::setCategoryMappings(bool b) const -{ - set(m_Settings, "Settings", "category_mappings", b); -} - -void NexusSettings::registerAsNXMHandler(bool force) -{ - Q_UNUSED(force); - NxmHandlerLinux handler; - handler.registerHandler(); -} - -std::vector NexusSettings::validationTimeouts() const -{ - using namespace std::chrono_literals; - - const auto s = get(m_Settings, "Settings", "validation_timeouts", ""); - - const auto numbers = s.split(" "); - std::vector v; - - for (auto ns : numbers) { - ns = ns.trimmed(); - if (ns.isEmpty()) - continue; - - bool ok = false; - const auto n = ns.toInt(&ok); - - if (!ok || n < 0 || n > 100) { - log::error("bad validation_timeouts number '{}'", ns); - continue; - } - - v.push_back(std::chrono::seconds(n)); - } - - if (v.empty()) - v = {10s, 15s, 20s}; - - return v; -} - -void NexusSettings::dump() -{ - const auto iniPath = InstanceManager::singleton().globalInstancesRootPath() + "/" + - QString::fromStdWString(AppConfig::nxmHandlerIni()); - - if (!QFileInfo(iniPath).exists()) { - log::debug("nxm ini not found at {}", iniPath); - return; - } - - QSettings s(iniPath, QSettings::IniFormat); - if (const auto st = s.status(); st != QSettings::NoError) { - log::debug("can't read nxm ini from {}", iniPath); - return; - } - - log::debug("nxmhandler settings:"); - - QSettings handler(R"(HKEY_CURRENT_USER\Software\Classes\nxm\)", - QSettings::NativeFormat); - log::debug(" . primary: {}", handler.value("shell/open/command/Default").toString()); - - const auto noregister = getOptional(s, "General", "noregister"); - - if (noregister) { - log::debug(" . noregister: {}", *noregister); - } else { - log::debug(" . noregister: (not found)"); - } - - ScopedReadArray sra(s, "handlers"); - - sra.for_each([&] { - const auto games = sra.get("games"); - const auto executable = sra.get("executable"); - const auto arguments = sra.get("arguments"); - - log::debug(" . handler:"); - log::debug(" . games: {}", games.toString()); - log::debug(" . executable: {}", executable.toString()); - log::debug(" . arguments: {}", arguments.toString()); - }); -} - -SteamSettings::SteamSettings(Settings& parent, QSettings& settings) - : m_Parent(parent), m_Settings(settings) -{} - -QString SteamSettings::appID() const -{ - return get(m_Settings, "Settings", "app_id", - m_Parent.game().plugin()->steamAPPId()); -} - -void SteamSettings::setAppID(const QString& id) -{ - if (id.isEmpty()) { - remove(m_Settings, "Settings", "app_id"); - } else { - set(m_Settings, "Settings", "app_id", id); - } -} - -bool SteamSettings::login(QString& username, QString& password) const -{ - username = get(m_Settings, "Settings", "steam_username", ""); - password = getWindowsCredential("steam_password"); - - return !username.isEmpty() && !password.isEmpty(); -} - -void SteamSettings::setLogin(QString username, QString password) -{ - if (username == "") { - remove(m_Settings, "Settings", "steam_username"); - password = ""; - } else { - set(m_Settings, "Settings", "steam_username", username); - } - - if (!setWindowsCredential("steam_password", password)) { - const auto e = GetLastError(); - log::error("Storing or deleting password failed: {}", formatSystemMessage(e)); - } -} - -InterfaceSettings::InterfaceSettings(QSettings& settings) : m_Settings(settings) {} - -bool InterfaceSettings::lockGUI() const -{ - return get(m_Settings, "Settings", "lock_gui", true); -} - -void InterfaceSettings::setLockGUI(bool b) -{ - set(m_Settings, "Settings", "lock_gui", b); -} - -std::optional InterfaceSettings::styleName() const -{ - return getOptional(m_Settings, "Settings", "style"); -} - -void InterfaceSettings::setStyleName(const QString& name) -{ - set(m_Settings, "Settings", "style", name); -} - -bool InterfaceSettings::collapsibleSeparators(Qt::SortOrder order) const -{ - return get(m_Settings, "Settings", - order == Qt::AscendingOrder ? "collapsible_separators_asc" - : "collapsible_separators_dsc", - true); -} - -void InterfaceSettings::setCollapsibleSeparators(bool ascending, bool descending) -{ - set(m_Settings, "Settings", "collapsible_separators_asc", ascending); - set(m_Settings, "Settings", "collapsible_separators_dsc", descending); -} - -bool InterfaceSettings::collapsibleSeparatorsHighlightTo() const -{ - return get(m_Settings, "Settings", "collapsible_separators_conflicts_to", true); -} - -void InterfaceSettings::setCollapsibleSeparatorsHighlightTo(bool b) -{ - set(m_Settings, "Settings", "collapsible_separators_conflicts_to", b); -} - -bool InterfaceSettings::collapsibleSeparatorsHighlightFrom() const -{ - return get(m_Settings, "Settings", "collapsible_separators_conflicts_from", - true); -} - -void InterfaceSettings::setCollapsibleSeparatorsHighlightFrom(bool b) -{ - set(m_Settings, "Settings", "collapsible_separators_conflicts_from", b); -} - -bool InterfaceSettings::collapsibleSeparatorsIcons(int column) const -{ - return get(m_Settings, "Settings", - QString("collapsible_separators_icons_%1").arg(column), true); -} - -void InterfaceSettings::setCollapsibleSeparatorsIcons(int column, bool show) -{ - set(m_Settings, "Settings", QString("collapsible_separators_icons_%1").arg(column), - show); -} - -bool InterfaceSettings::collapsibleSeparatorsPerProfile() const -{ - return get(m_Settings, "Settings", "collapsible_separators_per_profile", false); -} - -void InterfaceSettings::setCollapsibleSeparatorsPerProfile(bool b) -{ - set(m_Settings, "Settings", "collapsible_separators_per_profile", b); -} - -bool InterfaceSettings::saveFilters() const -{ - return get(m_Settings, "Settings", "save_filters", false); -} - -void InterfaceSettings::setSaveFilters(bool b) -{ - set(m_Settings, "Settings", "save_filters", b); -} - -bool InterfaceSettings::autoCollapseOnHover() const -{ - return get(m_Settings, "Settings", "auto_collapse_on_hover", false); -} - -void InterfaceSettings::setAutoCollapseOnHover(bool b) -{ - set(m_Settings, "Settings", "auto_collapse_on_hover", b); -} - -bool InterfaceSettings::checkUpdateAfterInstallation() const -{ - return get(m_Settings, "Settings", "autocheck_update_install", true); -} - -void InterfaceSettings::setCheckUpdateAfterInstallation(bool b) -{ - set(m_Settings, "Settings", "autocheck_update_install", b); -} - -bool InterfaceSettings::compactDownloads() const -{ - return get(m_Settings, "Settings", "compact_downloads", false); -} - -void InterfaceSettings::setCompactDownloads(bool b) -{ - set(m_Settings, "Settings", "compact_downloads", b); -} - -bool InterfaceSettings::metaDownloads() const -{ - return get(m_Settings, "Settings", "meta_downloads", false); -} - -void InterfaceSettings::setMetaDownloads(bool b) -{ - set(m_Settings, "Settings", "meta_downloads", b); -} - -bool InterfaceSettings::hideDownloadsAfterInstallation() const -{ - return get(m_Settings, "Settings", "autohide_downloads", false); -} - -void InterfaceSettings::setHideDownloadsAfterInstallation(bool b) -{ - set(m_Settings, "Settings", "autohide_downloads", b); -} - -bool InterfaceSettings::hideAPICounter() const -{ - return get(m_Settings, "Settings", "hide_api_counter", false); -} - -void InterfaceSettings::setHideAPICounter(bool b) -{ - set(m_Settings, "Settings", "hide_api_counter", b); -} - -bool InterfaceSettings::displayForeign() const -{ - return get(m_Settings, "Settings", "display_foreign", true); -} - -void InterfaceSettings::setDisplayForeign(bool b) -{ - set(m_Settings, "Settings", "display_foreign", b); -} - -QString InterfaceSettings::language() -{ - QString result = get(m_Settings, "Settings", "language", ""); - - if (result.isEmpty()) { - QStringList languagePreferences = QLocale::system().uiLanguages(); - - if (!languagePreferences.empty()) { - // the users most favoritest language - result = languagePreferences.at(0); - } else { - // fallback system locale - result = QLocale::system().name(); - } - } - - return result; -} - -void InterfaceSettings::setLanguage(const QString& name) -{ - set(m_Settings, "Settings", "language", name); -} - -bool InterfaceSettings::isTutorialCompleted(const QString& windowName) const -{ - return get(m_Settings, "CompletedWindowTutorials", windowName, false); -} - -void InterfaceSettings::setTutorialCompleted(const QString& windowName, bool b) -{ - set(m_Settings, "CompletedWindowTutorials", windowName, b); -} - -bool InterfaceSettings::showChangeGameConfirmation() const -{ - return get(m_Settings, "Settings", "show_change_game_confirmation", true); -} - -void InterfaceSettings::setShowChangeGameConfirmation(bool b) -{ - set(m_Settings, "Settings", "show_change_game_confirmation", b); -} - -bool InterfaceSettings::showMenubarOnAlt() const -{ - return get(m_Settings, "Settings", "show_menubar_on_alt", true); -} - -void InterfaceSettings::setShowMenubarOnAlt(bool b) -{ - set(m_Settings, "Settings", "show_menubar_on_alt", b); -} - -bool InterfaceSettings::doubleClicksOpenPreviews() const -{ - return get(m_Settings, "Settings", "double_click_previews", true); -} - -void InterfaceSettings::setDoubleClicksOpenPreviews(bool b) -{ - set(m_Settings, "Settings", "double_click_previews", b); -} - -FilterWidget::Options InterfaceSettings::filterOptions() const -{ - FilterWidget::Options o; - - o.useRegex = get(m_Settings, "Settings", "filter_regex", false); - o.regexCaseSensitive = - get(m_Settings, "Settings", "regex_case_sensitive", false); - o.regexExtended = get(m_Settings, "Settings", "regex_extended", false); - o.scrollToSelection = - get(m_Settings, "Settings", "filter_scroll_to_selection", false); - - return o; -} - -void InterfaceSettings::setFilterOptions(const FilterWidget::Options& o) -{ - set(m_Settings, "Settings", "filter_regex", o.useRegex); - set(m_Settings, "Settings", "regex_case_sensitive", o.regexCaseSensitive); - set(m_Settings, "Settings", "regex_extended", o.regexExtended); - set(m_Settings, "Settings", "filter_scroll_to_selection", o.scrollToSelection); -} - -DiagnosticsSettings::DiagnosticsSettings(QSettings& settings) : m_Settings(settings) {} - -log::Levels DiagnosticsSettings::logLevel() const -{ - return get(m_Settings, "Settings", "log_level", log::Levels::Info); -} - -void DiagnosticsSettings::setLogLevel(log::Levels level) -{ - set(m_Settings, "Settings", "log_level", level); -} - -env::CoreDumpTypes DiagnosticsSettings::coreDumpType() const -{ - return get(m_Settings, "Settings", "crash_dumps_type", - env::CoreDumpTypes::Mini); -} - -void DiagnosticsSettings::setCoreDumpType(env::CoreDumpTypes type) -{ - set(m_Settings, "Settings", "crash_dumps_type", type); -} - -int DiagnosticsSettings::maxCoreDumps() const -{ - return get(m_Settings, "Settings", "crash_dumps_max", 5); -} - -void DiagnosticsSettings::setMaxCoreDumps(int n) -{ - set(m_Settings, "Settings", "crash_dumps_max", n); -} - -std::chrono::seconds DiagnosticsSettings::spawnDelay() const -{ - return std::chrono::seconds(get(m_Settings, "Settings", "spawn_delay", 0)); -} - -void DiagnosticsSettings::setSpawnDelay(std::chrono::seconds t) -{ - set(m_Settings, "Settings", "spawn_delay", t.count()); -} - -void GlobalSettings::updateRegistryKey() -{ - const QString OldOrganization = "Tannin"; - const QString OldApplication = "Mod Organizer"; - const QString OldInstanceValue = "CurrentInstance"; - - const QString OldRootKey = "Software\\" + OldOrganization; - - if (env::registryValueExists(OldRootKey + "\\" + OldApplication, OldInstanceValue)) { - QSettings old(OldOrganization, OldApplication); - setCurrentInstance(old.value(OldInstanceValue).toString()); - old.remove(OldInstanceValue); - } - - env::deleteRegistryKeyIfEmpty(OldRootKey); -} - -QString GlobalSettings::currentInstance() -{ - return settings().value("CurrentInstance", "").toString(); -} - -void GlobalSettings::setCurrentInstance(const QString& s) -{ - settings().setValue("CurrentInstance", s); -} - -QSettings GlobalSettings::settings() -{ - const QString Organization = "Mod Organizer Team"; - const QString Application = "Mod Organizer"; - - return QSettings(Organization, Application); -} - -bool GlobalSettings::hideCreateInstanceIntro() -{ - return settings().value("HideCreateInstanceIntro", false).toBool(); -} - -void GlobalSettings::setHideCreateInstanceIntro(bool b) -{ - settings().setValue("HideCreateInstanceIntro", b); -} - -bool GlobalSettings::hideTutorialQuestion() -{ - return settings().value("HideTutorialQuestion", false).toBool(); -} - -void GlobalSettings::setHideTutorialQuestion(bool b) -{ - settings().setValue("HideTutorialQuestion", b); -} - -bool GlobalSettings::hideCategoryReminder() -{ - return settings().value("HideCategoryReminder", false).toBool(); -} - -void GlobalSettings::setHideCategoryReminder(bool b) -{ - settings().setValue("HideCategoryReminder", b); -} - -bool GlobalSettings::hideAssignCategoriesQuestion() -{ - return settings().value("HideAssignCategoriesQuestion", false).toBool(); -} - -void GlobalSettings::setHideAssignCategoriesQuestion(bool b) -{ - settings().setValue("HideAssignCategoriesQuestion", b); -} - -bool GlobalSettings::nexusApiKey(QString& apiKey) -{ - QString tempKey = getWindowsCredential("APIKEY"); - if (tempKey.isEmpty()) - return false; - - apiKey = tempKey; - return true; -} - -bool GlobalSettings::setNexusApiKey(const QString& apiKey) -{ - if (!setWindowsCredential("APIKEY", apiKey)) { - const auto e = GetLastError(); - log::error("Storing API key failed: {}", formatSystemMessage(e)); - return false; - } - - return true; -} - -bool GlobalSettings::clearNexusApiKey() -{ - return setNexusApiKey(""); -} - -bool GlobalSettings::hasNexusApiKey() -{ - return !getWindowsCredential("APIKEY").isEmpty(); -} - -void GlobalSettings::resetDialogs() -{ - setHideCreateInstanceIntro(false); - setHideTutorialQuestion(false); -} - -QStringList GlobalSettings::portableInstances() -{ - return settings().value("PortableInstances").toStringList(); -} - -void GlobalSettings::addPortableInstance(const QString& path) -{ - const QString canonical = QDir(path).absolutePath(); - QStringList list = portableInstances(); - if (!list.contains(canonical)) { - list.append(canonical); - settings().setValue("PortableInstances", list); - } -} - -void GlobalSettings::removePortableInstance(const QString& path) -{ - const QString canonical = QDir(path).absolutePath(); - QStringList list = portableInstances(); - if (list.removeAll(canonical) > 0) { - settings().setValue("PortableInstances", list); - } -} +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "settings.h" +#include +#include "env.h" +#include "envmetrics.h" +#include "executableslist.h" +#include "instancemanager.h" +#include "modelutils.h" +#include "nxmhandler_linux.h" +#include "serverinfo.h" +#include "settingsutilities.h" +#include "shared/appconfig.h" +#include +#include +#include + +using namespace MOBase; +using namespace MOShared; + +namespace +{ +bool usesBaseDirVariable(const QString& path) +{ + return path.contains(PathSettings::BaseDirVariable, Qt::CaseInsensitive); +} + +QString loadStoredPath(const QString& path) +{ + QString value = QDir::fromNativeSeparators(path); + if (!usesBaseDirVariable(value)) { + value = MOBase::normalizePathForHost(value); + } + return value; +} + +QString storePathForIni(const QString& path) +{ + if (path.isEmpty()) { + return path; + } + + if (usesBaseDirVariable(path)) { + return QDir::fromNativeSeparators(path); + } + + return MOBase::normalizePathForWine(path); +} +} // namespace + +EndorsementState endorsementStateFromString(const QString& s) +{ + if (s == "Endorsed") { + return EndorsementState::Accepted; + } else if (s == "Abstained") { + return EndorsementState::Refused; + } else { + return EndorsementState::NoDecision; + } +} + +QString toString(EndorsementState s) +{ + switch (s) { + case EndorsementState::Accepted: + return "Endorsed"; + + case EndorsementState::Refused: + return "Abstained"; + + case EndorsementState::NoDecision: // fall-through + default: + return {}; + } +} + +Settings* Settings::s_Instance = nullptr; + +Settings::Settings(const QString& path, bool globalInstance) + : m_Settings(path, QSettings::IniFormat), m_Game(m_Settings), + m_Geometry(m_Settings), m_Widgets(m_Settings, globalInstance), + m_Colors(m_Settings), m_Plugins(m_Settings), m_Paths(m_Settings), + m_Network(m_Settings, globalInstance), m_Nexus(*this, m_Settings), + m_Steam(*this, m_Settings), m_Interface(m_Settings), m_Diagnostics(m_Settings) +{ + if (globalInstance) { + if (s_Instance != nullptr) { + throw std::runtime_error("second instance of \"Settings\" created"); + } else { + s_Instance = this; + } + } +} + +Settings::~Settings() +{ + if (s_Instance == this) { + MOBase::QuestionBoxMemory::setCallbacks({}, {}, {}); + s_Instance = nullptr; + } +} + +Settings& Settings::instance() +{ + if (s_Instance == nullptr) { + throw std::runtime_error("no instance of \"Settings\""); + } + + return *s_Instance; +} + +Settings* Settings::maybeInstance() +{ + return s_Instance; +} + +void Settings::processUpdates(const QVersionNumber& currentVersion, + const QVersionNumber& lastVersion) +{ + if (firstStart()) { + set(m_Settings, "General", "version", currentVersion.toString()); + return; + } + + if (currentVersion == lastVersion) { + return; + } + + log::info("updating from {} to {}", lastVersion.toString(), + currentVersion.toString()); + + auto version = [&](const QVersionNumber& v, auto&& f) { + if (lastVersion < v) { + log::debug("processing updates for {}", v.toString()); + f(); + } + }; + + version({2, 2, 0}, [&] { + remove(m_Settings, "Settings", "steam_password"); + remove(m_Settings, "Settings", "nexus_username"); + remove(m_Settings, "Settings", "nexus_password"); + remove(m_Settings, "Settings", "nexus_login"); + remove(m_Settings, "Settings", "nexus_api_key"); + remove(m_Settings, "Settings", "ask_for_nexuspw"); + remove(m_Settings, "Settings", "nmm_version"); + + removeSection(m_Settings, "Servers"); + }); + + version({2, 2, 1}, [&] { + remove(m_Settings, "General", "mod_info_tabs"); + remove(m_Settings, "General", "mod_info_conflict_expanders"); + remove(m_Settings, "General", "mod_info_conflicts"); + remove(m_Settings, "General", "mod_info_advanced_conflicts"); + remove(m_Settings, "General", "mod_info_conflicts_overwrite"); + remove(m_Settings, "General", "mod_info_conflicts_noconflict"); + remove(m_Settings, "General", "mod_info_conflicts_overwritten"); + }); + + version({2, 2, 2}, [&] { + // log splitter is gone, it's a dock now + remove(m_Settings, "General", "log_split"); + + // moved to widgets + remove(m_Settings, "General", "mod_info_conflicts_tab"); + remove(m_Settings, "General", "mod_info_conflicts_general_expanders"); + remove(m_Settings, "General", "mod_info_conflicts_general_overwrite"); + remove(m_Settings, "General", "mod_info_conflicts_general_noconflict"); + remove(m_Settings, "General", "mod_info_conflicts_general_overwritten"); + remove(m_Settings, "General", "mod_info_conflicts_advanced_list"); + remove(m_Settings, "General", "mod_info_conflicts_advanced_options"); + remove(m_Settings, "General", "mod_info_tab_order"); + remove(m_Settings, "General", "mod_info_dialog_images_show_dds"); + + // moved to geometry + remove(m_Settings, "General", "window_geometry"); + remove(m_Settings, "General", "window_state"); + remove(m_Settings, "General", "toolbar_size"); + remove(m_Settings, "General", "toolbar_button_style"); + remove(m_Settings, "General", "menubar_visible"); + remove(m_Settings, "General", "statusbar_visible"); + remove(m_Settings, "General", "window_split"); + remove(m_Settings, "General", "window_monitor"); + remove(m_Settings, "General", "browser_geometry"); + remove(m_Settings, "General", "filters_visible"); + + // this was supposed to have been removed above when updating from 2.2.0, + // but it wasn't in Settings, it was in General + remove(m_Settings, "General", "ask_for_nexuspw"); + + m_Network.updateFromOldMap(); + }); + + version({2, 4, 0}, [&] { + // removed + remove(m_Settings, "Settings", "hide_unchecked_plugins"); + remove(m_Settings, "Settings", "load_mechanism"); + }); + + // save version in all case + set(m_Settings, "General", "version", currentVersion.toString()); + + log::debug("updating done"); +} + +QString Settings::filename() const +{ + return m_Settings.fileName(); +} + +bool Settings::checkForUpdates() const +{ + return get(m_Settings, "Settings", "check_for_updates", true); +} + +void Settings::setCheckForUpdates(bool b) +{ + set(m_Settings, "Settings", "check_for_updates", b); +} + +bool Settings::usePrereleases() const +{ + return get(m_Settings, "Settings", "use_prereleases", false); +} + +void Settings::setUsePrereleases(bool b) +{ + set(m_Settings, "Settings", "use_prereleases", b); +} + +QString Settings::fluorineUpdateChannel() const +{ +#if FLUORINE_IS_BETA_BUILD + const QString defaultChannel = QStringLiteral("beta"); +#else + const QString defaultChannel = QStringLiteral("stable"); +#endif + return get(m_Settings, "Settings", "fluorine_update_channel", + defaultChannel); +} + +void Settings::setFluorineUpdateChannel(const QString& channel) +{ + set(m_Settings, "Settings", "fluorine_update_channel", channel); +} + +bool Settings::profileLocalInis() const +{ + return get(m_Settings, "Settings", "profile_local_inis", true); +} + +void Settings::setProfileLocalInis(bool b) +{ + set(m_Settings, "Settings", "profile_local_inis", b); +} + +bool Settings::profileLocalSaves() const +{ + return get(m_Settings, "Settings", "profile_local_saves", false); +} + +void Settings::setProfileLocalSaves(bool b) +{ + set(m_Settings, "Settings", "profile_local_saves", b); +} + +bool Settings::profileArchiveInvalidation() const +{ + return get(m_Settings, "Settings", "profile_archive_invalidation", false); +} + +void Settings::setProfileArchiveInvalidation(bool b) +{ + set(m_Settings, "Settings", "profile_archive_invalidation", b); +} + +bool Settings::useSplash() const +{ + return get(m_Settings, "Settings", "use_splash", true); +} + +void Settings::setUseSplash(bool b) +{ + set(m_Settings, "Settings", "use_splash", b); +} + +std::size_t Settings::refreshThreadCount() const +{ + return get(m_Settings, "Settings", "refresh_thread_count", 10); +} + +void Settings::setRefreshThreadCount(std::size_t n) const +{ + return set(m_Settings, "Settings", "refresh_thread_count", n); +} + +std::optional Settings::version() const +{ + if (auto v = getOptional(m_Settings, "General", "version")) { + return QVersionNumber::fromString(*v).normalized(); + } + + return {}; +} + +bool Settings::firstStart() const +{ + return get(m_Settings, "General", "first_start", true); +} + +void Settings::setFirstStart(bool b) +{ + set(m_Settings, "General", "first_start", b); +} + +QString Settings::executablesBlacklist() const +{ + static const QString def = (QStringList() << "Chrome.exe" + << "Firefox.exe" + << "TSVNCache.exe" + << "TGitCache.exe" + << "Steam.exe" + << "GameOverlayUI.exe" + << "Discord.exe" + << "GalaxyClient.exe" + << "Spotify.exe" + << "Brave.exe") + .join(";"); + + return get(m_Settings, "Settings", "executable_blacklist", def); +} + +bool Settings::isExecutableBlacklisted(const QString& s) const +{ + for (auto exec : executablesBlacklist().split(";")) { + if (exec.compare(s, Qt::CaseInsensitive) == 0) { + return true; + } + } + + return false; +} + +void Settings::setExecutablesBlacklist(const QString& s) +{ + set(m_Settings, "Settings", "executable_blacklist", s); +} + +QStringList Settings::skipFileSuffixes() const +{ + static const QStringList def = QStringList() << ".mohidden"; + + auto setting = get(m_Settings, "Settings", "skip_file_suffixes", def); + + return setting; +} + +void Settings::setSkipFileSuffixes(const QStringList& s) +{ + set(m_Settings, "Settings", "skip_file_suffixes", s); +} + +QStringList Settings::skipDirectories() const +{ + static const QStringList def = QStringList() << ".git"; + + auto setting = get(m_Settings, "Settings", "skip_directories", def); + + return setting; +} + +void Settings::setSkipDirectories(const QStringList& s) +{ + set(m_Settings, "Settings", "skip_directories", s); +} + +void Settings::setMotdHash(uint hash) +{ + set(m_Settings, "General", "motd_hash", hash); +} + +unsigned int Settings::motdHash() const +{ + return get(m_Settings, "General", "motd_hash", 0); +} + +bool Settings::archiveParsing() const +{ + return get(m_Settings, "Settings", "archive_parsing_experimental", false); +} + +void Settings::setArchiveParsing(bool b) +{ + set(m_Settings, "Settings", "archive_parsing_experimental", b); +} + +std::vector> Settings::executables() const +{ + ScopedReadArray sra(m_Settings, "customExecutables"); + std::vector> v; + + sra.for_each([&] { + std::map map; + + for (auto&& key : sra.keys()) { + map[key] = sra.get(key); + } + if (map.contains("binary")) { + map["binary"] = loadStoredPath(map["binary"].toString()); + } + if (map.contains("workingDirectory")) { + map["workingDirectory"] = + loadStoredPath(map["workingDirectory"].toString()); + } + + v.push_back(map); + }); + + return v; +} + +void Settings::setExecutables(const std::vector>& v) +{ + const auto current = executables(); + + if (current == v) { + // no change + return; + } + + if (current.size() > v.size()) { + // Qt can't remove array elements, the section must be cleared + removeSection(m_Settings, "customExecutables"); + } + + ScopedWriteArray swa(m_Settings, "customExecutables", v.size()); + + for (const auto& map : v) { + swa.next(); + + for (auto&& p : map) { + if ((p.first == "binary") || (p.first == "workingDirectory")) { + swa.set(p.first, storePathForIni(p.second.toString())); + } else { + swa.set(p.first, p.second); + } + } + } +} + +bool Settings::keepBackupOnInstall() const +{ + return get(m_Settings, "General", "backup_install", false); +} + +void Settings::setKeepBackupOnInstall(bool b) +{ + set(m_Settings, "General", "backup_install", b); +} + +GameSettings& Settings::game() +{ + return m_Game; +} + +const GameSettings& Settings::game() const +{ + return m_Game; +} + +GeometrySettings& Settings::geometry() +{ + return m_Geometry; +} + +const GeometrySettings& Settings::geometry() const +{ + return m_Geometry; +} + +WidgetSettings& Settings::widgets() +{ + return m_Widgets; +} + +const WidgetSettings& Settings::widgets() const +{ + return m_Widgets; +} + +ColorSettings& Settings::colors() +{ + return m_Colors; +} + +const ColorSettings& Settings::colors() const +{ + return m_Colors; +} + +PluginSettings& Settings::plugins() +{ + return m_Plugins; +} + +const PluginSettings& Settings::plugins() const +{ + return m_Plugins; +} + +PathSettings& Settings::paths() +{ + return m_Paths; +} + +const PathSettings& Settings::paths() const +{ + return m_Paths; +} + +NetworkSettings& Settings::network() +{ + return m_Network; +} + +const NetworkSettings& Settings::network() const +{ + return m_Network; +} + +NexusSettings& Settings::nexus() +{ + return m_Nexus; +} + +const NexusSettings& Settings::nexus() const +{ + return m_Nexus; +} + +SteamSettings& Settings::steam() +{ + return m_Steam; +} + +const SteamSettings& Settings::steam() const +{ + return m_Steam; +} + +InterfaceSettings& Settings::interface() +{ + return m_Interface; +} + +const InterfaceSettings& Settings::interface() const +{ + return m_Interface; +} + +DiagnosticsSettings& Settings::diagnostics() +{ + return m_Diagnostics; +} + +const DiagnosticsSettings& Settings::diagnostics() const +{ + return m_Diagnostics; +} + +QSettings::Status Settings::sync() const +{ + m_Settings.sync(); + + const auto s = m_Settings.status(); + + // there's a bug in Qt at least until 5.15.0 where a utf-8 bom in the ini is + // handled correctly but still sets FormatError + // + // see qsettings.cpp, in QConfFileSettingsPrivate::readIniFile(), there's a + // specific check for utf-8, which adjusts `dataPos` so it's skipped, but + // the FLUSH_CURRENT_SECTION() macro uses `currentSectionStart`, and that one + // isn't adjusted when changing `dataPos` on the first line and so stays 0 + // + // this puts the bom in `unparsedIniSections` and eventually sets FormatError + // somewhere + // + // + // the other problem is that the status is never reset, not even when calling + // sync(), so the FormatError that's returned here is actually from reading + // the ini, not writing it + // + // + // since it's impossible to get a FormatError on write, it's considered to + // be a NoError here + + if (s == QSettings::FormatError) { + return QSettings::NoError; + } else { + return s; + } +} + +QSettings::Status Settings::iniStatus() const +{ + return m_Settings.status(); +} + +void Settings::dump() const +{ + static const QStringList ignore({"username", "password", "nexus_api_key", + "nexus_username", "nexus_password", + "steam_username"}); + + log::debug("settings:"); + + { + ScopedGroup sg(m_Settings, "Settings"); + + for (auto k : m_Settings.allKeys()) { + if (ignore.contains(k, Qt::CaseInsensitive)) { + continue; + } + + log::debug(" . {}={}", k, m_Settings.value(k).toString()); + } + } + + m_Network.dump(); + NexusSettings::dump(); +} + +void Settings::managedGameChanged(IPluginGame const* gamePlugin) +{ + m_Game.setPlugin(gamePlugin); +} + +GameSettings::GameSettings(QSettings& settings) + : m_Settings(settings) +{} + +const MOBase::IPluginGame* GameSettings::plugin() +{ + return m_GamePlugin; +} + +void GameSettings::setPlugin(const MOBase::IPluginGame* gamePlugin) +{ + m_GamePlugin = gamePlugin; +} + +bool GameSettings::forceEnableCoreFiles() const +{ + return get(m_Settings, "Settings", "force_enable_core_files", true); +} + +void GameSettings::setForceEnableCoreFiles(bool b) +{ + set(m_Settings, "Settings", "force_enable_core_files", b); +} + +std::optional GameSettings::directory() const +{ + if (auto v = getOptional(m_Settings, "General", "gamePath")) { + return loadStoredPath(QString::fromUtf8(*v)); + } + + return {}; +} + +void GameSettings::setDirectory(const QString& path) +{ + set(m_Settings, "General", "gamePath", storePathForIni(path).toUtf8()); +} + +std::optional GameSettings::name() const +{ + return getOptional(m_Settings, "General", "gameName"); +} + +void GameSettings::setName(const QString& name) +{ + set(m_Settings, "General", "gameName", name); +} + +std::optional GameSettings::edition() const +{ + return getOptional(m_Settings, "General", "game_edition"); +} + +void GameSettings::setEdition(const QString& name) +{ + set(m_Settings, "General", "game_edition", name); +} + +std::optional GameSettings::selectedProfileName() const +{ + if (auto v = getOptional(m_Settings, "General", "selected_profile")) { + return QString::fromUtf8(*v); + } + + return {}; +} + +void GameSettings::setSelectedProfileName(const QString& name) +{ + set(m_Settings, "General", "selected_profile", name.toUtf8()); +} + +GeometrySettings::GeometrySettings(QSettings& s) : m_Settings(s) {} + +void GeometrySettings::requestReset() +{ + m_Reset = true; +} + +void GeometrySettings::resetIfNeeded() +{ + if (!m_Reset) { + return; + } + + removeSection(m_Settings, "Geometry"); +} + +void GeometrySettings::saveGeometry(const QMainWindow* w) +{ + saveWindowGeometry(w); +} + +bool GeometrySettings::restoreGeometry(QMainWindow* w) const +{ + return restoreWindowGeometry(w); +} + +void GeometrySettings::saveGeometry(const QDialog* d) +{ + saveWindowGeometry(d); +} + +bool GeometrySettings::restoreGeometry(QDialog* d) const +{ + const auto r = restoreWindowGeometry(d); + + if (centerDialogs()) { + centerOnParent(d); + } + + return r; +} + +void GeometrySettings::saveWindowGeometry(const QWidget* w) +{ + set(m_Settings, "Geometry", geoSettingName(w), w->saveGeometry()); +} + +bool GeometrySettings::restoreWindowGeometry(QWidget* w) const +{ + if (auto v = getOptional(m_Settings, "Geometry", geoSettingName(w))) { + w->restoreGeometry(*v); + ensureWindowOnScreen(w); + return true; + } + + return false; +} + +void GeometrySettings::ensureWindowOnScreen(QWidget* w) +{ + // users report that the main window and/or dialogs are displayed off-screen; + // the usual workaround is keyboard navigation to move it + // + // qt should have code that deals with multiple monitors and off-screen + // geometries, but there seems to be bugs or inconsistencies that can't be + // reproduced + // + // the closest would probably be https://bugreports.qt.io/browse/QTBUG-64498, + // which is about multiple monitors and high dpi, but it seems fixed as of + // 5.12.4, which is shipped with 2.2.1 + // + // without being to reproduce the problem, some simple checks are made in a + // timer, which may mitigate the issues + + QTimer::singleShot(100, w, [w] { + const auto borders = 20; + + // desktop geometry, made smaller to make sure there isn't just a few pixels + const auto originalDg = env::Environment().metrics().desktopGeometry(); + const auto dg = originalDg.adjusted(borders, borders, -borders, -borders); + + const auto g = w->geometry(); + + if (!dg.intersects(g)) { + log::warn("window '{}' is offscreen, moving to main monitor; geo={}, desktop={}", + w->objectName(), g, originalDg); + + // widget is off-screen, center it on main monitor + centerOnMonitor(w, -1); + + log::warn("window '{}' now at {}", w->objectName(), w->geometry()); + } + }); +} + +void GeometrySettings::saveState(const QMainWindow* w) +{ + set(m_Settings, "Geometry", stateSettingName(w), w->saveState()); +} + +bool GeometrySettings::restoreState(QMainWindow* w) const +{ + if (auto v = getOptional(m_Settings, "Geometry", stateSettingName(w))) { + w->restoreState(*v); + return true; + } + + return false; +} + +void GeometrySettings::saveState(const QHeaderView* w) +{ + set(m_Settings, "Geometry", stateSettingName(w), w->saveState()); +} + +bool GeometrySettings::restoreState(QHeaderView* w) const +{ + if (auto v = getOptional(m_Settings, "Geometry", stateSettingName(w))) { + w->restoreState(*v); + return true; + } + + return false; +} + +void GeometrySettings::saveState(const QSplitter* w) +{ + set(m_Settings, "Geometry", stateSettingName(w), w->saveState()); +} + +bool GeometrySettings::restoreState(QSplitter* w) const +{ + if (auto v = getOptional(m_Settings, "Geometry", stateSettingName(w))) { + w->restoreState(*v); + return true; + } + + return false; +} + +void GeometrySettings::saveState(const ExpanderWidget* expander) +{ + set(m_Settings, "Geometry", stateSettingName(expander), expander->saveState()); +} + +bool GeometrySettings::restoreState(ExpanderWidget* expander) const +{ + if (auto v = + getOptional(m_Settings, "Geometry", stateSettingName(expander))) { + expander->restoreState(*v); + return true; + } + + return false; +} + +void GeometrySettings::saveVisibility(const QWidget* w) +{ + set(m_Settings, "Geometry", visibilitySettingName(w), w->isVisible()); +} + +bool GeometrySettings::restoreVisibility(QWidget* w, std::optional def) const +{ + if (auto v = + getOptional(m_Settings, "Geometry", visibilitySettingName(w), def)) { + w->setVisible(*v); + return true; + } + + return false; +} + +void GeometrySettings::restoreToolbars(QMainWindow* w) const +{ + // all toolbars have the same size and button style settings + const auto size = getOptional(m_Settings, "Geometry", "toolbar_size"); + const auto style = getOptional(m_Settings, "Geometry", "toolbar_button_style"); + + for (auto* tb : w->findChildren()) { + if (size) { + tb->setIconSize(*size); + } + + if (style) { + tb->setToolButtonStyle(static_cast(*style)); + } + + restoreVisibility(tb); + } +} + +void GeometrySettings::saveToolbars(const QMainWindow* w) +{ + const auto tbs = w->findChildren(); + + // save visibility for all + for (auto* tb : tbs) { + saveVisibility(tb); + } + + // all toolbars have the same size and button style settings, just save the + // first one + if (!tbs.isEmpty()) { + const auto* tb = tbs[0]; + + set(m_Settings, "Geometry", "toolbar_size", tb->iconSize()); + set(m_Settings, "Geometry", "toolbar_button_style", + static_cast(tb->toolButtonStyle())); + } +} + +QStringList GeometrySettings::modInfoTabOrder() const +{ + QStringList v; + + if (m_Settings.contains("mod_info_tabs")) { + // old byte array from 2.2.0 + QDataStream stream(m_Settings.value("mod_info_tabs").toByteArray()); + + int count = 0; + stream >> count; + + for (int i = 0; i < count; ++i) { + QString s; + stream >> s; + v.push_back(s); + } + } else { + // string list since 2.2.1 + QString string = get(m_Settings, "Widgets", "ModInfoTabOrder", ""); + QTextStream stream(&string); + + while (!stream.atEnd()) { + QString s; + stream >> s; + v.push_back(s); + } + } + + return v; +} + +void GeometrySettings::setModInfoTabOrder(const QString& names) +{ + set(m_Settings, "Widgets", "ModInfoTabOrder", names); +} + +bool GeometrySettings::centerDialogs() const +{ + return get(m_Settings, "Settings", "center_dialogs", false); +} + +void GeometrySettings::setCenterDialogs(bool b) +{ + set(m_Settings, "Settings", "center_dialogs", b); +} + +void GeometrySettings::centerOnMainWindowMonitor(QWidget* w) const +{ + const auto monitor = + getOptional(m_Settings, "Geometry", "MainWindow_monitor").value_or(-1); + + centerOnMonitor(w, monitor); +} + +void GeometrySettings::centerOnMonitor(QWidget* w, int monitor) +{ + QPoint center; + + if (monitor >= 0 && monitor < QGuiApplication::screens().size()) { + center = QGuiApplication::screens().at(monitor)->geometry().center(); + } else { + center = QGuiApplication::primaryScreen()->geometry().center(); + } + + w->move(center - w->rect().center()); +} + +void GeometrySettings::centerOnParent(QWidget* w, QWidget* parent) +{ + if (!parent) { + parent = w->parentWidget(); + + if (!parent) { + parent = qApp->activeWindow(); + } + } + + if (parent && parent->isVisible()) { + const auto pr = parent->geometry(); + w->move(pr.center() - w->rect().center()); + } +} + +void GeometrySettings::saveMainWindowMonitor(const QMainWindow* w) +{ + if (auto* handle = w->windowHandle()) { + if (auto* screen = handle->screen()) { + const int screenId = QGuiApplication::screens().indexOf(screen); + set(m_Settings, "Geometry", "MainWindow_monitor", screenId); + } + } +} + +Qt::Orientation dockOrientation(const QMainWindow* mw, const QDockWidget* d) +{ + // docks in these areas are horizontal + const auto horizontalAreas = Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea; + + if (mw->dockWidgetArea(const_cast(d)) & horizontalAreas) { + return Qt::Horizontal; + } else { + return Qt::Vertical; + } +} + +void GeometrySettings::saveDocks(const QMainWindow* mw) +{ + // this attempts to fix https://bugreports.qt.io/browse/QTBUG-46620 where dock + // sizes are not restored when the main window is maximized; it is used in + // MainWindow::readSettings() and MainWindow::storeSettings() + // + // there's also https://stackoverflow.com/questions/44005852, which has what + // seems to be a popular fix, but it breaks the restored size of the window + // by setting it to the desktop's resolution, so that doesn't work + // + // the only fix I could find is to remember the sizes of the docks and manually + // setting them back; saving is straightforward, but restoring is messy + // + // this also depends on the window being visible before the timer in restore() + // is fired and the timer must be processed by application.exec(); therefore, + // the splash screen _must_ be closed before readSettings() is called, because + // it has its own event loop, which seems to interfere with this + // + // all of this should become unnecessary when QTBUG-46620 is fixed + // + + // saves the size of each dock + for (const auto* dock : mw->findChildren()) { + int size = 0; + + // save the width for horizontal docks, or the height for vertical + if (dockOrientation(mw, dock) == Qt::Horizontal) { + size = dock->size().width(); + } else { + size = dock->size().height(); + } + + set(m_Settings, "Geometry", dockSettingName(dock), size); + } +} + +void GeometrySettings::restoreDocks(QMainWindow* mw) const +{ + struct DockInfo + { + QDockWidget* d; + int size = 0; + Qt::Orientation ori; + }; + + std::vector dockInfos; + + // for each dock + for (auto* dock : mw->findChildren()) { + if (auto size = getOptional(m_Settings, "Geometry", dockSettingName(dock))) { + // remember this dock, its size and orientation + dockInfos.push_back({dock, *size, dockOrientation(mw, dock)}); + } + } + + // the main window must have had time to process the settings from + // readSettings() or it seems to override whatever is set here + // + // some people said a single processEvents() call is enough, but it doesn't + // look like it + QTimer::singleShot(5, [=] { + for (const auto& info : dockInfos) { + mw->resizeDocks({info.d}, {info.size}, info.ori); + } + }); +} + +WidgetSettings::WidgetSettings(QSettings& s, bool globalInstance) : m_Settings(s) +{ + if (globalInstance) { + MOBase::QuestionBoxMemory::setCallbacks( + [this](auto&& w, auto&& f) { + return questionButton(w, f); + }, + [this](auto&& w, auto&& b) { + setQuestionWindowButton(w, b); + }, + [this](auto&& w, auto&& f, auto&& b) { + setQuestionFileButton(w, f, b); + }); + } +} + +void WidgetSettings::saveTreeCheckState(const QTreeView* tv, int role) +{ + QVariantList data; + for (auto index : flatIndex(tv->model())) { + data.append(index.data(role)); + } + set(m_Settings, "Widgets", indexSettingName(tv), data); +} + +void WidgetSettings::restoreTreeCheckState(QTreeView* tv, int role) const +{ + if (auto states = + getOptional(m_Settings, "Widgets", indexSettingName(tv))) { + auto allIndex = flatIndex(tv->model()); + MOBase::log::debug("restoreTreeCheckState: {}, {}", states->size(), + allIndex.size()); + if (states->size() != allIndex.size()) { + return; + } + for (int i = 0; i < states->size(); ++i) { + tv->model()->setData(allIndex[i], states->at(i), role); + } + } +} + +void WidgetSettings::saveTreeExpandState(const QTreeView* tv, int role) +{ + QVariantList expanded; + for (auto index : flatIndex(tv->model())) { + if (tv->isExpanded(index)) { + expanded.append(index.data(role)); + } + } + set(m_Settings, "Widgets", indexSettingName(tv), expanded); +} + +void WidgetSettings::restoreTreeExpandState(QTreeView* tv, int role) const +{ + if (auto expanded = + getOptional(m_Settings, "Widgets", indexSettingName(tv))) { + tv->collapseAll(); + for (auto index : flatIndex(tv->model())) { + if (expanded->contains(index.data(role))) { + tv->expand(index); + } + } + } +} + +std::optional WidgetSettings::index(const QComboBox* cb) const +{ + return getOptional(m_Settings, "Widgets", indexSettingName(cb)); +} + +void WidgetSettings::saveIndex(const QComboBox* cb) +{ + set(m_Settings, "Widgets", indexSettingName(cb), cb->currentIndex()); +} + +void WidgetSettings::restoreIndex(QComboBox* cb, std::optional def) const +{ + if (auto v = getOptional(m_Settings, "Widgets", indexSettingName(cb), def)) { + cb->setCurrentIndex(*v); + } +} + +std::optional WidgetSettings::index(const QTabWidget* w) const +{ + return getOptional(m_Settings, "Widgets", indexSettingName(w)); +} + +void WidgetSettings::saveIndex(const QTabWidget* w) +{ + set(m_Settings, "Widgets", indexSettingName(w), w->currentIndex()); +} + +void WidgetSettings::restoreIndex(QTabWidget* w, std::optional def) const +{ + if (auto v = getOptional(m_Settings, "Widgets", indexSettingName(w), def)) { + w->setCurrentIndex(*v); + } +} + +std::optional WidgetSettings::checked(const QAbstractButton* w) const +{ + warnIfNotCheckable(w); + return getOptional(m_Settings, "Widgets", checkedSettingName(w)); +} + +void WidgetSettings::saveChecked(const QAbstractButton* w) +{ + warnIfNotCheckable(w); + set(m_Settings, "Widgets", checkedSettingName(w), w->isChecked()); +} + +void WidgetSettings::restoreChecked(QAbstractButton* w, std::optional def) const +{ + warnIfNotCheckable(w); + + if (auto v = getOptional(m_Settings, "Widgets", checkedSettingName(w), def)) { + w->setChecked(*v); + } +} + +QuestionBoxMemory::Button WidgetSettings::questionButton(const QString& windowName, + const QString& filename) const +{ + const QString sectionName("DialogChoices"); + + if (!filename.isEmpty()) { + const auto fileSetting = windowName + "/" + filename; + if (auto v = getOptional(m_Settings, sectionName, fileSetting)) { + return static_cast(*v); + } + } + + if (auto v = getOptional(m_Settings, sectionName, windowName)) { + return static_cast(*v); + } + + return QuestionBoxMemory::NoButton; +} + +void WidgetSettings::setQuestionWindowButton(const QString& windowName, + QuestionBoxMemory::Button button) +{ + const QString sectionName("DialogChoices"); + + if (button == QuestionBoxMemory::NoButton) { + remove(m_Settings, sectionName, windowName); + } else { + set(m_Settings, sectionName, windowName, button); + } +} + +void WidgetSettings::setQuestionFileButton(const QString& windowName, + const QString& filename, + QuestionBoxMemory::Button button) +{ + const QString sectionName("DialogChoices"); + const QString settingName(windowName + "/" + filename); + + if (button == QuestionBoxMemory::NoButton) { + remove(m_Settings, sectionName, settingName); + } else { + set(m_Settings, sectionName, settingName, button); + } +} + +void WidgetSettings::resetQuestionButtons() +{ + removeSection(m_Settings, "DialogChoices"); +} + +ColorSettings::ColorSettings(QSettings& s) : m_Settings(s) {} + +QColor ColorSettings::modlistOverwrittenLoose() const +{ + return get(m_Settings, "Settings", "overwrittenLooseFilesColor", + QColor(0, 255, 0, 64)); +} + +void ColorSettings::setModlistOverwrittenLoose(const QColor& c) +{ + set(m_Settings, "Settings", "overwrittenLooseFilesColor", c); +} + +QColor ColorSettings::modlistOverwritingLoose() const +{ + return get(m_Settings, "Settings", "overwritingLooseFilesColor", + QColor(255, 0, 0, 64)); +} + +void ColorSettings::setModlistOverwritingLoose(const QColor& c) +{ + set(m_Settings, "Settings", "overwritingLooseFilesColor", c); +} + +QColor ColorSettings::modlistOverwrittenArchive() const +{ + return get(m_Settings, "Settings", "overwrittenArchiveFilesColor", + QColor(0, 255, 255, 64)); +} + +void ColorSettings::setModlistOverwrittenArchive(const QColor& c) +{ + set(m_Settings, "Settings", "overwrittenArchiveFilesColor", c); +} + +QColor ColorSettings::modlistOverwritingArchive() const +{ + return get(m_Settings, "Settings", "overwritingArchiveFilesColor", + QColor(255, 0, 255, 64)); +} + +void ColorSettings::setModlistOverwritingArchive(const QColor& c) +{ + set(m_Settings, "Settings", "overwritingArchiveFilesColor", c); +} + +QColor ColorSettings::modlistContainsFile() const +{ + return get(m_Settings, "Settings", "containsFileColor", + QColor(0, 0, 255, 64)); +} + +void ColorSettings::setModlistContainsFile(const QColor& c) +{ + set(m_Settings, "Settings", "containsFileColor", c); +} + +QColor ColorSettings::pluginListContained() const +{ + return get(m_Settings, "Settings", "containedColor", QColor(0, 0, 255, 64)); +} + +void ColorSettings::setPluginListContained(const QColor& c) +{ + set(m_Settings, "Settings", "containedColor", c); +} + +QColor ColorSettings::pluginListMaster() const +{ + return get(m_Settings, "Settings", "masterColor", QColor(255, 255, 0, 64)); +} + +void ColorSettings::setPluginListMaster(const QColor& c) +{ + set(m_Settings, "Settings", "masterColor", c); +} + +std::optional ColorSettings::previousSeparatorColor() const +{ + const auto c = getOptional(m_Settings, "General", "previousSeparatorColor"); + if (c && c->isValid()) { + return c; + } + + return {}; +} + +void ColorSettings::setPreviousSeparatorColor(const QColor& c) const +{ + set(m_Settings, "General", "previousSeparatorColor", c); +} + +void ColorSettings::removePreviousSeparatorColor() +{ + remove(m_Settings, "General", "previousSeparatorColor"); +} + +bool ColorSettings::colorSeparatorScrollbar() const +{ + return get(m_Settings, "Settings", "colorSeparatorScrollbars", true); +} + +void ColorSettings::setColorSeparatorScrollbar(bool b) +{ + set(m_Settings, "Settings", "colorSeparatorScrollbars", b); +} + +QColor ColorSettings::idealTextColor(const QColor& rBackgroundColor) +{ + if (rBackgroundColor.alpha() < 50) + return {Qt::black}; + + // "inverse' of luminance of the background + int iLuminance = (rBackgroundColor.red() * 0.299) + + (rBackgroundColor.green() * 0.587) + + (rBackgroundColor.blue() * 0.114); + return {iLuminance >= 128 ? Qt::black : Qt::white}; +} + +PluginSettings::PluginSettings(QSettings& settings) : m_Settings(settings) {} + +void PluginSettings::clearPlugins() +{ + m_Plugins.clear(); + m_PluginSettings.clear(); + m_PluginBlacklist.clear(); + + m_PluginBlacklist = readBlacklist(); +} + +void PluginSettings::registerPlugin(IPlugin* plugin) +{ + m_Plugins.push_back(plugin); + m_PluginSettings.insert(plugin->name(), QVariantMap()); + m_PluginDescriptions.insert(plugin->name(), QVariantMap()); + + for (const PluginSetting& setting : plugin->settings()) { + const QString settingName = plugin->name() + "/" + setting.key; + + QVariant temp = get(m_Settings, "Plugins", settingName, QVariant()); + + // No previous enabled? Skip. + if (setting.key == "enabled" && (!temp.isValid() || !temp.canConvert())) { + continue; + } + + if (!temp.isValid()) { + temp = setting.defaultValue; + } else if (!temp.convert(setting.defaultValue.metaType())) { + log::warn("failed to interpret \"{}\" as correct type for \"{}\" in plugin " + "\"{}\", using default", + temp.toString(), setting.key, plugin->name()); + + temp = setting.defaultValue; + } + + m_PluginSettings[plugin->name()][setting.key] = temp; + + m_PluginDescriptions[plugin->name()][setting.key] = + QString("%1 (default: %2)") + .arg(setting.description) + .arg(setting.defaultValue.toString()); + } + + // Handle previous "enabled" settings: + if (m_PluginSettings[plugin->name()].contains("enabled")) { + setPersistent(plugin->name(), "enabled", + m_PluginSettings[plugin->name()]["enabled"].toBool(), true); + m_PluginSettings[plugin->name()].remove("enabled"); + m_PluginDescriptions[plugin->name()].remove("enabled"); + + // We need to drop it manually in Settings since it is not possible to remove plugin + // settings: + remove(m_Settings, "Plugins", plugin->name() + "/enabled"); + } +} + +void PluginSettings::unregisterPlugin(IPlugin* plugin) +{ + auto it = std::find(m_Plugins.begin(), m_Plugins.end(), plugin); + if (it != m_Plugins.end()) { + m_Plugins.erase(it); + } + m_PluginSettings.remove(plugin->name()); + m_PluginDescriptions.remove(plugin->name()); +} + +std::vector PluginSettings::plugins() const +{ + return m_Plugins; +} + +QVariant PluginSettings::setting(const QString& pluginName, const QString& key) const +{ + auto iterPlugin = m_PluginSettings.find(pluginName); + if (iterPlugin == m_PluginSettings.end()) { + return {}; + } + + auto iterSetting = iterPlugin->find(key); + if (iterSetting == iterPlugin->end()) { + return {}; + } + + return *iterSetting; +} + +void PluginSettings::setSetting(const QString& pluginName, const QString& key, + const QVariant& value) +{ + auto iterPlugin = m_PluginSettings.find(pluginName); + + if (iterPlugin == m_PluginSettings.end()) { + throw MyException(QObject::tr("attempt to store setting for unknown plugin \"%1\"") + .arg(pluginName)); + } + + QVariant oldValue = m_PluginSettings[pluginName][key]; + + // store the new setting both in memory and in the ini + m_PluginSettings[pluginName][key] = value; + set(m_Settings, "Plugins", pluginName + "/" + key, value); + + // emit signal: + emit pluginSettingChanged(pluginName, key, oldValue, value); +} + +QVariantMap PluginSettings::settings(const QString& pluginName) const +{ + return m_PluginSettings[pluginName]; +} + +void PluginSettings::setSettings(const QString& pluginName, const QVariantMap& map) +{ + auto iterPlugin = m_PluginSettings.find(pluginName); + + if (iterPlugin == m_PluginSettings.end()) { + throw MyException(QObject::tr("attempt to store setting for unknown plugin \"%1\"") + .arg(pluginName)); + } + + QVariantMap oldSettings = m_PluginSettings[pluginName]; + m_PluginSettings[pluginName] = map; + + // Emit signals for settings that have been changed or added: + for (auto& k : map.keys()) { + // .value() return a default-constructed QVariant if k is not in oldSettings: + QVariant oldValue = oldSettings.value(k); + if (oldValue != map[k]) { + emit pluginSettingChanged(pluginName, k, oldSettings.value(k), map[k]); + } + } + + // Emit signals for settings that have been removed: + for (auto& k : oldSettings.keys()) { + if (!map.contains(k)) { + emit pluginSettingChanged(pluginName, k, oldSettings[k], QVariant()); + } + } +} + +QVariantMap PluginSettings::descriptions(const QString& pluginName) const +{ + return m_PluginDescriptions[pluginName]; +} + +void PluginSettings::setDescriptions(const QString& pluginName, const QVariantMap& map) +{ + m_PluginDescriptions[pluginName] = map; +} + +QVariant PluginSettings::persistent(const QString& pluginName, const QString& key, + const QVariant& def) const +{ + if (!m_PluginSettings.contains(pluginName)) { + return def; + } + + return get(m_Settings, "PluginPersistance", pluginName + "/" + key, def); +} + +void PluginSettings::setPersistent(const QString& pluginName, const QString& key, + const QVariant& value, bool sync) +{ + if (!m_PluginSettings.contains(pluginName)) { + throw MyException(QObject::tr("attempt to store setting for unknown plugin \"%1\"") + .arg(pluginName)); + } + + set(m_Settings, "PluginPersistance", pluginName + "/" + key, value); + + if (sync) { + m_Settings.sync(); + } +} + +void PluginSettings::addBlacklist(const QString& fileName) +{ + m_PluginBlacklist.insert(fileName); + writeBlacklist(); +} + +bool PluginSettings::blacklisted(const QString& fileName) const +{ + return m_PluginBlacklist.contains(fileName); +} + +void PluginSettings::setBlacklist(const QStringList& pluginNames) +{ + m_PluginBlacklist.clear(); + + for (const auto& name : pluginNames) { + m_PluginBlacklist.insert(name); + } +} + +const QSet& PluginSettings::blacklist() const +{ + return m_PluginBlacklist; +} + +void PluginSettings::save() +{ + for (auto iterPlugins = m_PluginSettings.begin(); + iterPlugins != m_PluginSettings.end(); ++iterPlugins) { + for (auto iterSettings = iterPlugins->begin(); iterSettings != iterPlugins->end(); + ++iterSettings) { + const auto key = iterPlugins.key() + "/" + iterSettings.key(); + set(m_Settings, "Plugins", key, iterSettings.value()); + } + } + + writeBlacklist(); +} + +void PluginSettings::writeBlacklist() +{ + const auto current = readBlacklist(); + + if (current.size() > m_PluginBlacklist.size()) { + // Qt can't remove array elements, the section must be cleared + removeSection(m_Settings, "pluginBlacklist"); + } + + ScopedWriteArray swa(m_Settings, "pluginBlacklist", m_PluginBlacklist.size()); + + for (const QString& plugin : m_PluginBlacklist) { + swa.next(); + swa.set("name", plugin); + } +} + +QSet PluginSettings::readBlacklist() const +{ + QSet set; + + ScopedReadArray sra(m_Settings, "pluginBlacklist"); + sra.for_each([&] { + set.insert(sra.get("name")); + }); + + return set; +} + +const QString PathSettings::BaseDirVariable = "%BASE_DIR%"; + +PathSettings::PathSettings(QSettings& settings) : m_Settings(settings) {} + +std::map PathSettings::recent() const +{ + std::map map; + + ScopedReadArray sra(m_Settings, "recentDirectories"); + + sra.for_each([&] { + const QVariant name = sra.get("name"); + const QVariant dir = sra.get("directory"); + + if (name.isValid() && dir.isValid()) { + map.emplace(name.toString(), loadStoredPath(dir.toString())); + } + }); + + return map; +} + +void PathSettings::setRecent(const std::map& map) +{ + const auto current = recent(); + + if (current.size() > map.size()) { + // Qt can't remove array elements, the section must be cleared + removeSection(m_Settings, "recentDirectories"); + } + + ScopedWriteArray swa(m_Settings, "recentDirectories", map.size()); + + for (auto&& p : map) { + swa.next(); + + swa.set("name", p.first); + swa.set("directory", storePathForIni(p.second)); + } +} + +QString PathSettings::getConfigurablePath(const QString& key, const QString& def, + bool resolve) const +{ + QString result = + loadStoredPath(get(m_Settings, "Settings", key, makeDefaultPath(def))); + + if (resolve) { + result = PathSettings::resolve(result, base()); + } + + return result; +} + +void PathSettings::setConfigurablePath(const QString& key, const QString& path) +{ + if (path.isEmpty()) { + remove(m_Settings, "Settings", key); + } else { + set(m_Settings, "Settings", key, storePathForIni(path)); + } +} + +QString PathSettings::resolve(const QString& path, const QString& baseDir) +{ + QString s = path; + s.replace(BaseDirVariable, baseDir); + return s; +} + +QString PathSettings::makeDefaultPath(const QString dirName) +{ + return BaseDirVariable + "/" + dirName; +} + +QString PathSettings::base() const +{ + const QString dataPath = QFileInfo(m_Settings.fileName()).dir().path(); + + return loadStoredPath(get(m_Settings, "Settings", "base_directory", + dataPath)); +} + +QString PathSettings::downloads(bool resolve) const +{ + return getConfigurablePath("download_directory", ToQString(AppConfig::downloadPath()), + resolve); +} + +QString PathSettings::cache(bool resolve) const +{ + return getConfigurablePath("cache_directory", ToQString(AppConfig::cachePath()), + resolve); +} + +QString PathSettings::mods(bool resolve) const +{ + return getConfigurablePath("mod_directory", ToQString(AppConfig::modsPath()), + resolve); +} + +QString PathSettings::profiles(bool resolve) const +{ + return getConfigurablePath("profiles_directory", ToQString(AppConfig::profilesPath()), + resolve); +} + +QString PathSettings::overwrite(bool resolve) const +{ + return getConfigurablePath("overwrite_directory", + ToQString(AppConfig::overwritePath()), resolve); +} + +void PathSettings::setBase(const QString& path) +{ + if (path.isEmpty()) { + remove(m_Settings, "Settings", "base_directory"); + } else { + set(m_Settings, "Settings", "base_directory", storePathForIni(path)); + } +} + +void PathSettings::setDownloads(const QString& path) +{ + setConfigurablePath("download_directory", path); +} + +void PathSettings::setMods(const QString& path) +{ + setConfigurablePath("mod_directory", path); +} + +void PathSettings::setCache(const QString& path) +{ + setConfigurablePath("cache_directory", path); +} + +void PathSettings::setProfiles(const QString& path) +{ + setConfigurablePath("profiles_directory", path); +} + +void PathSettings::setOverwrite(const QString& path) +{ + setConfigurablePath("overwrite_directory", path); +} + +NetworkSettings::NetworkSettings(QSettings& settings, bool globalInstance) + : m_Settings(settings) +{ + if (globalInstance) { + updateCustomBrowser(); + } +} + +void NetworkSettings::updateCustomBrowser() const +{ + if (useCustomBrowser()) { + MOBase::shell::SetUrlHandler(customBrowserCommand()); + } else { + MOBase::shell::SetUrlHandler(""); + } +} + +bool NetworkSettings::offlineMode() const +{ + return get(m_Settings, "Settings", "offline_mode", false); +} + +void NetworkSettings::setOfflineMode(bool b) +{ + set(m_Settings, "Settings", "offline_mode", b); +} + +bool NetworkSettings::useProxy() const +{ + return get(m_Settings, "Settings", "use_proxy", false); +} + +void NetworkSettings::setUseProxy(bool b) +{ + set(m_Settings, "Settings", "use_proxy", b); +} + +void NetworkSettings::setDownloadSpeed(const QString& name, int bytesPerSecond) +{ + auto current = servers(); + + for (auto& server : current) { + if (server.name() == name) { + server.addDownload(bytesPerSecond); + updateServers(current); + return; + } + } + + log::error("server '{}' not found while trying to add a download with bps {}", name, + bytesPerSecond); +} + +ServerList NetworkSettings::servers() const +{ + ServerList list; + + { + ScopedReadArray sra(m_Settings, "Servers"); + + sra.for_each([&] { + ServerInfo::SpeedList lastDownloads; + + const auto lastDownloadsString = sra.get("lastDownloads", ""); + + for (const auto& s : lastDownloadsString.split(" ")) { + const auto bytesPerSecond = s.toInt(); + if (bytesPerSecond > 0) { + lastDownloads.push_back(bytesPerSecond); + } + } + + ServerInfo server( + sra.get("name", ""), sra.get("premium", false), + QDate::fromString(sra.get("lastSeen", ""), Qt::ISODate), + sra.get("preferred", 0), lastDownloads); + + list.add(std::move(server)); + }); + } + + return list; +} + +void NetworkSettings::updateServers(ServerList newServers) +{ + // clean up unavailable servers + newServers.cleanup(); + + const auto current = servers(); + + if (current.size() > newServers.size()) { + // Qt can't remove array elements, the section must be cleared + removeSection(m_Settings, "Servers"); + } + + ScopedWriteArray swa(m_Settings, "Servers", newServers.size()); + + for (const auto& server : newServers) { + swa.next(); + + swa.set("name", server.name()); + swa.set("premium", server.isPremium()); + swa.set("lastSeen", server.lastSeen().toString(Qt::ISODate)); + swa.set("preferred", server.preferred()); + + QString lastDownloads; + for (const auto& speed : server.lastDownloads()) { + if (speed > 0) { + lastDownloads += QString("%1 ").arg(speed); + } + } + + swa.set("lastDownloads", lastDownloads.trimmed()); + } +} + +void NetworkSettings::updateFromOldMap() +{ + // servers used to be a map of byte arrays until 2.2.1, it's now an array of + // individual values instead + // + // so post 2.2.1, only one key is returned: "size", the size of the arrays; + // in 2.2.1, one key per server is returned + + // sanity check that this is really 2.2.1 + { + const QStringList keys = ScopedGroup(m_Settings, "Servers").keys(); + + for (auto&& k : keys) { + if (k == "size") { + // this looks like an array, so the upgrade was probably already done + return; + } + } + } + + const auto servers = serversFromOldMap(); + removeSection(m_Settings, "Servers"); + updateServers(servers); +} + +bool NetworkSettings::useCustomBrowser() const +{ + return get(m_Settings, "Settings", "use_custom_browser", false); +} + +void NetworkSettings::setUseCustomBrowser(bool b) +{ + set(m_Settings, "Settings", "use_custom_browser", b); + updateCustomBrowser(); +} + +QString NetworkSettings::customBrowserCommand() const +{ + return get(m_Settings, "Settings", "custom_browser", ""); +} + +void NetworkSettings::setCustomBrowserCommand(const QString& s) +{ + set(m_Settings, "Settings", "custom_browser", s); + updateCustomBrowser(); +} + +ServerList NetworkSettings::serversFromOldMap() const +{ + // for 2.2.1 and before + + ServerList list; + const ScopedGroup sg(m_Settings, "Servers"); + + sg.for_each([&](auto&& serverKey) { + QVariantMap data = sg.get(serverKey); + + ServerInfo server(serverKey, data["premium"].toBool(), data["lastSeen"].toDate(), + data["preferred"].toInt(), {}); + + // ignoring download count and speed, it's now a list of values instead of + // a total + + list.add(std::move(server)); + }); + + return list; +} + +void NetworkSettings::dump() const +{ + log::debug("servers:"); + + for (const auto& server : servers()) { + QString lastDownloads; + for (auto speed : server.lastDownloads()) { + lastDownloads += QString("%1 ").arg(speed); + } + + log::debug(" . {} premium={} lastSeen={} preferred={} lastDownloads={}", + server.name(), server.isPremium() ? "yes" : "no", + server.lastSeen().toString(Qt::ISODate), server.preferred(), + lastDownloads.trimmed()); + } +} + +NexusSettings::NexusSettings(Settings& parent, QSettings& settings) + : m_Parent(parent), m_Settings(settings) +{} + +bool NexusSettings::endorsementIntegration() const +{ + return get(m_Settings, "Settings", "endorsement_integration", true); +} + +void NexusSettings::setEndorsementIntegration(bool b) const +{ + set(m_Settings, "Settings", "endorsement_integration", b); +} + +EndorsementState NexusSettings::endorsementState() const +{ + return endorsementStateFromString( + get(m_Settings, "General", "endorse_state", "")); +} + +void NexusSettings::setEndorsementState(EndorsementState s) +{ + const auto v = toString(s); + + if (v.isEmpty()) { + remove(m_Settings, "General", "endorse_state"); + } else { + set(m_Settings, "General", "endorse_state", v); + } +} + +bool NexusSettings::trackedIntegration() const +{ + return get(m_Settings, "Settings", "tracked_integration", true); +} + +void NexusSettings::setTrackedIntegration(bool b) const +{ + set(m_Settings, "Settings", "tracked_integration", b); +} + +bool NexusSettings::categoryMappings() const +{ + return get(m_Settings, "Settings", "category_mappings", true); +} + +void NexusSettings::setCategoryMappings(bool b) const +{ + set(m_Settings, "Settings", "category_mappings", b); +} + +void NexusSettings::registerAsNXMHandler(bool force) +{ + Q_UNUSED(force); + NxmHandlerLinux handler; + NxmHandlerLinux::registerHandler(); +} + +std::vector NexusSettings::validationTimeouts() const +{ + using namespace std::chrono_literals; + + const auto s = get(m_Settings, "Settings", "validation_timeouts", ""); + + const auto numbers = s.split(" "); + std::vector v; + + for (auto ns : numbers) { + ns = ns.trimmed(); + if (ns.isEmpty()) + continue; + + bool ok = false; + const auto n = ns.toInt(&ok); + + if (!ok || n < 0 || n > 100) { + log::error("bad validation_timeouts number '{}'", ns); + continue; + } + + v.emplace_back(n); + } + + if (v.empty()) + v = {10s, 15s, 20s}; + + return v; +} + +void NexusSettings::dump() +{ + const auto iniPath = InstanceManager::singleton().globalInstancesRootPath() + "/" + + QString::fromStdWString(AppConfig::nxmHandlerIni()); + + if (!QFileInfo(iniPath).exists()) { + log::debug("nxm ini not found at {}", iniPath); + return; + } + + QSettings s(iniPath, QSettings::IniFormat); + if (const auto st = s.status(); st != QSettings::NoError) { + log::debug("can't read nxm ini from {}", iniPath); + return; + } + + log::debug("nxmhandler settings:"); + + QSettings handler(R"(HKEY_CURRENT_USER\Software\Classes\nxm\)", + QSettings::NativeFormat); + log::debug(" . primary: {}", handler.value("shell/open/command/Default").toString()); + + const auto noregister = getOptional(s, "General", "noregister"); + + if (noregister) { + log::debug(" . noregister: {}", *noregister); + } else { + log::debug(" . noregister: (not found)"); + } + + ScopedReadArray sra(s, "handlers"); + + sra.for_each([&] { + const auto games = sra.get("games"); + const auto executable = sra.get("executable"); + const auto arguments = sra.get("arguments"); + + log::debug(" . handler:"); + log::debug(" . games: {}", games.toString()); + log::debug(" . executable: {}", executable.toString()); + log::debug(" . arguments: {}", arguments.toString()); + }); +} + +SteamSettings::SteamSettings(Settings& parent, QSettings& settings) + : m_Parent(parent), m_Settings(settings) +{} + +QString SteamSettings::appID() const +{ + return get(m_Settings, "Settings", "app_id", + m_Parent.game().plugin()->steamAPPId()); +} + +void SteamSettings::setAppID(const QString& id) +{ + if (id.isEmpty()) { + remove(m_Settings, "Settings", "app_id"); + } else { + set(m_Settings, "Settings", "app_id", id); + } +} + +bool SteamSettings::login(QString& username, QString& password) const +{ + username = get(m_Settings, "Settings", "steam_username", ""); + password = getWindowsCredential("steam_password"); + + return !username.isEmpty() && !password.isEmpty(); +} + +void SteamSettings::setLogin(QString username, QString password) +{ + if (username == "") { + remove(m_Settings, "Settings", "steam_username"); + password = ""; + } else { + set(m_Settings, "Settings", "steam_username", username); + } + + if (!setWindowsCredential("steam_password", password)) { + const auto e = GetLastError(); + log::error("Storing or deleting password failed: {}", formatSystemMessage(e)); + } +} + +InterfaceSettings::InterfaceSettings(QSettings& settings) : m_Settings(settings) {} + +bool InterfaceSettings::lockGUI() const +{ + return get(m_Settings, "Settings", "lock_gui", true); +} + +void InterfaceSettings::setLockGUI(bool b) +{ + set(m_Settings, "Settings", "lock_gui", b); +} + +std::optional InterfaceSettings::styleName() const +{ + return getOptional(m_Settings, "Settings", "style"); +} + +void InterfaceSettings::setStyleName(const QString& name) +{ + set(m_Settings, "Settings", "style", name); +} + +bool InterfaceSettings::collapsibleSeparators(Qt::SortOrder order) const +{ + return get(m_Settings, "Settings", + order == Qt::AscendingOrder ? "collapsible_separators_asc" + : "collapsible_separators_dsc", + true); +} + +void InterfaceSettings::setCollapsibleSeparators(bool ascending, bool descending) +{ + set(m_Settings, "Settings", "collapsible_separators_asc", ascending); + set(m_Settings, "Settings", "collapsible_separators_dsc", descending); +} + +bool InterfaceSettings::collapsibleSeparatorsHighlightTo() const +{ + return get(m_Settings, "Settings", "collapsible_separators_conflicts_to", true); +} + +void InterfaceSettings::setCollapsibleSeparatorsHighlightTo(bool b) +{ + set(m_Settings, "Settings", "collapsible_separators_conflicts_to", b); +} + +bool InterfaceSettings::collapsibleSeparatorsHighlightFrom() const +{ + return get(m_Settings, "Settings", "collapsible_separators_conflicts_from", + true); +} + +void InterfaceSettings::setCollapsibleSeparatorsHighlightFrom(bool b) +{ + set(m_Settings, "Settings", "collapsible_separators_conflicts_from", b); +} + +bool InterfaceSettings::collapsibleSeparatorsIcons(int column) const +{ + return get(m_Settings, "Settings", + QString("collapsible_separators_icons_%1").arg(column), true); +} + +void InterfaceSettings::setCollapsibleSeparatorsIcons(int column, bool show) +{ + set(m_Settings, "Settings", QString("collapsible_separators_icons_%1").arg(column), + show); +} + +bool InterfaceSettings::collapsibleSeparatorsPerProfile() const +{ + return get(m_Settings, "Settings", "collapsible_separators_per_profile", false); +} + +void InterfaceSettings::setCollapsibleSeparatorsPerProfile(bool b) +{ + set(m_Settings, "Settings", "collapsible_separators_per_profile", b); +} + +bool InterfaceSettings::saveFilters() const +{ + return get(m_Settings, "Settings", "save_filters", false); +} + +void InterfaceSettings::setSaveFilters(bool b) +{ + set(m_Settings, "Settings", "save_filters", b); +} + +bool InterfaceSettings::autoCollapseOnHover() const +{ + return get(m_Settings, "Settings", "auto_collapse_on_hover", false); +} + +void InterfaceSettings::setAutoCollapseOnHover(bool b) +{ + set(m_Settings, "Settings", "auto_collapse_on_hover", b); +} + +bool InterfaceSettings::checkUpdateAfterInstallation() const +{ + return get(m_Settings, "Settings", "autocheck_update_install", true); +} + +void InterfaceSettings::setCheckUpdateAfterInstallation(bool b) +{ + set(m_Settings, "Settings", "autocheck_update_install", b); +} + +bool InterfaceSettings::compactDownloads() const +{ + return get(m_Settings, "Settings", "compact_downloads", false); +} + +void InterfaceSettings::setCompactDownloads(bool b) +{ + set(m_Settings, "Settings", "compact_downloads", b); +} + +bool InterfaceSettings::metaDownloads() const +{ + return get(m_Settings, "Settings", "meta_downloads", false); +} + +void InterfaceSettings::setMetaDownloads(bool b) +{ + set(m_Settings, "Settings", "meta_downloads", b); +} + +bool InterfaceSettings::hideDownloadsAfterInstallation() const +{ + return get(m_Settings, "Settings", "autohide_downloads", false); +} + +void InterfaceSettings::setHideDownloadsAfterInstallation(bool b) +{ + set(m_Settings, "Settings", "autohide_downloads", b); +} + +bool InterfaceSettings::hideAPICounter() const +{ + return get(m_Settings, "Settings", "hide_api_counter", false); +} + +void InterfaceSettings::setHideAPICounter(bool b) +{ + set(m_Settings, "Settings", "hide_api_counter", b); +} + +bool InterfaceSettings::displayForeign() const +{ + return get(m_Settings, "Settings", "display_foreign", true); +} + +void InterfaceSettings::setDisplayForeign(bool b) +{ + set(m_Settings, "Settings", "display_foreign", b); +} + +QString InterfaceSettings::language() +{ + QString result = get(m_Settings, "Settings", "language", ""); + + if (result.isEmpty()) { + QStringList languagePreferences = QLocale::system().uiLanguages(); + + if (!languagePreferences.empty()) { + // the users most favoritest language + result = languagePreferences.at(0); + } else { + // fallback system locale + result = QLocale::system().name(); + } + } + + return result; +} + +void InterfaceSettings::setLanguage(const QString& name) +{ + set(m_Settings, "Settings", "language", name); +} + +bool InterfaceSettings::isTutorialCompleted(const QString& windowName) const +{ + return get(m_Settings, "CompletedWindowTutorials", windowName, false); +} + +void InterfaceSettings::setTutorialCompleted(const QString& windowName, bool b) +{ + set(m_Settings, "CompletedWindowTutorials", windowName, b); +} + +bool InterfaceSettings::showChangeGameConfirmation() const +{ + return get(m_Settings, "Settings", "show_change_game_confirmation", true); +} + +void InterfaceSettings::setShowChangeGameConfirmation(bool b) +{ + set(m_Settings, "Settings", "show_change_game_confirmation", b); +} + +bool InterfaceSettings::showMenubarOnAlt() const +{ + return get(m_Settings, "Settings", "show_menubar_on_alt", true); +} + +void InterfaceSettings::setShowMenubarOnAlt(bool b) +{ + set(m_Settings, "Settings", "show_menubar_on_alt", b); +} + +bool InterfaceSettings::doubleClicksOpenPreviews() const +{ + return get(m_Settings, "Settings", "double_click_previews", true); +} + +void InterfaceSettings::setDoubleClicksOpenPreviews(bool b) +{ + set(m_Settings, "Settings", "double_click_previews", b); +} + +FilterWidget::Options InterfaceSettings::filterOptions() const +{ + FilterWidget::Options o; + + o.useRegex = get(m_Settings, "Settings", "filter_regex", false); + o.regexCaseSensitive = + get(m_Settings, "Settings", "regex_case_sensitive", false); + o.regexExtended = get(m_Settings, "Settings", "regex_extended", false); + o.scrollToSelection = + get(m_Settings, "Settings", "filter_scroll_to_selection", false); + + return o; +} + +void InterfaceSettings::setFilterOptions(const FilterWidget::Options& o) +{ + set(m_Settings, "Settings", "filter_regex", o.useRegex); + set(m_Settings, "Settings", "regex_case_sensitive", o.regexCaseSensitive); + set(m_Settings, "Settings", "regex_extended", o.regexExtended); + set(m_Settings, "Settings", "filter_scroll_to_selection", o.scrollToSelection); +} + +DiagnosticsSettings::DiagnosticsSettings(QSettings& settings) : m_Settings(settings) {} + +log::Levels DiagnosticsSettings::logLevel() const +{ + return get(m_Settings, "Settings", "log_level", log::Levels::Info); +} + +void DiagnosticsSettings::setLogLevel(log::Levels level) +{ + set(m_Settings, "Settings", "log_level", level); +} + +env::CoreDumpTypes DiagnosticsSettings::coreDumpType() const +{ + return get(m_Settings, "Settings", "crash_dumps_type", + env::CoreDumpTypes::Mini); +} + +void DiagnosticsSettings::setCoreDumpType(env::CoreDumpTypes type) +{ + set(m_Settings, "Settings", "crash_dumps_type", type); +} + +int DiagnosticsSettings::maxCoreDumps() const +{ + return get(m_Settings, "Settings", "crash_dumps_max", 5); +} + +void DiagnosticsSettings::setMaxCoreDumps(int n) +{ + set(m_Settings, "Settings", "crash_dumps_max", n); +} + +std::chrono::seconds DiagnosticsSettings::spawnDelay() const +{ + return std::chrono::seconds(get(m_Settings, "Settings", "spawn_delay", 0)); +} + +void DiagnosticsSettings::setSpawnDelay(std::chrono::seconds t) +{ + set(m_Settings, "Settings", "spawn_delay", t.count()); +} + +void GlobalSettings::updateRegistryKey() +{ + const QString OldOrganization = "Tannin"; + const QString OldApplication = "Mod Organizer"; + const QString OldInstanceValue = "CurrentInstance"; + + const QString OldRootKey = "Software\\" + OldOrganization; + + if (env::registryValueExists(OldRootKey + "\\" + OldApplication, OldInstanceValue)) { + QSettings old(OldOrganization, OldApplication); + setCurrentInstance(old.value(OldInstanceValue).toString()); + old.remove(OldInstanceValue); + } + + env::deleteRegistryKeyIfEmpty(OldRootKey); +} + +QString GlobalSettings::currentInstance() +{ + return settings().value("CurrentInstance", "").toString(); +} + +void GlobalSettings::setCurrentInstance(const QString& s) +{ + settings().setValue("CurrentInstance", s); +} + +QSettings GlobalSettings::settings() +{ + const QString Organization = "Mod Organizer Team"; + const QString Application = "Mod Organizer"; + + return QSettings(Organization, Application); +} + +bool GlobalSettings::hideCreateInstanceIntro() +{ + return settings().value("HideCreateInstanceIntro", false).toBool(); +} + +void GlobalSettings::setHideCreateInstanceIntro(bool b) +{ + settings().setValue("HideCreateInstanceIntro", b); +} + +bool GlobalSettings::hideTutorialQuestion() +{ + return settings().value("HideTutorialQuestion", false).toBool(); +} + +void GlobalSettings::setHideTutorialQuestion(bool b) +{ + settings().setValue("HideTutorialQuestion", b); +} + +bool GlobalSettings::hideCategoryReminder() +{ + return settings().value("HideCategoryReminder", false).toBool(); +} + +void GlobalSettings::setHideCategoryReminder(bool b) +{ + settings().setValue("HideCategoryReminder", b); +} + +bool GlobalSettings::hideAssignCategoriesQuestion() +{ + return settings().value("HideAssignCategoriesQuestion", false).toBool(); +} + +void GlobalSettings::setHideAssignCategoriesQuestion(bool b) +{ + settings().setValue("HideAssignCategoriesQuestion", b); +} + +bool GlobalSettings::nexusApiKey(QString& apiKey) +{ + QString tempKey = getWindowsCredential("APIKEY"); + if (tempKey.isEmpty()) + return false; + + apiKey = tempKey; + return true; +} + +bool GlobalSettings::setNexusApiKey(const QString& apiKey) +{ + if (!setWindowsCredential("APIKEY", apiKey)) { + const auto e = GetLastError(); + log::error("Storing API key failed: {}", formatSystemMessage(e)); + return false; + } + + return true; +} + +bool GlobalSettings::clearNexusApiKey() +{ + return setNexusApiKey(""); +} + +bool GlobalSettings::hasNexusApiKey() +{ + return !getWindowsCredential("APIKEY").isEmpty(); +} + +void GlobalSettings::resetDialogs() +{ + setHideCreateInstanceIntro(false); + setHideTutorialQuestion(false); +} + +QStringList GlobalSettings::portableInstances() +{ + return settings().value("PortableInstances").toStringList(); +} + +void GlobalSettings::addPortableInstance(const QString& path) +{ + const QString canonical = QDir(path).absolutePath(); + QStringList list = portableInstances(); + if (!list.contains(canonical)) { + list.append(canonical); + settings().setValue("PortableInstances", list); + } +} + +void GlobalSettings::removePortableInstance(const QString& path) +{ + const QString canonical = QDir(path).absolutePath(); + QStringList list = portableInstances(); + if (list.removeAll(canonical) > 0) { + settings().setValue("PortableInstances", list); + } +} diff --git a/src/src/settings.h b/src/src/settings.h index 239ac1c..38ad505 100644 --- a/src/src/settings.h +++ b/src/src/settings.h @@ -1,995 +1,995 @@ -/* -Copyright (C) 2012 Sebastian Herbord. All rights reserved. - -This file is part of Mod Organizer. - -Mod Organizer is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -Mod Organizer is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with Mod Organizer. If not, see . -*/ - -#ifndef SETTINGS_H -#define SETTINGS_H - -#include "envdump.h" -#include -#include -#include - -#ifdef interface -#undef interface -#endif - -namespace MOBase -{ -class IPlugin; -class IPluginGame; -class ExpanderWidget; -} // namespace MOBase - -class QSplitter; - -class ServerList; -class Settings; - -// setting for the currently managed game -// -class GameSettings -{ -public: - GameSettings(QSettings& setting); - - // game plugin - // - const MOBase::IPluginGame* plugin(); - void setPlugin(const MOBase::IPluginGame* gamePlugin); - - // whether files of the core game are forced-enabled so the user can't - // accidentally disable them - // - bool forceEnableCoreFiles() const; - void setForceEnableCoreFiles(bool b); - - // the directory where the managed game is stored - // - std::optional directory() const; - void setDirectory(const QString& path); - - // the name of the managed game - // - std::optional name() const; - void setName(const QString& name); - - // the edition of the managed game - // - std::optional edition() const; - void setEdition(const QString& name); - - // the current profile name - // - std::optional selectedProfileName() const; - void setSelectedProfileName(const QString& name); - -private: - QSettings& m_Settings; - const MOBase::IPluginGame* m_GamePlugin{nullptr}; -}; - -// geometry settings for various widgets; this should contain any setting that -// can get invalid through UI changes or when users change display settings -// (resolution, monitors, etc.); see WidgetSettings for the counterpart -// -// all these settings are stored under [Geometry] and get wiped when the -// "reset geometry settings" button is clicked in the settings -// -// saveGeometry(), restoreGeometry(), saveState() and restoreState() call the -// same functions on the given widget -// -class GeometrySettings -{ -public: - GeometrySettings(QSettings& s); - - // asks the settings to get reset - // - // this gets called from the settings dialog and gets picked up in - // resetIfNeeded(), called from runApplication() just before exiting - // - void requestReset(); - void resetIfNeeded(); - - void saveGeometry(const QMainWindow* w); - bool restoreGeometry(QMainWindow* w) const; - - void saveGeometry(const QDialog* d); - bool restoreGeometry(QDialog* d) const; - - void saveState(const QMainWindow* window); - bool restoreState(QMainWindow* window) const; - - void saveState(const QHeaderView* header); - bool restoreState(QHeaderView* header) const; - - void saveState(const QSplitter* splitter); - bool restoreState(QSplitter* splitter) const; - - void saveState(const MOBase::ExpanderWidget* expander); - bool restoreState(MOBase::ExpanderWidget* expander) const; - - void saveVisibility(const QWidget* w); - bool restoreVisibility(QWidget* w, std::optional def = {}) const; - - void saveToolbars(const QMainWindow* w); - void restoreToolbars(QMainWindow* w) const; - - void saveDocks(const QMainWindow* w); - void restoreDocks(QMainWindow* w) const; - - // this should be a generic "tab order" setting, but it only happens for the - // mod info dialog right now - // - QStringList modInfoTabOrder() const; - void setModInfoTabOrder(const QString& names); - - // whether dialogs should be centered on their parent - // - bool centerDialogs() const; - void setCenterDialogs(bool b); - - // assumes the given widget is a top-level - // - void centerOnMainWindowMonitor(QWidget* w) const; - - // saves the monitor number of the given window - // - void saveMainWindowMonitor(const QMainWindow* w); - -private: - QSettings& m_Settings; - bool m_Reset{false}; - - void saveWindowGeometry(const QWidget* w); - bool restoreWindowGeometry(QWidget* w) const; - - static void ensureWindowOnScreen(QWidget* w) ; - static void centerOnMonitor(QWidget* w, int monitor); - static void centerOnParent(QWidget* w, QWidget* parent = nullptr); -}; - -// widget settings that should stay valid regardless of UI changes or when users -// change display settings (resolution, monitors, etc.); see GeometrySettings -// for the counterpart -// -class WidgetSettings -{ -public: - // globalInstance is forwarded from the Settings constructor; WidgetSettings - // has the callbacks used by QuestionBoxMemory and those are global, so they - // should only be set by the global instance - // - WidgetSettings(QSettings& s, bool globalInstance); - - // tree item check - this saves the list of expanded items based on the given role - // - void saveTreeCheckState(const QTreeView* tv, int role = Qt::CheckStateRole); - void restoreTreeCheckState(QTreeView* tv, int role = Qt::CheckStateRole) const; - - // tree state - this saves the list of expanded items based on the given role - // - void saveTreeExpandState(const QTreeView* tv, int role = Qt::DisplayRole); - void restoreTreeExpandState(QTreeView* tv, int role = Qt::DisplayRole) const; - - // selected index for a combobox - // - std::optional index(const QComboBox* cb) const; - void saveIndex(const QComboBox* cb); - void restoreIndex(QComboBox* cb, std::optional def = {}) const; - - // selected tab index for a tab widget - // - std::optional index(const QTabWidget* w) const; - void saveIndex(const QTabWidget* w); - void restoreIndex(QTabWidget* w, std::optional def = {}) const; - - // check state for a checkable button - // - std::optional checked(const QAbstractButton* w) const; - void saveChecked(const QAbstractButton* w); - void restoreChecked(QAbstractButton* w, std::optional def = {}) const; - - // returns the remembered button for a question dialog, or NoButton if the - // user hasn't saved the choice - // - MOBase::QuestionBoxMemory::Button questionButton(const QString& windowName, - const QString& filename) const; - - // sets the button to be remembered for the given window - // - void setQuestionWindowButton(const QString& windowName, - MOBase::QuestionBoxMemory::Button button); - - // sets the button to be remembered for the given file - // - void setQuestionFileButton(const QString& windowName, const QString& filename, - MOBase::QuestionBoxMemory::Button choice); - - // wipes all the remembered buttons - // - void resetQuestionButtons(); - -private: - QSettings& m_Settings; -}; - -// various color settings -// -class ColorSettings -{ -public: - ColorSettings(QSettings& s); - - QColor modlistOverwrittenLoose() const; - void setModlistOverwrittenLoose(const QColor& c); - - QColor modlistOverwritingLoose() const; - void setModlistOverwritingLoose(const QColor& c); - - QColor modlistOverwrittenArchive() const; - void setModlistOverwrittenArchive(const QColor& c); - - QColor modlistOverwritingArchive() const; - void setModlistOverwritingArchive(const QColor& c); - - QColor modlistContainsFile() const; - void setModlistContainsFile(const QColor& c); - - QColor pluginListContained() const; - void setPluginListContained(const QColor& c); - - QColor pluginListMaster() const; - void setPluginListMaster(const QColor& c); - - std::optional previousSeparatorColor() const; - void setPreviousSeparatorColor(const QColor& c) const; - void removePreviousSeparatorColor(); - - // whether the scrollbar of the mod list should have colors for custom - // separator colors - // - bool colorSeparatorScrollbar() const; - void setColorSeparatorScrollbar(bool b); - - // returns a color with a good contrast for the given background - // - static QColor idealTextColor(const QColor& rBackgroundColor); - -private: - QSettings& m_Settings; -}; - -// settings about plugins -// -class PluginSettings : public QObject -{ - Q_OBJECT - -public: - PluginSettings(QSettings& settings); - - // forgets all the plugins - // - void clearPlugins(); - - // adds/removes the given plugin to the list and loads all of its settings - // - void registerPlugin(MOBase::IPlugin* plugin); - void unregisterPlugin(MOBase::IPlugin* plugin); - - // returns all the registered plugins - // - std::vector plugins() const; - - // returns the plugin setting for the given key - // - QVariant setting(const QString& pluginName, const QString& key) const; - - // sets the plugin setting for the given key - // - void setSetting(const QString& pluginName, const QString& key, const QVariant& value); - - // returns all settings - // - QVariantMap settings(const QString& pluginName) const; - - // overwrites all settings - // - void setSettings(const QString& pluginName, const QVariantMap& map); - - // returns all descriptions - // - QVariantMap descriptions(const QString& pluginName) const; - - // overwrites all descriptions - // - void setDescriptions(const QString& pluginName, const QVariantMap& map); - - // ? - QVariant persistent(const QString& pluginName, const QString& key, - const QVariant& def) const; - void setPersistent(const QString& pluginName, const QString& key, - const QVariant& value, bool sync); - - // adds the given plugin to the blacklist - // - void addBlacklist(const QString& fileName); - - // returns whether the given plugin is blacklisted - // - bool blacklisted(const QString& fileName) const; - - // overwrites the whole blacklist - // - void setBlacklist(const QStringList& pluginNames); - - // returns the blacklist - // - const QSet& blacklist() const; - - // commits all the settings to the ini - // - void save(); - -Q_SIGNALS: - - /** - * Emitted when a plugin setting changes. - */ - void pluginSettingChanged(QString const& pluginName, const QString& key, - const QVariant& oldValue, const QVariant& newValue); - -private: - QSettings& m_Settings; - std::vector m_Plugins; - QMap m_PluginSettings; - QMap m_PluginDescriptions; - QSet m_PluginBlacklist; - - // commits the blacklist to the ini - // - void writeBlacklist(); - - // reads the blacklist from the ini - // - QSet readBlacklist() const; -}; - -// paths for the game and various components -// -// if the 'resolve' parameter is true, %BASE_DIR% is expanded; it's set to -// false mostly in the settings dialog -// -class PathSettings -{ -public: - // %BASE_DIR% - static const QString BaseDirVariable; - - PathSettings(QSettings& settings); - - QString base() const; - void setBase(const QString& path); - - QString downloads(bool resolve = true) const; - void setDownloads(const QString& path); - - QString mods(bool resolve = true) const; - void setMods(const QString& path); - - QString cache(bool resolve = true) const; - void setCache(const QString& path); - - QString profiles(bool resolve = true) const; - void setProfiles(const QString& path); - - QString overwrite(bool resolve = true) const; - void setOverwrite(const QString& path); - - // map of names to directories, used to remember the last directory used in - // various file pickers - // - std::map recent() const; - void setRecent(const std::map& map); - - // resolves %BASE_DIR% - // - static QString resolve(const QString& path, const QString& baseDir); - - // returns %BASE_DIR%/dirName - // - static QString makeDefaultPath(const QString dirName); - -private: - QSettings& m_Settings; - - QString getConfigurablePath(const QString& key, const QString& def, - bool resolve) const; - void setConfigurablePath(const QString& key, const QString& path); -}; - -class NetworkSettings -{ -public: - // globalInstance is forwarded from the Settings constructor; NetworkSettings - // will set the global URL custom command - // - NetworkSettings(QSettings& settings, bool globalInstance); - - // whether the user has disabled online features - // - bool offlineMode() const; - void setOfflineMode(bool b); - - // whether the user wants to use the system proxy - // - bool useProxy() const; - void setUseProxy(bool b); - - // add a new download speed to the list for the given server; each server - // remembers the last couple of download speeds and displays the average in - // the network settings - // - void setDownloadSpeed(const QString& serverName, int bytesPerSecond); - - // known servers - // - ServerList servers() const; - - // sets the servers - // - void updateServers(ServerList servers); - - // for 2.2.1 and before, rewrites the old byte array map to the new format - // - void updateFromOldMap(); - - // whether to use a custom command to open links - // - bool useCustomBrowser() const; - void setUseCustomBrowser(bool b); - - // custom command to open links - // - QString customBrowserCommand() const; - void setCustomBrowserCommand(const QString& s); - - void dump() const; - -private: - QSettings& m_Settings; - - // for pre 2.2.1 ini files - // - ServerList serversFromOldMap() const; - - // sets the custom command in uibase, called when the settings change - // - void updateCustomBrowser() const; -}; - -enum class EndorsementState -{ - Accepted = 1, - Refused, - NoDecision -}; - -EndorsementState endorsementStateFromString(const QString& s); -QString toString(EndorsementState s); - -class NexusSettings -{ -public: - NexusSettings(Settings& parent, QSettings& settings); - - // returns whether endorsement integration is enabled - // - bool endorsementIntegration() const; - void setEndorsementIntegration(bool b) const; - - // returns the endorsement state of MO itself - // - EndorsementState endorsementState() const; - void setEndorsementState(EndorsementState s); - - // returns whether tracked integration is enabled - // - bool trackedIntegration() const; - void setTrackedIntegration(bool b) const; - - // returns whether nexus category mappings are enabled - // - bool categoryMappings() const; - void setCategoryMappings(bool b) const; - - // registers MO as the handler for nxm links - // - // if 'force' is true, the registration dialog will be shown even if the user - // said earlier not to - // - static void registerAsNXMHandler(bool force); - - std::vector validationTimeouts() const; - - // dumps nxmhandler stuff - // - static void dump() ; - -private: - Settings& m_Parent; - QSettings& m_Settings; -}; - -class SteamSettings -{ -public: - SteamSettings(Settings& parent, QSettings& settings); - - // the steam appid is assigned by the steam platform to each product sold - // there. - // - // the appid may differ between different versions of a game so it may be - // impossible for MO to automatically recognize it, though usually it does - // - QString appID() const; - void setAppID(const QString& id); - - // the steam username is stored in the ini, but the password is in the - // windows credentials store; both values are independent and either can be - // empty - // - // if the username exists in the ini, it is assigned to `username`; if not - // `username` is set to an empty string - // - // if the password exists in the credentials store, it is assigned to - // `password`; if not, `password` is set to an empty string - // - // returns whether _both_ the username and password have a value - // - bool login(QString& username, QString& password) const; - - // sets the steam login; the username is saved in the ini file and the - // password in the credentials store - // - // if a value is empty, it is removed from its backing store - // - void setLogin(QString username, QString password); - -private: - Settings& m_Parent; - QSettings& m_Settings; -}; - -class InterfaceSettings -{ -public: - InterfaceSettings(QSettings& settings); - - // whether the GUI should be locked when running executables - // - bool lockGUI() const; - void setLockGUI(bool b); - - // filename of the theme - // - std::optional styleName() const; - void setStyleName(const QString& name); - - // whether to use collapsible separators when possible - // - bool collapsibleSeparators(Qt::SortOrder order) const; - void setCollapsibleSeparators(bool ascending, bool descending); - - // whether to highlight mod conflicts and plugins on collapsed - // separators - // - bool collapsibleSeparatorsHighlightTo() const; - void setCollapsibleSeparatorsHighlightTo(bool b); - - // whether to highlight mod conflicts and plugins from separators - // when selected but collapsed - // - bool collapsibleSeparatorsHighlightFrom() const; - void setCollapsibleSeparatorsHighlightFrom(bool b); - - // whether to show icons on collapsed separators - // - bool collapsibleSeparatorsIcons(int column) const; - void setCollapsibleSeparatorsIcons(int column, bool show); - - // whether each profile should have its own expansion state - // - bool collapsibleSeparatorsPerProfile() const; - void setCollapsibleSeparatorsPerProfile(bool b); - - // whether to save/restore filter states between runs - // - bool saveFilters() const; - void setSaveFilters(bool b); - - // whether to collapse groups (separators, categories, ...) after - // a delay when hovering (similar to auto-expand) - // - bool autoCollapseOnHover() const; - void setAutoCollapseOnHover(bool b); - - // whether to check for update after installing a mod - // - bool checkUpdateAfterInstallation() const; - void setCheckUpdateAfterInstallation(bool b); - - // whether to show compact downloads - // - bool compactDownloads() const; - void setCompactDownloads(bool b); - - // whether to show meta information for downloads - // - bool metaDownloads() const; - void setMetaDownloads(bool b); - - // whether to hide downloads after installing them - // - bool hideDownloadsAfterInstallation() const; - void setHideDownloadsAfterInstallation(bool b); - - // whether the API counter should be hidden - // - bool hideAPICounter() const; - void setHideAPICounter(bool b); - - // whether the user wants to see non-official plugins installed outside MO in - // the mod list - // - bool displayForeign() const; - void setDisplayForeign(bool b); - - // short code of the configured language (corresponding to the translation - // files) - // - QString language(); - void setLanguage(const QString& name); - - // whether the given tutorial has been completed - // - bool isTutorialCompleted(const QString& windowName) const; - void setTutorialCompleted(const QString& windowName, bool b = true); - - // whether to show the confirmation when switching instances - // - bool showChangeGameConfirmation() const; - void setShowChangeGameConfirmation(bool b); - - // whether to show the menubar when pressing the Alt key - // - bool showMenubarOnAlt() const; - void setShowMenubarOnAlt(bool b); - - // whether double-clicks on files should try to open previews first - // - bool doubleClicksOpenPreviews() const; - void setDoubleClicksOpenPreviews(bool b); - - // filter widget options - // - MOBase::FilterWidget::Options filterOptions() const; - void setFilterOptions(const MOBase::FilterWidget::Options& o); - -private: - QSettings& m_Settings; -}; - -class DiagnosticsSettings -{ -public: - DiagnosticsSettings(QSettings& settings); - - // log level for MO - // - MOBase::log::Levels logLevel() const; - void setLogLevel(MOBase::log::Levels level); - - // crash dump type for both MO and usvfs - // - env::CoreDumpTypes coreDumpType() const; - void setCoreDumpType(env::CoreDumpTypes type); - - // maximum number of dump files keps, for both MO and usvfs - // - int maxCoreDumps() const; - void setMaxCoreDumps(int n); - - std::chrono::seconds spawnDelay() const; - void setSpawnDelay(std::chrono::seconds t); - -private: - QSettings& m_Settings; -}; - -// manages the settings for MO; the settings are accessed directly through a -// QSettings and so are not cached here -// -class Settings : public QObject -{ - Q_OBJECT; - -public: - // there is one Settings global object for MO when an instance is loaded, but - // other Settings objects are required in several places, such as in the - // Instance class, the instance dialogs, etc. - // - // any Settings object created with globalInstance==false won't set the - // singleton - // - // some sub-objects like WidgetSettings and NetworkSettings need to know - // whether they were created from a globalInstance, see their constructor - // - // @param path path to an ini file - // @param globalInsance whether this is the global instance; creates the - // singleton and asserts if it already exists - // - Settings(const QString& path, bool globalInstance = false); - ~Settings() override; - - // throws if there is no global Settings instance - // - static Settings& instance(); - - // returns null if there is no global Settings instance - // - static Settings* maybeInstance(); - - // name of the ini file - // - QString filename() const; - - // version of MO stored in the ini; this may be different from the current - // version if the user just updated - // - std::optional version() const; - - // updates the settings to bring them up to date - // - void processUpdates(const QVersionNumber& current, const QVersionNumber& last); - - // whether MO has been started for the first time - // - bool firstStart() const; - void setFirstStart(bool b); - - // configured executables - // - std::vector> executables() const; - void setExecutables(const std::vector>& v); - - // whether to backup existing mods on install - // - bool keepBackupOnInstall() const; - void setKeepBackupOnInstall(bool b); - - // blacklisted executables do not get hooked by usvfs; this list is managed - // by MO but given to usvfs when starting an executable - // - QString executablesBlacklist() const; - bool isExecutableBlacklisted(const QString& s) const; - void setExecutablesBlacklist(const QString& s); - - QStringList skipFileSuffixes() const; - void setSkipFileSuffixes(const QStringList& s); - - QStringList skipDirectories() const; - void setSkipDirectories(const QStringList& s); - - // ? looks obsolete, only used by dead code - // - unsigned int motdHash() const; - void setMotdHash(unsigned int hash); - - // whether archives should be parsed to show conflicts and contents - // - bool archiveParsing() const; - void setArchiveParsing(bool b); - - // whether the user wants to check for updates - // - bool checkForUpdates() const; - void setCheckForUpdates(bool b); - - // whether the user wants to upgrade to pre-releases - // - bool usePrereleases() const; - void setUsePrereleases(bool b); - - // Fluorine self-update channel: "stable" (tagged releases) or "beta" - // (rolling build). Defaults to whichever channel the installed binary - // was built under — set at first launch, mutable via Settings dialog. - QString fluorineUpdateChannel() const; - void setFluorineUpdateChannel(const QString& channel); - - // whether profiles should default to local INIs - // - bool profileLocalInis() const; - void setProfileLocalInis(bool b); - - // whether profiles should default to local saves - // - bool profileLocalSaves() const; - void setProfileLocalSaves(bool b); - - // whether profiles should default to automatic archive invalidation - // - bool profileArchiveInvalidation() const; - void setProfileArchiveInvalidation(bool b); - - // whether to use spascreen or not - // - bool useSplash() const; - void setUseSplash(bool b); - - // number of threads to use when refreshing - // - std::size_t refreshThreadCount() const; - void setRefreshThreadCount(std::size_t n) const; - - GameSettings& game(); - const GameSettings& game() const; - - GeometrySettings& geometry(); - const GeometrySettings& geometry() const; - - WidgetSettings& widgets(); - const WidgetSettings& widgets() const; - - ColorSettings& colors(); - const ColorSettings& colors() const; - - PluginSettings& plugins(); - const PluginSettings& plugins() const; - - PathSettings& paths(); - const PathSettings& paths() const; - - NetworkSettings& network(); - const NetworkSettings& network() const; - - NexusSettings& nexus(); - const NexusSettings& nexus() const; - - SteamSettings& steam(); - const SteamSettings& steam() const; - - InterfaceSettings& interface(); - const InterfaceSettings& interface() const; - - DiagnosticsSettings& diagnostics(); - const DiagnosticsSettings& diagnostics() const; - - // makes sure the ini file is written to disk - // - QSettings::Status sync() const; - - // last status of the ini file - // - QSettings::Status iniStatus() const; - - void dump() const; - -public slots: - // this slot is connected to by various parts of MO - // - void managedGameChanged(MOBase::IPluginGame const* gamePlugin); - -signals: - // these are fired from outside the settings, mostly by the settings dialog - // - void languageChanged(const QString& newLanguage); - void styleChanged(const QString& newStyle); - -private: - static Settings* s_Instance; - mutable QSettings m_Settings; - - GameSettings m_Game; - GeometrySettings m_Geometry; - WidgetSettings m_Widgets; - ColorSettings m_Colors; - PluginSettings m_Plugins; - PathSettings m_Paths; - NetworkSettings m_Network; - NexusSettings m_Nexus; - SteamSettings m_Steam; - InterfaceSettings m_Interface; - DiagnosticsSettings m_Diagnostics; -}; - -// manages global settings in the registry -// -class GlobalSettings -{ -public: - // migrates the old settings from the Tannin key to the new one - static void updateRegistryKey(); - - static QString currentInstance(); - static void setCurrentInstance(const QString& s); - - static bool hideCreateInstanceIntro(); - static void setHideCreateInstanceIntro(bool b); - - static bool hideTutorialQuestion(); - static void setHideTutorialQuestion(bool b); - - static bool hideCategoryReminder(); - static void setHideCategoryReminder(bool b); - - static bool hideAssignCategoriesQuestion(); - static void setHideAssignCategoriesQuestion(bool b); - - // if the key exists from the credentials store, puts it in `apiKey` and - // returns true; otherwise, returns false and leaves `apiKey` untouched - // - static bool nexusApiKey(QString& apiKey); - - // sets the api key in the credentials store, removes it if empty; returns - // false on errors - // - static bool setNexusApiKey(const QString& apiKey); - - // removes the api key from the credentials store; returns false on errors - // - static bool clearNexusApiKey(); - - // returns whether an API key is currently stored - // - static bool hasNexusApiKey(); - - // resets anything that the user can disable - static void resetDialogs(); - - // persistent registry of portable instance paths - static QStringList portableInstances(); - static void addPortableInstance(const QString& path); - static void removePortableInstance(const QString& path); - -private: - static QSettings settings(); -}; - -// helper class that calls restoreGeometry() in the constructor and -// saveGeometry() in the destructor -// -template -class GeometrySaver -{ -public: - GeometrySaver(Settings& s, W* w) : m_settings(s), m_widget(w) - { - m_settings.geometry().restoreGeometry(m_widget); - } - - ~GeometrySaver() { m_settings.geometry().saveGeometry(m_widget); } - -private: - Settings& m_settings; - W* m_widget; -}; - -#endif // SETTINGS_H +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef SETTINGS_H +#define SETTINGS_H + +#include "envdump.h" +#include +#include +#include + +#ifdef interface +#undef interface +#endif + +namespace MOBase +{ +class IPlugin; +class IPluginGame; +class ExpanderWidget; +} // namespace MOBase + +class QSplitter; + +class ServerList; +class Settings; + +// setting for the currently managed game +// +class GameSettings +{ +public: + GameSettings(QSettings& setting); + + // game plugin + // + const MOBase::IPluginGame* plugin(); + void setPlugin(const MOBase::IPluginGame* gamePlugin); + + // whether files of the core game are forced-enabled so the user can't + // accidentally disable them + // + bool forceEnableCoreFiles() const; + void setForceEnableCoreFiles(bool b); + + // the directory where the managed game is stored + // + std::optional directory() const; + void setDirectory(const QString& path); + + // the name of the managed game + // + std::optional name() const; + void setName(const QString& name); + + // the edition of the managed game + // + std::optional edition() const; + void setEdition(const QString& name); + + // the current profile name + // + std::optional selectedProfileName() const; + void setSelectedProfileName(const QString& name); + +private: + QSettings& m_Settings; + const MOBase::IPluginGame* m_GamePlugin{nullptr}; +}; + +// geometry settings for various widgets; this should contain any setting that +// can get invalid through UI changes or when users change display settings +// (resolution, monitors, etc.); see WidgetSettings for the counterpart +// +// all these settings are stored under [Geometry] and get wiped when the +// "reset geometry settings" button is clicked in the settings +// +// saveGeometry(), restoreGeometry(), saveState() and restoreState() call the +// same functions on the given widget +// +class GeometrySettings +{ +public: + GeometrySettings(QSettings& s); + + // asks the settings to get reset + // + // this gets called from the settings dialog and gets picked up in + // resetIfNeeded(), called from runApplication() just before exiting + // + void requestReset(); + void resetIfNeeded(); + + void saveGeometry(const QMainWindow* w); + bool restoreGeometry(QMainWindow* w) const; + + void saveGeometry(const QDialog* d); + bool restoreGeometry(QDialog* d) const; + + void saveState(const QMainWindow* window); + bool restoreState(QMainWindow* window) const; + + void saveState(const QHeaderView* header); + bool restoreState(QHeaderView* header) const; + + void saveState(const QSplitter* splitter); + bool restoreState(QSplitter* splitter) const; + + void saveState(const MOBase::ExpanderWidget* expander); + bool restoreState(MOBase::ExpanderWidget* expander) const; + + void saveVisibility(const QWidget* w); + bool restoreVisibility(QWidget* w, std::optional def = {}) const; + + void saveToolbars(const QMainWindow* w); + void restoreToolbars(QMainWindow* w) const; + + void saveDocks(const QMainWindow* w); + void restoreDocks(QMainWindow* w) const; + + // this should be a generic "tab order" setting, but it only happens for the + // mod info dialog right now + // + QStringList modInfoTabOrder() const; + void setModInfoTabOrder(const QString& names); + + // whether dialogs should be centered on their parent + // + bool centerDialogs() const; + void setCenterDialogs(bool b); + + // assumes the given widget is a top-level + // + void centerOnMainWindowMonitor(QWidget* w) const; + + // saves the monitor number of the given window + // + void saveMainWindowMonitor(const QMainWindow* w); + +private: + QSettings& m_Settings; + bool m_Reset{false}; + + void saveWindowGeometry(const QWidget* w); + bool restoreWindowGeometry(QWidget* w) const; + + static void ensureWindowOnScreen(QWidget* w) ; + static void centerOnMonitor(QWidget* w, int monitor); + static void centerOnParent(QWidget* w, QWidget* parent = nullptr); +}; + +// widget settings that should stay valid regardless of UI changes or when users +// change display settings (resolution, monitors, etc.); see GeometrySettings +// for the counterpart +// +class WidgetSettings +{ +public: + // globalInstance is forwarded from the Settings constructor; WidgetSettings + // has the callbacks used by QuestionBoxMemory and those are global, so they + // should only be set by the global instance + // + WidgetSettings(QSettings& s, bool globalInstance); + + // tree item check - this saves the list of expanded items based on the given role + // + void saveTreeCheckState(const QTreeView* tv, int role = Qt::CheckStateRole); + void restoreTreeCheckState(QTreeView* tv, int role = Qt::CheckStateRole) const; + + // tree state - this saves the list of expanded items based on the given role + // + void saveTreeExpandState(const QTreeView* tv, int role = Qt::DisplayRole); + void restoreTreeExpandState(QTreeView* tv, int role = Qt::DisplayRole) const; + + // selected index for a combobox + // + std::optional index(const QComboBox* cb) const; + void saveIndex(const QComboBox* cb); + void restoreIndex(QComboBox* cb, std::optional def = {}) const; + + // selected tab index for a tab widget + // + std::optional index(const QTabWidget* w) const; + void saveIndex(const QTabWidget* w); + void restoreIndex(QTabWidget* w, std::optional def = {}) const; + + // check state for a checkable button + // + std::optional checked(const QAbstractButton* w) const; + void saveChecked(const QAbstractButton* w); + void restoreChecked(QAbstractButton* w, std::optional def = {}) const; + + // returns the remembered button for a question dialog, or NoButton if the + // user hasn't saved the choice + // + MOBase::QuestionBoxMemory::Button questionButton(const QString& windowName, + const QString& filename) const; + + // sets the button to be remembered for the given window + // + void setQuestionWindowButton(const QString& windowName, + MOBase::QuestionBoxMemory::Button button); + + // sets the button to be remembered for the given file + // + void setQuestionFileButton(const QString& windowName, const QString& filename, + MOBase::QuestionBoxMemory::Button choice); + + // wipes all the remembered buttons + // + void resetQuestionButtons(); + +private: + QSettings& m_Settings; +}; + +// various color settings +// +class ColorSettings +{ +public: + ColorSettings(QSettings& s); + + QColor modlistOverwrittenLoose() const; + void setModlistOverwrittenLoose(const QColor& c); + + QColor modlistOverwritingLoose() const; + void setModlistOverwritingLoose(const QColor& c); + + QColor modlistOverwrittenArchive() const; + void setModlistOverwrittenArchive(const QColor& c); + + QColor modlistOverwritingArchive() const; + void setModlistOverwritingArchive(const QColor& c); + + QColor modlistContainsFile() const; + void setModlistContainsFile(const QColor& c); + + QColor pluginListContained() const; + void setPluginListContained(const QColor& c); + + QColor pluginListMaster() const; + void setPluginListMaster(const QColor& c); + + std::optional previousSeparatorColor() const; + void setPreviousSeparatorColor(const QColor& c) const; + void removePreviousSeparatorColor(); + + // whether the scrollbar of the mod list should have colors for custom + // separator colors + // + bool colorSeparatorScrollbar() const; + void setColorSeparatorScrollbar(bool b); + + // returns a color with a good contrast for the given background + // + static QColor idealTextColor(const QColor& rBackgroundColor); + +private: + QSettings& m_Settings; +}; + +// settings about plugins +// +class PluginSettings : public QObject +{ + Q_OBJECT + +public: + PluginSettings(QSettings& settings); + + // forgets all the plugins + // + void clearPlugins(); + + // adds/removes the given plugin to the list and loads all of its settings + // + void registerPlugin(MOBase::IPlugin* plugin); + void unregisterPlugin(MOBase::IPlugin* plugin); + + // returns all the registered plugins + // + std::vector plugins() const; + + // returns the plugin setting for the given key + // + QVariant setting(const QString& pluginName, const QString& key) const; + + // sets the plugin setting for the given key + // + void setSetting(const QString& pluginName, const QString& key, const QVariant& value); + + // returns all settings + // + QVariantMap settings(const QString& pluginName) const; + + // overwrites all settings + // + void setSettings(const QString& pluginName, const QVariantMap& map); + + // returns all descriptions + // + QVariantMap descriptions(const QString& pluginName) const; + + // overwrites all descriptions + // + void setDescriptions(const QString& pluginName, const QVariantMap& map); + + // ? + QVariant persistent(const QString& pluginName, const QString& key, + const QVariant& def) const; + void setPersistent(const QString& pluginName, const QString& key, + const QVariant& value, bool sync); + + // adds the given plugin to the blacklist + // + void addBlacklist(const QString& fileName); + + // returns whether the given plugin is blacklisted + // + bool blacklisted(const QString& fileName) const; + + // overwrites the whole blacklist + // + void setBlacklist(const QStringList& pluginNames); + + // returns the blacklist + // + const QSet& blacklist() const; + + // commits all the settings to the ini + // + void save(); + +Q_SIGNALS: + + /** + * Emitted when a plugin setting changes. + */ + void pluginSettingChanged(QString const& pluginName, const QString& key, + const QVariant& oldValue, const QVariant& newValue); + +private: + QSettings& m_Settings; + std::vector m_Plugins; + QMap m_PluginSettings; + QMap m_PluginDescriptions; + QSet m_PluginBlacklist; + + // commits the blacklist to the ini + // + void writeBlacklist(); + + // reads the blacklist from the ini + // + QSet readBlacklist() const; +}; + +// paths for the game and various components +// +// if the 'resolve' parameter is true, %BASE_DIR% is expanded; it's set to +// false mostly in the settings dialog +// +class PathSettings +{ +public: + // %BASE_DIR% + static const QString BaseDirVariable; + + PathSettings(QSettings& settings); + + QString base() const; + void setBase(const QString& path); + + QString downloads(bool resolve = true) const; + void setDownloads(const QString& path); + + QString mods(bool resolve = true) const; + void setMods(const QString& path); + + QString cache(bool resolve = true) const; + void setCache(const QString& path); + + QString profiles(bool resolve = true) const; + void setProfiles(const QString& path); + + QString overwrite(bool resolve = true) const; + void setOverwrite(const QString& path); + + // map of names to directories, used to remember the last directory used in + // various file pickers + // + std::map recent() const; + void setRecent(const std::map& map); + + // resolves %BASE_DIR% + // + static QString resolve(const QString& path, const QString& baseDir); + + // returns %BASE_DIR%/dirName + // + static QString makeDefaultPath(QString dirName); + +private: + QSettings& m_Settings; + + QString getConfigurablePath(const QString& key, const QString& def, + bool resolve) const; + void setConfigurablePath(const QString& key, const QString& path); +}; + +class NetworkSettings +{ +public: + // globalInstance is forwarded from the Settings constructor; NetworkSettings + // will set the global URL custom command + // + NetworkSettings(QSettings& settings, bool globalInstance); + + // whether the user has disabled online features + // + bool offlineMode() const; + void setOfflineMode(bool b); + + // whether the user wants to use the system proxy + // + bool useProxy() const; + void setUseProxy(bool b); + + // add a new download speed to the list for the given server; each server + // remembers the last couple of download speeds and displays the average in + // the network settings + // + void setDownloadSpeed(const QString& serverName, int bytesPerSecond); + + // known servers + // + ServerList servers() const; + + // sets the servers + // + void updateServers(ServerList servers); + + // for 2.2.1 and before, rewrites the old byte array map to the new format + // + void updateFromOldMap(); + + // whether to use a custom command to open links + // + bool useCustomBrowser() const; + void setUseCustomBrowser(bool b); + + // custom command to open links + // + QString customBrowserCommand() const; + void setCustomBrowserCommand(const QString& s); + + void dump() const; + +private: + QSettings& m_Settings; + + // for pre 2.2.1 ini files + // + ServerList serversFromOldMap() const; + + // sets the custom command in uibase, called when the settings change + // + void updateCustomBrowser() const; +}; + +enum class EndorsementState +{ + Accepted = 1, + Refused, + NoDecision +}; + +EndorsementState endorsementStateFromString(const QString& s); +QString toString(EndorsementState s); + +class NexusSettings +{ +public: + NexusSettings(Settings& parent, QSettings& settings); + + // returns whether endorsement integration is enabled + // + bool endorsementIntegration() const; + void setEndorsementIntegration(bool b) const; + + // returns the endorsement state of MO itself + // + EndorsementState endorsementState() const; + void setEndorsementState(EndorsementState s); + + // returns whether tracked integration is enabled + // + bool trackedIntegration() const; + void setTrackedIntegration(bool b) const; + + // returns whether nexus category mappings are enabled + // + bool categoryMappings() const; + void setCategoryMappings(bool b) const; + + // registers MO as the handler for nxm links + // + // if 'force' is true, the registration dialog will be shown even if the user + // said earlier not to + // + static void registerAsNXMHandler(bool force); + + std::vector validationTimeouts() const; + + // dumps nxmhandler stuff + // + static void dump() ; + +private: + Settings& m_Parent; + QSettings& m_Settings; +}; + +class SteamSettings +{ +public: + SteamSettings(Settings& parent, QSettings& settings); + + // the steam appid is assigned by the steam platform to each product sold + // there. + // + // the appid may differ between different versions of a game so it may be + // impossible for MO to automatically recognize it, though usually it does + // + QString appID() const; + void setAppID(const QString& id); + + // the steam username is stored in the ini, but the password is in the + // windows credentials store; both values are independent and either can be + // empty + // + // if the username exists in the ini, it is assigned to `username`; if not + // `username` is set to an empty string + // + // if the password exists in the credentials store, it is assigned to + // `password`; if not, `password` is set to an empty string + // + // returns whether _both_ the username and password have a value + // + bool login(QString& username, QString& password) const; + + // sets the steam login; the username is saved in the ini file and the + // password in the credentials store + // + // if a value is empty, it is removed from its backing store + // + void setLogin(QString username, QString password); + +private: + Settings& m_Parent; + QSettings& m_Settings; +}; + +class InterfaceSettings +{ +public: + InterfaceSettings(QSettings& settings); + + // whether the GUI should be locked when running executables + // + bool lockGUI() const; + void setLockGUI(bool b); + + // filename of the theme + // + std::optional styleName() const; + void setStyleName(const QString& name); + + // whether to use collapsible separators when possible + // + bool collapsibleSeparators(Qt::SortOrder order) const; + void setCollapsibleSeparators(bool ascending, bool descending); + + // whether to highlight mod conflicts and plugins on collapsed + // separators + // + bool collapsibleSeparatorsHighlightTo() const; + void setCollapsibleSeparatorsHighlightTo(bool b); + + // whether to highlight mod conflicts and plugins from separators + // when selected but collapsed + // + bool collapsibleSeparatorsHighlightFrom() const; + void setCollapsibleSeparatorsHighlightFrom(bool b); + + // whether to show icons on collapsed separators + // + bool collapsibleSeparatorsIcons(int column) const; + void setCollapsibleSeparatorsIcons(int column, bool show); + + // whether each profile should have its own expansion state + // + bool collapsibleSeparatorsPerProfile() const; + void setCollapsibleSeparatorsPerProfile(bool b); + + // whether to save/restore filter states between runs + // + bool saveFilters() const; + void setSaveFilters(bool b); + + // whether to collapse groups (separators, categories, ...) after + // a delay when hovering (similar to auto-expand) + // + bool autoCollapseOnHover() const; + void setAutoCollapseOnHover(bool b); + + // whether to check for update after installing a mod + // + bool checkUpdateAfterInstallation() const; + void setCheckUpdateAfterInstallation(bool b); + + // whether to show compact downloads + // + bool compactDownloads() const; + void setCompactDownloads(bool b); + + // whether to show meta information for downloads + // + bool metaDownloads() const; + void setMetaDownloads(bool b); + + // whether to hide downloads after installing them + // + bool hideDownloadsAfterInstallation() const; + void setHideDownloadsAfterInstallation(bool b); + + // whether the API counter should be hidden + // + bool hideAPICounter() const; + void setHideAPICounter(bool b); + + // whether the user wants to see non-official plugins installed outside MO in + // the mod list + // + bool displayForeign() const; + void setDisplayForeign(bool b); + + // short code of the configured language (corresponding to the translation + // files) + // + QString language(); + void setLanguage(const QString& name); + + // whether the given tutorial has been completed + // + bool isTutorialCompleted(const QString& windowName) const; + void setTutorialCompleted(const QString& windowName, bool b = true); + + // whether to show the confirmation when switching instances + // + bool showChangeGameConfirmation() const; + void setShowChangeGameConfirmation(bool b); + + // whether to show the menubar when pressing the Alt key + // + bool showMenubarOnAlt() const; + void setShowMenubarOnAlt(bool b); + + // whether double-clicks on files should try to open previews first + // + bool doubleClicksOpenPreviews() const; + void setDoubleClicksOpenPreviews(bool b); + + // filter widget options + // + MOBase::FilterWidget::Options filterOptions() const; + void setFilterOptions(const MOBase::FilterWidget::Options& o); + +private: + QSettings& m_Settings; +}; + +class DiagnosticsSettings +{ +public: + DiagnosticsSettings(QSettings& settings); + + // log level for MO + // + MOBase::log::Levels logLevel() const; + void setLogLevel(MOBase::log::Levels level); + + // crash dump type for both MO and usvfs + // + env::CoreDumpTypes coreDumpType() const; + void setCoreDumpType(env::CoreDumpTypes type); + + // maximum number of dump files keps, for both MO and usvfs + // + int maxCoreDumps() const; + void setMaxCoreDumps(int n); + + std::chrono::seconds spawnDelay() const; + void setSpawnDelay(std::chrono::seconds t); + +private: + QSettings& m_Settings; +}; + +// manages the settings for MO; the settings are accessed directly through a +// QSettings and so are not cached here +// +class Settings : public QObject +{ + Q_OBJECT; + +public: + // there is one Settings global object for MO when an instance is loaded, but + // other Settings objects are required in several places, such as in the + // Instance class, the instance dialogs, etc. + // + // any Settings object created with globalInstance==false won't set the + // singleton + // + // some sub-objects like WidgetSettings and NetworkSettings need to know + // whether they were created from a globalInstance, see their constructor + // + // @param path path to an ini file + // @param globalInsance whether this is the global instance; creates the + // singleton and asserts if it already exists + // + Settings(const QString& path, bool globalInstance = false); + ~Settings() override; + + // throws if there is no global Settings instance + // + static Settings& instance(); + + // returns null if there is no global Settings instance + // + static Settings* maybeInstance(); + + // name of the ini file + // + QString filename() const; + + // version of MO stored in the ini; this may be different from the current + // version if the user just updated + // + std::optional version() const; + + // updates the settings to bring them up to date + // + void processUpdates(const QVersionNumber& current, const QVersionNumber& last); + + // whether MO has been started for the first time + // + bool firstStart() const; + void setFirstStart(bool b); + + // configured executables + // + std::vector> executables() const; + void setExecutables(const std::vector>& v); + + // whether to backup existing mods on install + // + bool keepBackupOnInstall() const; + void setKeepBackupOnInstall(bool b); + + // blacklisted executables do not get hooked by usvfs; this list is managed + // by MO but given to usvfs when starting an executable + // + QString executablesBlacklist() const; + bool isExecutableBlacklisted(const QString& s) const; + void setExecutablesBlacklist(const QString& s); + + QStringList skipFileSuffixes() const; + void setSkipFileSuffixes(const QStringList& s); + + QStringList skipDirectories() const; + void setSkipDirectories(const QStringList& s); + + // ? looks obsolete, only used by dead code + // + unsigned int motdHash() const; + void setMotdHash(unsigned int hash); + + // whether archives should be parsed to show conflicts and contents + // + bool archiveParsing() const; + void setArchiveParsing(bool b); + + // whether the user wants to check for updates + // + bool checkForUpdates() const; + void setCheckForUpdates(bool b); + + // whether the user wants to upgrade to pre-releases + // + bool usePrereleases() const; + void setUsePrereleases(bool b); + + // Fluorine self-update channel: "stable" (tagged releases) or "beta" + // (rolling build). Defaults to whichever channel the installed binary + // was built under — set at first launch, mutable via Settings dialog. + QString fluorineUpdateChannel() const; + void setFluorineUpdateChannel(const QString& channel); + + // whether profiles should default to local INIs + // + bool profileLocalInis() const; + void setProfileLocalInis(bool b); + + // whether profiles should default to local saves + // + bool profileLocalSaves() const; + void setProfileLocalSaves(bool b); + + // whether profiles should default to automatic archive invalidation + // + bool profileArchiveInvalidation() const; + void setProfileArchiveInvalidation(bool b); + + // whether to use spascreen or not + // + bool useSplash() const; + void setUseSplash(bool b); + + // number of threads to use when refreshing + // + std::size_t refreshThreadCount() const; + void setRefreshThreadCount(std::size_t n) const; + + GameSettings& game(); + const GameSettings& game() const; + + GeometrySettings& geometry(); + const GeometrySettings& geometry() const; + + WidgetSettings& widgets(); + const WidgetSettings& widgets() const; + + ColorSettings& colors(); + const ColorSettings& colors() const; + + PluginSettings& plugins(); + const PluginSettings& plugins() const; + + PathSettings& paths(); + const PathSettings& paths() const; + + NetworkSettings& network(); + const NetworkSettings& network() const; + + NexusSettings& nexus(); + const NexusSettings& nexus() const; + + SteamSettings& steam(); + const SteamSettings& steam() const; + + InterfaceSettings& interface(); + const InterfaceSettings& interface() const; + + DiagnosticsSettings& diagnostics(); + const DiagnosticsSettings& diagnostics() const; + + // makes sure the ini file is written to disk + // + QSettings::Status sync() const; + + // last status of the ini file + // + QSettings::Status iniStatus() const; + + void dump() const; + +public slots: + // this slot is connected to by various parts of MO + // + void managedGameChanged(MOBase::IPluginGame const* gamePlugin); + +signals: + // these are fired from outside the settings, mostly by the settings dialog + // + void languageChanged(const QString& newLanguage); + void styleChanged(const QString& newStyle); + +private: + static Settings* s_Instance; + mutable QSettings m_Settings; + + GameSettings m_Game; + GeometrySettings m_Geometry; + WidgetSettings m_Widgets; + ColorSettings m_Colors; + PluginSettings m_Plugins; + PathSettings m_Paths; + NetworkSettings m_Network; + NexusSettings m_Nexus; + SteamSettings m_Steam; + InterfaceSettings m_Interface; + DiagnosticsSettings m_Diagnostics; +}; + +// manages global settings in the registry +// +class GlobalSettings +{ +public: + // migrates the old settings from the Tannin key to the new one + static void updateRegistryKey(); + + static QString currentInstance(); + static void setCurrentInstance(const QString& s); + + static bool hideCreateInstanceIntro(); + static void setHideCreateInstanceIntro(bool b); + + static bool hideTutorialQuestion(); + static void setHideTutorialQuestion(bool b); + + static bool hideCategoryReminder(); + static void setHideCategoryReminder(bool b); + + static bool hideAssignCategoriesQuestion(); + static void setHideAssignCategoriesQuestion(bool b); + + // if the key exists from the credentials store, puts it in `apiKey` and + // returns true; otherwise, returns false and leaves `apiKey` untouched + // + static bool nexusApiKey(QString& apiKey); + + // sets the api key in the credentials store, removes it if empty; returns + // false on errors + // + static bool setNexusApiKey(const QString& apiKey); + + // removes the api key from the credentials store; returns false on errors + // + static bool clearNexusApiKey(); + + // returns whether an API key is currently stored + // + static bool hasNexusApiKey(); + + // resets anything that the user can disable + static void resetDialogs(); + + // persistent registry of portable instance paths + static QStringList portableInstances(); + static void addPortableInstance(const QString& path); + static void removePortableInstance(const QString& path); + +private: + static QSettings settings(); +}; + +// helper class that calls restoreGeometry() in the constructor and +// saveGeometry() in the destructor +// +template +class GeometrySaver +{ +public: + GeometrySaver(Settings& s, W* w) : m_settings(s), m_widget(w) + { + m_settings.geometry().restoreGeometry(m_widget); + } + + ~GeometrySaver() { m_settings.geometry().saveGeometry(m_widget); } + +private: + Settings& m_settings; + W* m_widget; +}; + +#endif // SETTINGS_H diff --git a/src/src/settingsdialoggeneral.cpp b/src/src/settingsdialoggeneral.cpp index 690dfb4..81af13c 100644 --- a/src/src/settingsdialoggeneral.cpp +++ b/src/src/settingsdialoggeneral.cpp @@ -1,180 +1,180 @@ -#include "settingsdialoggeneral.h" -#include "categoriesdialog.h" -#include "colortable.h" -#include "shared/appconfig.h" -#include "ui_settingsdialog.h" -#include -#include - -using namespace MOBase; - -GeneralSettingsTab::GeneralSettingsTab(Settings& s, SettingsDialog& d) - : SettingsTab(s, d) -{ - // language - addLanguages(); - selectLanguage(); - - // download list - ui->compactBox->setChecked(settings().interface().compactDownloads()); - ui->showMetaBox->setChecked(settings().interface().metaDownloads()); - ui->hideDownloadInstallBox->setChecked( - settings().interface().hideDownloadsAfterInstallation()); - - // Update settings have moved to their own "Updates" tab. Hide the - // legacy controls here so the two UIs don't contradict each other. - ui->checkForUpdates->hide(); - ui->usePrereleaseBox->hide(); - - // profile defaults - ui->localINIs->setChecked(settings().profileLocalInis()); - ui->localSaves->setChecked(settings().profileLocalSaves()); - ui->automaticArchiveInvalidation->setChecked(settings().profileArchiveInvalidation()); - - // miscellaneous - ui->centerDialogs->setChecked(settings().geometry().centerDialogs()); - ui->changeGameConfirmation->setChecked( - settings().interface().showChangeGameConfirmation()); - ui->showMenubarOnAlt->setChecked(settings().interface().showMenubarOnAlt()); - ui->doubleClickPreviews->setChecked( - settings().interface().doubleClicksOpenPreviews()); - - QObject::connect(ui->categoriesBtn, &QPushButton::clicked, [&] { - onEditCategories(); - }); - - QObject::connect(ui->resetDialogsButton, &QPushButton::clicked, [&] { - onResetDialogs(); - }); -} - -void GeneralSettingsTab::update() -{ - // language - const QString oldLanguage = settings().interface().language(); - const QString newLanguage = - ui->languageBox->itemData(ui->languageBox->currentIndex()).toString(); - - if (newLanguage != oldLanguage) { - settings().interface().setLanguage(newLanguage); - emit settings().languageChanged(newLanguage); - } - - // download list - settings().interface().setCompactDownloads(ui->compactBox->isChecked()); - settings().interface().setMetaDownloads(ui->showMetaBox->isChecked()); - settings().interface().setHideDownloadsAfterInstallation( - ui->hideDownloadInstallBox->isChecked()); - - // Update settings are persisted by UpdatesSettingsTab (own tab). - - // profile defaults - settings().setProfileLocalInis(ui->localINIs->isChecked()); - settings().setProfileLocalSaves(ui->localSaves->isChecked()); - settings().setProfileArchiveInvalidation( - ui->automaticArchiveInvalidation->isChecked()); - - // miscellaneous - settings().geometry().setCenterDialogs(ui->centerDialogs->isChecked()); - settings().interface().setShowChangeGameConfirmation( - ui->changeGameConfirmation->isChecked()); - settings().interface().setShowMenubarOnAlt(ui->showMenubarOnAlt->isChecked()); - settings().interface().setDoubleClicksOpenPreviews( - ui->doubleClickPreviews->isChecked()); -} - -void GeneralSettingsTab::addLanguages() -{ - // matches the end of filenames for something like "_en.qm" or "_zh_CN.qm" - const QString pattern = QString::fromStdWString(AppConfig::translationPrefix()) + - "_([a-z]{2,3}(_[A-Z]{2,2})?).qm"; - - const QRegularExpression exp(QRegularExpression::anchoredPattern(pattern)); - - QDirIterator iter(QCoreApplication::applicationDirPath() + "/translations", - QDir::Files); - - std::vector> languages; - - while (iter.hasNext()) { - iter.next(); - - const QString file = iter.fileName(); - auto match = exp.match(file); - if (!match.hasMatch()) { - continue; - } - - const QString languageCode = match.captured(1); - const QLocale locale(languageCode); - - QString languageString = QString("%1 (%2)") - .arg(locale.nativeLanguageName()) - .arg(locale.nativeTerritoryName()); - - if (locale.language() == QLocale::Chinese) { - if (languageCode == "zh_TW") { - languageString = "Chinese (Traditional)"; - } else { - languageString = "Chinese (Simplified)"; - } - } - - languages.push_back({languageString, match.captured(1)}); - } - - if (!ui->languageBox->findText("English")) { - languages.push_back({QString("English"), QString("en_US")}); - } - - std::sort(languages.begin(), languages.end()); - - for (const auto& lang : languages) { - ui->languageBox->addItem(lang.first, lang.second); - } -} - -void GeneralSettingsTab::selectLanguage() -{ - QString languageCode = settings().interface().language(); - int currentID = ui->languageBox->findData(languageCode); - // I made a mess. :( Most languages are stored with only the iso country - // code (2 characters like "de") but chinese - // with the exact language variant (zh_TW) so I have to search for both - // variants - if (currentID == -1) { - currentID = ui->languageBox->findData(languageCode.mid(0, 2)); - } - if (currentID != -1) { - ui->languageBox->setCurrentIndex(currentID); - } -} - -void GeneralSettingsTab::resetDialogs() -{ - settings().widgets().resetQuestionButtons(); - GlobalSettings::resetDialogs(); -} - -void GeneralSettingsTab::onEditCategories() -{ - CategoriesDialog catDialog(&dialog()); - - if (catDialog.exec() == QDialog::Accepted) { - catDialog.commitChanges(); - } -} - -void GeneralSettingsTab::onResetDialogs() -{ - const auto r = QMessageBox::question( - parentWidget(), QObject::tr("Confirm?"), - QObject::tr( - "This will reset all the choices you made to dialogs and make them all " - "visible again. Continue?"), - QMessageBox::Yes | QMessageBox::No); - - if (r == QMessageBox::Yes) { - resetDialogs(); - } -} +#include "settingsdialoggeneral.h" +#include "categoriesdialog.h" +#include "colortable.h" +#include "shared/appconfig.h" +#include "ui_settingsdialog.h" +#include +#include + +using namespace MOBase; + +GeneralSettingsTab::GeneralSettingsTab(Settings& s, SettingsDialog& d) + : SettingsTab(s, d) +{ + // language + addLanguages(); + selectLanguage(); + + // download list + ui->compactBox->setChecked(settings().interface().compactDownloads()); + ui->showMetaBox->setChecked(settings().interface().metaDownloads()); + ui->hideDownloadInstallBox->setChecked( + settings().interface().hideDownloadsAfterInstallation()); + + // Update settings have moved to their own "Updates" tab. Hide the + // legacy controls here so the two UIs don't contradict each other. + ui->checkForUpdates->hide(); + ui->usePrereleaseBox->hide(); + + // profile defaults + ui->localINIs->setChecked(settings().profileLocalInis()); + ui->localSaves->setChecked(settings().profileLocalSaves()); + ui->automaticArchiveInvalidation->setChecked(settings().profileArchiveInvalidation()); + + // miscellaneous + ui->centerDialogs->setChecked(settings().geometry().centerDialogs()); + ui->changeGameConfirmation->setChecked( + settings().interface().showChangeGameConfirmation()); + ui->showMenubarOnAlt->setChecked(settings().interface().showMenubarOnAlt()); + ui->doubleClickPreviews->setChecked( + settings().interface().doubleClicksOpenPreviews()); + + QObject::connect(ui->categoriesBtn, &QPushButton::clicked, [&] { + onEditCategories(); + }); + + QObject::connect(ui->resetDialogsButton, &QPushButton::clicked, [&] { + onResetDialogs(); + }); +} + +void GeneralSettingsTab::update() +{ + // language + const QString oldLanguage = settings().interface().language(); + const QString newLanguage = + ui->languageBox->itemData(ui->languageBox->currentIndex()).toString(); + + if (newLanguage != oldLanguage) { + settings().interface().setLanguage(newLanguage); + emit settings().languageChanged(newLanguage); + } + + // download list + settings().interface().setCompactDownloads(ui->compactBox->isChecked()); + settings().interface().setMetaDownloads(ui->showMetaBox->isChecked()); + settings().interface().setHideDownloadsAfterInstallation( + ui->hideDownloadInstallBox->isChecked()); + + // Update settings are persisted by UpdatesSettingsTab (own tab). + + // profile defaults + settings().setProfileLocalInis(ui->localINIs->isChecked()); + settings().setProfileLocalSaves(ui->localSaves->isChecked()); + settings().setProfileArchiveInvalidation( + ui->automaticArchiveInvalidation->isChecked()); + + // miscellaneous + settings().geometry().setCenterDialogs(ui->centerDialogs->isChecked()); + settings().interface().setShowChangeGameConfirmation( + ui->changeGameConfirmation->isChecked()); + settings().interface().setShowMenubarOnAlt(ui->showMenubarOnAlt->isChecked()); + settings().interface().setDoubleClicksOpenPreviews( + ui->doubleClickPreviews->isChecked()); +} + +void GeneralSettingsTab::addLanguages() +{ + // matches the end of filenames for something like "_en.qm" or "_zh_CN.qm" + const QString pattern = QString::fromStdWString(AppConfig::translationPrefix()) + + "_([a-z]{2,3}(_[A-Z]{2,2})?).qm"; + + const QRegularExpression exp(QRegularExpression::anchoredPattern(pattern)); + + QDirIterator iter(QCoreApplication::applicationDirPath() + "/translations", + QDir::Files); + + std::vector> languages; + + while (iter.hasNext()) { + iter.next(); + + const QString file = iter.fileName(); + auto match = exp.match(file); + if (!match.hasMatch()) { + continue; + } + + const QString languageCode = match.captured(1); + const QLocale locale(languageCode); + + QString languageString = QString("%1 (%2)") + .arg(locale.nativeLanguageName()) + .arg(locale.nativeTerritoryName()); + + if (locale.language() == QLocale::Chinese) { + if (languageCode == "zh_TW") { + languageString = "Chinese (Traditional)"; + } else { + languageString = "Chinese (Simplified)"; + } + } + + languages.emplace_back(languageString, match.captured(1)); + } + + if (!ui->languageBox->findText("English")) { + languages.emplace_back(QString("English"), QString("en_US")); + } + + std::sort(languages.begin(), languages.end()); + + for (const auto& lang : languages) { + ui->languageBox->addItem(lang.first, lang.second); + } +} + +void GeneralSettingsTab::selectLanguage() +{ + QString languageCode = settings().interface().language(); + int currentID = ui->languageBox->findData(languageCode); + // I made a mess. :( Most languages are stored with only the iso country + // code (2 characters like "de") but chinese + // with the exact language variant (zh_TW) so I have to search for both + // variants + if (currentID == -1) { + currentID = ui->languageBox->findData(languageCode.mid(0, 2)); + } + if (currentID != -1) { + ui->languageBox->setCurrentIndex(currentID); + } +} + +void GeneralSettingsTab::resetDialogs() +{ + settings().widgets().resetQuestionButtons(); + GlobalSettings::resetDialogs(); +} + +void GeneralSettingsTab::onEditCategories() +{ + CategoriesDialog catDialog(&dialog()); + + if (catDialog.exec() == QDialog::Accepted) { + catDialog.commitChanges(); + } +} + +void GeneralSettingsTab::onResetDialogs() +{ + const auto r = QMessageBox::question( + parentWidget(), QObject::tr("Confirm?"), + QObject::tr( + "This will reset all the choices you made to dialogs and make them all " + "visible again. Continue?"), + QMessageBox::Yes | QMessageBox::No); + + if (r == QMessageBox::Yes) { + resetDialogs(); + } +} diff --git a/src/src/settingsdialogmodlist.cpp b/src/src/settingsdialogmodlist.cpp index b46f1c4..de38217 100644 --- a/src/src/settingsdialogmodlist.cpp +++ b/src/src/settingsdialogmodlist.cpp @@ -1,85 +1,85 @@ -#include "settingsdialogmodlist.h" -#include "categoriesdialog.h" -#include "colortable.h" -#include "modlist.h" -#include "shared/appconfig.h" -#include "ui_settingsdialog.h" -#include -#include - -using namespace MOBase; - -ModListSettingsTab::ModListSettingsTab(Settings& s, SettingsDialog& d) - : SettingsTab(s, d), - m_columnToBox{ - {ModList::COL_CONFLICTFLAGS, ui->collapsibleSeparatorsIconsConflictsBox}, - {ModList::COL_FLAGS, ui->collapsibleSeparatorsIconsFlagsBox}, - {ModList::COL_CONTENT, ui->collapsibleSeparatorsIconsContentsBox}, - {ModList::COL_VERSION, ui->collapsibleSeparatorsIconsVersionBox}} -{ - // connect before setting to trigger - QObject::connect(ui->collapsibleSeparatorsAscBox, &QCheckBox::toggled, [=, this] { - updateCollapsibleSeparatorsGroup(); - }); - QObject::connect(ui->collapsibleSeparatorsDscBox, &QCheckBox::toggled, [=, this] { - updateCollapsibleSeparatorsGroup(); - }); - - ui->colorSeparatorsBox->setChecked(settings().colors().colorSeparatorScrollbar()); - ui->displayForeignBox->setChecked(settings().interface().displayForeign()); - ui->collapsibleSeparatorsAscBox->setChecked( - settings().interface().collapsibleSeparators(Qt::AscendingOrder)); - ui->collapsibleSeparatorsDscBox->setChecked( - settings().interface().collapsibleSeparators(Qt::DescendingOrder)); - ui->collapsibleSeparatorsHighlightFromBox->setChecked( - settings().interface().collapsibleSeparatorsHighlightFrom()); - ui->collapsibleSeparatorsHighlightToBox->setChecked( - settings().interface().collapsibleSeparatorsHighlightTo()); - ui->collapsibleSeparatorsPerProfileBox->setChecked( - settings().interface().collapsibleSeparatorsPerProfile()); - ui->saveFiltersBox->setChecked(settings().interface().saveFilters()); - ui->autoCollapseDelayBox->setChecked(settings().interface().autoCollapseOnHover()); - ui->checkUpdateInstallBox->setChecked( - settings().interface().checkUpdateAfterInstallation()); - - for (auto& p : m_columnToBox) { - p.second->setChecked(settings().interface().collapsibleSeparatorsIcons(p.first)); - } -} - -void ModListSettingsTab::update() -{ - // mod list - settings().colors().setColorSeparatorScrollbar(ui->colorSeparatorsBox->isChecked()); - settings().interface().setDisplayForeign(ui->displayForeignBox->isChecked()); - settings().interface().setCollapsibleSeparators( - ui->collapsibleSeparatorsAscBox->isChecked(), - ui->collapsibleSeparatorsDscBox->isChecked()); - settings().interface().setCollapsibleSeparatorsHighlightFrom( - ui->collapsibleSeparatorsHighlightFromBox->isChecked()); - settings().interface().setCollapsibleSeparatorsHighlightTo( - ui->collapsibleSeparatorsHighlightToBox->isChecked()); - settings().interface().setCollapsibleSeparatorsPerProfile( - ui->collapsibleSeparatorsPerProfileBox->isChecked()); - settings().interface().setSaveFilters(ui->saveFiltersBox->isChecked()); - settings().interface().setAutoCollapseOnHover(ui->autoCollapseDelayBox->isChecked()); - settings().interface().setCheckUpdateAfterInstallation( - ui->checkUpdateInstallBox->isChecked()); - - for (auto& p : m_columnToBox) { - settings().interface().setCollapsibleSeparatorsIcons(p.first, - p.second->isChecked()); - } -} - -void ModListSettingsTab::updateCollapsibleSeparatorsGroup() -{ - const auto checked = ui->collapsibleSeparatorsAscBox->isChecked() || - ui->collapsibleSeparatorsDscBox->isChecked(); - for (auto* widget : ui->collapsibleSeparatorsWidget->findChildren()) { - widget->setEnabled(checked); - } - ui->collapsibleSeparatorsLabel->setEnabled(true); - ui->collapsibleSeparatorsAscBox->setEnabled(true); - ui->collapsibleSeparatorsDscBox->setEnabled(true); -} +#include "settingsdialogmodlist.h" +#include "categoriesdialog.h" +#include "colortable.h" +#include "modlist.h" +#include "shared/appconfig.h" +#include "ui_settingsdialog.h" +#include +#include + +using namespace MOBase; + +ModListSettingsTab::ModListSettingsTab(Settings& s, SettingsDialog& d) + : SettingsTab(s, d), + m_columnToBox{ + {ModList::COL_CONFLICTFLAGS, ui->collapsibleSeparatorsIconsConflictsBox}, + {ModList::COL_FLAGS, ui->collapsibleSeparatorsIconsFlagsBox}, + {ModList::COL_CONTENT, ui->collapsibleSeparatorsIconsContentsBox}, + {ModList::COL_VERSION, ui->collapsibleSeparatorsIconsVersionBox}} +{ + // connect before setting to trigger + QObject::connect(ui->collapsibleSeparatorsAscBox, &QCheckBox::toggled, [=, this] { + updateCollapsibleSeparatorsGroup(); + }); + QObject::connect(ui->collapsibleSeparatorsDscBox, &QCheckBox::toggled, [=, this] { + updateCollapsibleSeparatorsGroup(); + }); + + ui->colorSeparatorsBox->setChecked(settings().colors().colorSeparatorScrollbar()); + ui->displayForeignBox->setChecked(settings().interface().displayForeign()); + ui->collapsibleSeparatorsAscBox->setChecked( + settings().interface().collapsibleSeparators(Qt::AscendingOrder)); + ui->collapsibleSeparatorsDscBox->setChecked( + settings().interface().collapsibleSeparators(Qt::DescendingOrder)); + ui->collapsibleSeparatorsHighlightFromBox->setChecked( + settings().interface().collapsibleSeparatorsHighlightFrom()); + ui->collapsibleSeparatorsHighlightToBox->setChecked( + settings().interface().collapsibleSeparatorsHighlightTo()); + ui->collapsibleSeparatorsPerProfileBox->setChecked( + settings().interface().collapsibleSeparatorsPerProfile()); + ui->saveFiltersBox->setChecked(settings().interface().saveFilters()); + ui->autoCollapseDelayBox->setChecked(settings().interface().autoCollapseOnHover()); + ui->checkUpdateInstallBox->setChecked( + settings().interface().checkUpdateAfterInstallation()); + + for (const auto& p : m_columnToBox) { + p.second->setChecked(settings().interface().collapsibleSeparatorsIcons(p.first)); + } +} + +void ModListSettingsTab::update() +{ + // mod list + settings().colors().setColorSeparatorScrollbar(ui->colorSeparatorsBox->isChecked()); + settings().interface().setDisplayForeign(ui->displayForeignBox->isChecked()); + settings().interface().setCollapsibleSeparators( + ui->collapsibleSeparatorsAscBox->isChecked(), + ui->collapsibleSeparatorsDscBox->isChecked()); + settings().interface().setCollapsibleSeparatorsHighlightFrom( + ui->collapsibleSeparatorsHighlightFromBox->isChecked()); + settings().interface().setCollapsibleSeparatorsHighlightTo( + ui->collapsibleSeparatorsHighlightToBox->isChecked()); + settings().interface().setCollapsibleSeparatorsPerProfile( + ui->collapsibleSeparatorsPerProfileBox->isChecked()); + settings().interface().setSaveFilters(ui->saveFiltersBox->isChecked()); + settings().interface().setAutoCollapseOnHover(ui->autoCollapseDelayBox->isChecked()); + settings().interface().setCheckUpdateAfterInstallation( + ui->checkUpdateInstallBox->isChecked()); + + for (const auto& p : m_columnToBox) { + settings().interface().setCollapsibleSeparatorsIcons(p.first, + p.second->isChecked()); + } +} + +void ModListSettingsTab::updateCollapsibleSeparatorsGroup() +{ + const auto checked = ui->collapsibleSeparatorsAscBox->isChecked() || + ui->collapsibleSeparatorsDscBox->isChecked(); + for (auto* widget : ui->collapsibleSeparatorsWidget->findChildren()) { + widget->setEnabled(checked); + } + ui->collapsibleSeparatorsLabel->setEnabled(true); + ui->collapsibleSeparatorsAscBox->setEnabled(true); + ui->collapsibleSeparatorsDscBox->setEnabled(true); +} diff --git a/src/src/settingsdialogplugins.cpp b/src/src/settingsdialogplugins.cpp index fabef4c..1fddff3 100644 --- a/src/src/settingsdialogplugins.cpp +++ b/src/src/settingsdialogplugins.cpp @@ -1,394 +1,394 @@ -#include "settingsdialogplugins.h" -#include "noeditdelegate.h" -#include "ui_settingsdialog.h" -#include - -#include "disableproxyplugindialog.h" -#include "organizercore.h" -#include "plugincontainer.h" - -using namespace MOBase; - -PluginsSettingsTab::PluginsSettingsTab(Settings& s, PluginContainer* pluginContainer, - SettingsDialog& d) - : SettingsTab(s, d), m_pluginContainer(pluginContainer) -{ - ui->pluginSettingsList->setStyleSheet("QTreeWidget::item {padding-right: 10px;}"); - - // Create top-level tree widget: - QStringList pluginInterfaces = m_pluginContainer->pluginInterfaces(); - pluginInterfaces.sort(Qt::CaseInsensitive); - std::map topItems; - for (QString interfaceName : pluginInterfaces) { - auto* item = new QTreeWidgetItem(ui->pluginsList, {interfaceName}); - item->setFlags(item->flags() & ~Qt::ItemIsSelectable); - auto font = item->font(0); - font.setBold(true); - item->setFont(0, font); - topItems[interfaceName] = item; - item->setExpanded(true); - item->setFlags(item->flags() & ~Qt::ItemIsSelectable); - } - ui->pluginsList->setHeaderHidden(true); - - // display plugin settings - QSet handledNames; - for (IPlugin* plugin : settings().plugins().plugins()) { - if (handledNames.contains(plugin->name()) || - m_pluginContainer->requirements(plugin).master()) { - continue; - } - - QTreeWidgetItem* listItem = new QTreeWidgetItem( - topItems.at(m_pluginContainer->topImplementedInterface(plugin))); - listItem->setData(0, Qt::DisplayRole, plugin->localizedName()); - listItem->setData(0, PluginRole, QVariant::fromValue((void*)plugin)); - listItem->setData(0, SettingsRole, settings().plugins().settings(plugin->name())); - listItem->setData(0, DescriptionsRole, - settings().plugins().descriptions(plugin->name())); - - // Handle child item: - auto children = m_pluginContainer->requirements(plugin).children(); - for (auto* child : children) { - QTreeWidgetItem* childItem = new QTreeWidgetItem(listItem); - childItem->setData(0, Qt::DisplayRole, child->localizedName()); - childItem->setData(0, PluginRole, QVariant::fromValue((void*)child)); - childItem->setData(0, SettingsRole, settings().plugins().settings(child->name())); - childItem->setData(0, DescriptionsRole, - settings().plugins().descriptions(child->name())); - - handledNames.insert(child->name()); - } - - handledNames.insert(plugin->name()); - } - - for (auto& [k, item] : topItems) { - if (item->childCount() == 0) { - item->setHidden(true); - } - } - - ui->pluginsList->sortByColumn(0, Qt::AscendingOrder); - - // display plugin blacklist - for (const QString& pluginName : settings().plugins().blacklist()) { - ui->pluginBlacklist->addItem(pluginName); - } - - m_filter.setEdit(ui->pluginFilterEdit); - - QObject::connect(ui->pluginsList, &QTreeWidget::currentItemChanged, - [&](auto* current, auto* previous) { - on_pluginsList_currentItemChanged(current, previous); - }); - QObject::connect(ui->enabledCheckbox, &QCheckBox::clicked, [&](bool checked) { - on_checkboxEnabled_clicked(checked); - }); - - QShortcut* delShortcut = - new QShortcut(QKeySequence(Qt::Key_Delete), ui->pluginBlacklist); - QObject::connect(delShortcut, &QShortcut::activated, &dialog(), [&] { - deleteBlacklistItem(); - }); - QObject::connect(&m_filter, &FilterWidget::changed, [&] { - filterPluginList(); - }); - - updateListItems(); - filterPluginList(); -} - -void PluginsSettingsTab::updateListItems() -{ - for (auto i = 0; i < ui->pluginsList->topLevelItemCount(); ++i) { - auto* topLevelItem = ui->pluginsList->topLevelItem(i); - for (auto j = 0; j < topLevelItem->childCount(); ++j) { - auto* item = topLevelItem->child(j); - auto* plugin = this->plugin(item); - - bool inactive = !m_pluginContainer->implementInterface(plugin) && - !m_pluginContainer->isEnabled(plugin); - - auto font = item->font(0); - font.setItalic(inactive); - item->setFont(0, font); - for (auto k = 0; k < item->childCount(); ++k) { - item->child(k)->setFont(0, font); - } - } - } -} - -void PluginsSettingsTab::filterPluginList() -{ - auto selectedItems = ui->pluginsList->selectedItems(); - QTreeWidgetItem* firstNotHidden = nullptr; - - for (auto i = 0; i < ui->pluginsList->topLevelItemCount(); ++i) { - auto* topLevelItem = ui->pluginsList->topLevelItem(i); - - bool found = false; - for (auto j = 0; j < topLevelItem->childCount(); ++j) { - auto* item = topLevelItem->child(j); - auto* plugin = this->plugin(item); - - // Check the item or the child - If any match (item or child), the whole - // group is displayed. - bool match = m_filter.matches([plugin](const QRegularExpression& regex) { - return regex.match(plugin->localizedName()).hasMatch(); - }); - for (auto* child : m_pluginContainer->requirements(plugin).children()) { - match = match || m_filter.matches([child](const QRegularExpression& regex) { - return regex.match(child->localizedName()).hasMatch(); - }); - } - - if (match) { - found = true; - item->setHidden(false); - - if (firstNotHidden == nullptr) { - firstNotHidden = item; - } - } else { - item->setHidden(true); - } - } - - topLevelItem->setHidden(!found); - } - - // Unselect item if hidden: - if (firstNotHidden) { - ui->pluginDescription->setVisible(true); - ui->pluginSettingsList->setVisible(true); - ui->noPluginLabel->setVisible(false); - if (selectedItems.isEmpty()) { - ui->pluginsList->setCurrentItem(firstNotHidden); - } else if (selectedItems[0]->isHidden()) { - ui->pluginsList->setCurrentItem(firstNotHidden); - } - } else { - ui->pluginDescription->setVisible(false); - ui->pluginSettingsList->setVisible(false); - ui->noPluginLabel->setVisible(true); - } -} - -IPlugin* PluginsSettingsTab::plugin(QTreeWidgetItem* pluginItem) -{ - return static_cast(qvariant_cast(pluginItem->data(0, PluginRole))); -} - -void PluginsSettingsTab::update() -{ - // transfer plugin settings to in-memory structure - for (int i = 0; i < ui->pluginsList->topLevelItemCount(); ++i) { - auto* topLevelItem = ui->pluginsList->topLevelItem(i); - for (int j = 0; j < topLevelItem->childCount(); ++j) { - auto* item = topLevelItem->child(j); - settings().plugins().setSettings(plugin(item)->name(), - item->data(0, SettingsRole).toMap()); - } - } - - // set plugin blacklist - QStringList names; - for (QListWidgetItem* item : ui->pluginBlacklist->findItems("*", Qt::MatchWildcard)) { - names.push_back(item->text()); - } - - settings().plugins().setBlacklist(names); - - settings().plugins().save(); -} - -void PluginsSettingsTab::closing() -{ - storeSettings(ui->pluginsList->currentItem()); -} - -void PluginsSettingsTab::on_checkboxEnabled_clicked(bool checked) -{ - // Retrieve the plugin: - auto* item = ui->pluginsList->currentItem(); - if (!item || !item->data(0, PluginRole).isValid()) { - return; - } - IPlugin* plugin = this->plugin(item); - const auto& requirements = m_pluginContainer->requirements(plugin); - - // User wants to enable: - if (checked) { - m_pluginContainer->setEnabled(plugin, true, false); - } else { - // Custom check for proxy + current game: - if (m_pluginContainer->implementInterface(plugin)) { - - // Current game: - auto* game = m_pluginContainer->managedGame(); - if (m_pluginContainer->requirements(game).proxy() == plugin) { - QMessageBox::warning(parentWidget(), QObject::tr("Cannot disable plugin"), - QObject::tr("The '%1' plugin is used by the current game " - "plugin and cannot disabled.") - .arg(plugin->localizedName()), - QMessageBox::Ok); - ui->enabledCheckbox->setChecked(true); - return; - } - - // Check the proxied plugins: - auto proxied = requirements.proxied(); - if (!proxied.empty()) { - DisableProxyPluginDialog dialog(plugin, proxied, parentWidget()); - if (dialog.exec() != QDialog::Accepted) { - ui->enabledCheckbox->setChecked(true); - return; - } - } - } - - // Check if the plugins is required for other plugins: - auto requiredFor = requirements.requiredFor(); - if (!requiredFor.empty()) { - QStringList pluginNames; - for (auto& p : requiredFor) { - pluginNames.append(p->localizedName()); - } - pluginNames.sort(); - QString message = - QObject::tr("

            Disabling the '%1' plugin will also disable the following " - "plugins:

              %1

            Do you want to continue?

            ") - .arg(plugin->localizedName()) - .arg("
          • " + pluginNames.join("
          • ") + "
          • "); - if (QMessageBox::warning(parentWidget(), QObject::tr("Really disable plugin?"), - message, - QMessageBox::Yes | QMessageBox::No) == QMessageBox::No) { - ui->enabledCheckbox->setChecked(true); - return; - } - } - m_pluginContainer->setEnabled(plugin, false, true); - } - - // Proxy was disabled / enabled, need restart: - if (m_pluginContainer->implementInterface(plugin)) { - dialog().setExitNeeded(Exit::Restart); - } - - // Panel plugins swap UI tabs at init and cannot be hot-unloaded. - static const QStringList panelPluginNames = { - QStringLiteral("Bethesda Plugin Manager"), - }; - if (panelPluginNames.contains(plugin->name())) { - QMessageBox::information( - parentWidget(), QObject::tr("Restart required"), - QObject::tr("The '%1' plugin manages a UI panel. Restart Fluorine Manager " - "for the change to take effect.") - .arg(plugin->localizedName()), - QMessageBox::Ok); - dialog().setExitNeeded(Exit::Restart); - } - - updateListItems(); -} - -void PluginsSettingsTab::on_pluginsList_currentItemChanged(QTreeWidgetItem* current, - QTreeWidgetItem* previous) -{ - storeSettings(previous); - - if (!current->data(0, PluginRole).isValid()) { - return; - } - - ui->pluginSettingsList->clear(); - IPlugin* plugin = this->plugin(current); - ui->authorLabel->setText(plugin->author()); - ui->versionLabel->setText(plugin->version().canonicalString()); - ui->descriptionLabel->setText(plugin->description()); - - // Checkbox, do not show for children or game plugins, disable - // if the plugin cannot be enabled. - ui->enabledCheckbox->setVisible( - !m_pluginContainer->implementInterface(plugin) && - plugin->master().isEmpty()); - - bool enabled = m_pluginContainer->isEnabled(plugin); - auto& requirements = m_pluginContainer->requirements(plugin); - auto problems = requirements.problems(); - - if (m_pluginContainer->requirements(plugin).isCorePlugin()) { - ui->enabledCheckbox->setDisabled(true); - ui->enabledCheckbox->setToolTip( - QObject::tr("This plugin is required for Mod Organizer to work properly and " - "cannot be disabled.")); - } - // Plugin is enable or can be enabled. - else if (enabled || problems.empty()) { - ui->enabledCheckbox->setDisabled(false); - ui->enabledCheckbox->setToolTip(""); - ui->enabledCheckbox->setChecked(enabled); - } - // Plugin is disable and cannot be enabled. - else { - ui->enabledCheckbox->setDisabled(true); - ui->enabledCheckbox->setChecked(false); - if (problems.size() == 1) { - ui->enabledCheckbox->setToolTip(problems[0].shortDescription()); - } else { - QStringList descriptions; - for (auto& problem : problems) { - descriptions.append(problem.shortDescription()); - } - ui->enabledCheckbox->setToolTip("
            • " + descriptions.join("
            • ") + - "
            "); - } - } - - QVariantMap settings = current->data(0, SettingsRole).toMap(); - QVariantMap descriptions = current->data(0, DescriptionsRole).toMap(); - ui->pluginSettingsList->setEnabled(settings.count() != 0); - for (auto iter = settings.begin(); iter != settings.end(); ++iter) { - QTreeWidgetItem* newItem = new QTreeWidgetItem(QStringList(iter.key())); - QVariant value = *iter; - QString description; - { - auto descriptionIter = descriptions.find(iter.key()); - if (descriptionIter != descriptions.end()) { - description = descriptionIter->toString(); - } - } - - ui->pluginSettingsList->setItemDelegateForColumn(0, new NoEditDelegate()); - newItem->setData(1, Qt::DisplayRole, value); - newItem->setData(1, Qt::EditRole, value); - newItem->setToolTip(1, description); - - newItem->setFlags(newItem->flags() | Qt::ItemIsEditable); - ui->pluginSettingsList->addTopLevelItem(newItem); - } - - ui->pluginSettingsList->resizeColumnToContents(0); - ui->pluginSettingsList->resizeColumnToContents(1); -} - -void PluginsSettingsTab::deleteBlacklistItem() -{ - ui->pluginBlacklist->takeItem(ui->pluginBlacklist->currentIndex().row()); -} - -void PluginsSettingsTab::storeSettings(QTreeWidgetItem* pluginItem) -{ - if (pluginItem != nullptr && pluginItem->data(0, PluginRole).isValid()) { - QVariantMap settings = pluginItem->data(0, SettingsRole).toMap(); - - for (int i = 0; i < ui->pluginSettingsList->topLevelItemCount(); ++i) { - const QTreeWidgetItem* item = ui->pluginSettingsList->topLevelItem(i); - settings[item->text(0)] = item->data(1, Qt::DisplayRole); - } - - pluginItem->setData(0, SettingsRole, settings); - } -} +#include "settingsdialogplugins.h" +#include "noeditdelegate.h" +#include "ui_settingsdialog.h" +#include + +#include "disableproxyplugindialog.h" +#include "organizercore.h" +#include "plugincontainer.h" + +using namespace MOBase; + +PluginsSettingsTab::PluginsSettingsTab(Settings& s, PluginContainer* pluginContainer, + SettingsDialog& d) + : SettingsTab(s, d), m_pluginContainer(pluginContainer) +{ + ui->pluginSettingsList->setStyleSheet("QTreeWidget::item {padding-right: 10px;}"); + + // Create top-level tree widget: + QStringList pluginInterfaces = PluginContainer::pluginInterfaces(); + pluginInterfaces.sort(Qt::CaseInsensitive); + std::map topItems; + for (QString interfaceName : pluginInterfaces) { + auto* item = new QTreeWidgetItem(ui->pluginsList, {interfaceName}); + item->setFlags(item->flags() & ~Qt::ItemIsSelectable); + auto font = item->font(0); + font.setBold(true); + item->setFont(0, font); + topItems[interfaceName] = item; + item->setExpanded(true); + item->setFlags(item->flags() & ~Qt::ItemIsSelectable); + } + ui->pluginsList->setHeaderHidden(true); + + // display plugin settings + QSet handledNames; + for (IPlugin* plugin : settings().plugins().plugins()) { + if (handledNames.contains(plugin->name()) || + m_pluginContainer->requirements(plugin).master()) { + continue; + } + + QTreeWidgetItem* listItem = new QTreeWidgetItem( + topItems.at(m_pluginContainer->topImplementedInterface(plugin))); + listItem->setData(0, Qt::DisplayRole, plugin->localizedName()); + listItem->setData(0, PluginRole, QVariant::fromValue((void*)plugin)); + listItem->setData(0, SettingsRole, settings().plugins().settings(plugin->name())); + listItem->setData(0, DescriptionsRole, + settings().plugins().descriptions(plugin->name())); + + // Handle child item: + auto children = m_pluginContainer->requirements(plugin).children(); + for (auto* child : children) { + QTreeWidgetItem* childItem = new QTreeWidgetItem(listItem); + childItem->setData(0, Qt::DisplayRole, child->localizedName()); + childItem->setData(0, PluginRole, QVariant::fromValue((void*)child)); + childItem->setData(0, SettingsRole, settings().plugins().settings(child->name())); + childItem->setData(0, DescriptionsRole, + settings().plugins().descriptions(child->name())); + + handledNames.insert(child->name()); + } + + handledNames.insert(plugin->name()); + } + + for (auto& [k, item] : topItems) { + if (item->childCount() == 0) { + item->setHidden(true); + } + } + + ui->pluginsList->sortByColumn(0, Qt::AscendingOrder); + + // display plugin blacklist + for (const QString& pluginName : settings().plugins().blacklist()) { + ui->pluginBlacklist->addItem(pluginName); + } + + m_filter.setEdit(ui->pluginFilterEdit); + + QObject::connect(ui->pluginsList, &QTreeWidget::currentItemChanged, + [&](auto* current, auto* previous) { + on_pluginsList_currentItemChanged(current, previous); + }); + QObject::connect(ui->enabledCheckbox, &QCheckBox::clicked, [&](bool checked) { + on_checkboxEnabled_clicked(checked); + }); + + QShortcut* delShortcut = + new QShortcut(QKeySequence(Qt::Key_Delete), ui->pluginBlacklist); + QObject::connect(delShortcut, &QShortcut::activated, &dialog(), [&] { + deleteBlacklistItem(); + }); + QObject::connect(&m_filter, &FilterWidget::changed, [&] { + filterPluginList(); + }); + + updateListItems(); + filterPluginList(); +} + +void PluginsSettingsTab::updateListItems() +{ + for (auto i = 0; i < ui->pluginsList->topLevelItemCount(); ++i) { + auto* topLevelItem = ui->pluginsList->topLevelItem(i); + for (auto j = 0; j < topLevelItem->childCount(); ++j) { + auto* item = topLevelItem->child(j); + auto* plugin = PluginsSettingsTab::plugin(item); + + bool inactive = !m_pluginContainer->implementInterface(plugin) && + !m_pluginContainer->isEnabled(plugin); + + auto font = item->font(0); + font.setItalic(inactive); + item->setFont(0, font); + for (auto k = 0; k < item->childCount(); ++k) { + item->child(k)->setFont(0, font); + } + } + } +} + +void PluginsSettingsTab::filterPluginList() +{ + auto selectedItems = ui->pluginsList->selectedItems(); + QTreeWidgetItem* firstNotHidden = nullptr; + + for (auto i = 0; i < ui->pluginsList->topLevelItemCount(); ++i) { + auto* topLevelItem = ui->pluginsList->topLevelItem(i); + + bool found = false; + for (auto j = 0; j < topLevelItem->childCount(); ++j) { + auto* item = topLevelItem->child(j); + auto* plugin = PluginsSettingsTab::plugin(item); + + // Check the item or the child - If any match (item or child), the whole + // group is displayed. + bool match = m_filter.matches([plugin](const QRegularExpression& regex) { + return regex.match(plugin->localizedName()).hasMatch(); + }); + for (auto* child : m_pluginContainer->requirements(plugin).children()) { + match = match || m_filter.matches([child](const QRegularExpression& regex) { + return regex.match(child->localizedName()).hasMatch(); + }); + } + + if (match) { + found = true; + item->setHidden(false); + + if (firstNotHidden == nullptr) { + firstNotHidden = item; + } + } else { + item->setHidden(true); + } + } + + topLevelItem->setHidden(!found); + } + + // Unselect item if hidden: + if (firstNotHidden) { + ui->pluginDescription->setVisible(true); + ui->pluginSettingsList->setVisible(true); + ui->noPluginLabel->setVisible(false); + if (selectedItems.isEmpty()) { + ui->pluginsList->setCurrentItem(firstNotHidden); + } else if (selectedItems[0]->isHidden()) { + ui->pluginsList->setCurrentItem(firstNotHidden); + } + } else { + ui->pluginDescription->setVisible(false); + ui->pluginSettingsList->setVisible(false); + ui->noPluginLabel->setVisible(true); + } +} + +IPlugin* PluginsSettingsTab::plugin(QTreeWidgetItem* pluginItem) +{ + return static_cast(qvariant_cast(pluginItem->data(0, PluginRole))); +} + +void PluginsSettingsTab::update() +{ + // transfer plugin settings to in-memory structure + for (int i = 0; i < ui->pluginsList->topLevelItemCount(); ++i) { + auto* topLevelItem = ui->pluginsList->topLevelItem(i); + for (int j = 0; j < topLevelItem->childCount(); ++j) { + auto* item = topLevelItem->child(j); + settings().plugins().setSettings(plugin(item)->name(), + item->data(0, SettingsRole).toMap()); + } + } + + // set plugin blacklist + QStringList names; + for (QListWidgetItem* item : ui->pluginBlacklist->findItems("*", Qt::MatchWildcard)) { + names.push_back(item->text()); + } + + settings().plugins().setBlacklist(names); + + settings().plugins().save(); +} + +void PluginsSettingsTab::closing() +{ + storeSettings(ui->pluginsList->currentItem()); +} + +void PluginsSettingsTab::on_checkboxEnabled_clicked(bool checked) +{ + // Retrieve the plugin: + auto* item = ui->pluginsList->currentItem(); + if (!item || !item->data(0, PluginRole).isValid()) { + return; + } + IPlugin* plugin = PluginsSettingsTab::plugin(item); + const auto& requirements = m_pluginContainer->requirements(plugin); + + // User wants to enable: + if (checked) { + m_pluginContainer->setEnabled(plugin, true, false); + } else { + // Custom check for proxy + current game: + if (m_pluginContainer->implementInterface(plugin)) { + + // Current game: + auto* game = m_pluginContainer->managedGame(); + if (m_pluginContainer->requirements(game).proxy() == plugin) { + QMessageBox::warning(parentWidget(), QObject::tr("Cannot disable plugin"), + QObject::tr("The '%1' plugin is used by the current game " + "plugin and cannot disabled.") + .arg(plugin->localizedName()), + QMessageBox::Ok); + ui->enabledCheckbox->setChecked(true); + return; + } + + // Check the proxied plugins: + auto proxied = requirements.proxied(); + if (!proxied.empty()) { + DisableProxyPluginDialog dialog(plugin, proxied, parentWidget()); + if (dialog.exec() != QDialog::Accepted) { + ui->enabledCheckbox->setChecked(true); + return; + } + } + } + + // Check if the plugins is required for other plugins: + auto requiredFor = requirements.requiredFor(); + if (!requiredFor.empty()) { + QStringList pluginNames; + for (auto& p : requiredFor) { + pluginNames.append(p->localizedName()); + } + pluginNames.sort(); + QString message = + QObject::tr("

            Disabling the '%1' plugin will also disable the following " + "plugins:

              %1

            Do you want to continue?

            ") + .arg(plugin->localizedName()) + .arg("
          • " + pluginNames.join("
          • ") + "
          • "); + if (QMessageBox::warning(parentWidget(), QObject::tr("Really disable plugin?"), + message, + QMessageBox::Yes | QMessageBox::No) == QMessageBox::No) { + ui->enabledCheckbox->setChecked(true); + return; + } + } + m_pluginContainer->setEnabled(plugin, false, true); + } + + // Proxy was disabled / enabled, need restart: + if (m_pluginContainer->implementInterface(plugin)) { + dialog().setExitNeeded(Exit::Restart); + } + + // Panel plugins swap UI tabs at init and cannot be hot-unloaded. + static const QStringList panelPluginNames = { + QStringLiteral("Bethesda Plugin Manager"), + }; + if (panelPluginNames.contains(plugin->name())) { + QMessageBox::information( + parentWidget(), QObject::tr("Restart required"), + QObject::tr("The '%1' plugin manages a UI panel. Restart Fluorine Manager " + "for the change to take effect.") + .arg(plugin->localizedName()), + QMessageBox::Ok); + dialog().setExitNeeded(Exit::Restart); + } + + updateListItems(); +} + +void PluginsSettingsTab::on_pluginsList_currentItemChanged(QTreeWidgetItem* current, + QTreeWidgetItem* previous) +{ + storeSettings(previous); + + if (!current->data(0, PluginRole).isValid()) { + return; + } + + ui->pluginSettingsList->clear(); + IPlugin* plugin = PluginsSettingsTab::plugin(current); + ui->authorLabel->setText(plugin->author()); + ui->versionLabel->setText(plugin->version().canonicalString()); + ui->descriptionLabel->setText(plugin->description()); + + // Checkbox, do not show for children or game plugins, disable + // if the plugin cannot be enabled. + ui->enabledCheckbox->setVisible( + !m_pluginContainer->implementInterface(plugin) && + plugin->master().isEmpty()); + + bool enabled = m_pluginContainer->isEnabled(plugin); + const auto& requirements = m_pluginContainer->requirements(plugin); + auto problems = requirements.problems(); + + if (m_pluginContainer->requirements(plugin).isCorePlugin()) { + ui->enabledCheckbox->setDisabled(true); + ui->enabledCheckbox->setToolTip( + QObject::tr("This plugin is required for Mod Organizer to work properly and " + "cannot be disabled.")); + } + // Plugin is enable or can be enabled. + else if (enabled || problems.empty()) { + ui->enabledCheckbox->setDisabled(false); + ui->enabledCheckbox->setToolTip(""); + ui->enabledCheckbox->setChecked(enabled); + } + // Plugin is disable and cannot be enabled. + else { + ui->enabledCheckbox->setDisabled(true); + ui->enabledCheckbox->setChecked(false); + if (problems.size() == 1) { + ui->enabledCheckbox->setToolTip(problems[0].shortDescription()); + } else { + QStringList descriptions; + for (auto& problem : problems) { + descriptions.append(problem.shortDescription()); + } + ui->enabledCheckbox->setToolTip("
            • " + descriptions.join("
            • ") + + "
            "); + } + } + + QVariantMap settings = current->data(0, SettingsRole).toMap(); + QVariantMap descriptions = current->data(0, DescriptionsRole).toMap(); + ui->pluginSettingsList->setEnabled(settings.count() != 0); + for (auto iter = settings.begin(); iter != settings.end(); ++iter) { + QTreeWidgetItem* newItem = new QTreeWidgetItem(QStringList(iter.key())); + QVariant value = *iter; + QString description; + { + auto descriptionIter = descriptions.find(iter.key()); + if (descriptionIter != descriptions.end()) { + description = descriptionIter->toString(); + } + } + + ui->pluginSettingsList->setItemDelegateForColumn(0, new NoEditDelegate()); + newItem->setData(1, Qt::DisplayRole, value); + newItem->setData(1, Qt::EditRole, value); + newItem->setToolTip(1, description); + + newItem->setFlags(newItem->flags() | Qt::ItemIsEditable); + ui->pluginSettingsList->addTopLevelItem(newItem); + } + + ui->pluginSettingsList->resizeColumnToContents(0); + ui->pluginSettingsList->resizeColumnToContents(1); +} + +void PluginsSettingsTab::deleteBlacklistItem() +{ + ui->pluginBlacklist->takeItem(ui->pluginBlacklist->currentIndex().row()); +} + +void PluginsSettingsTab::storeSettings(QTreeWidgetItem* pluginItem) +{ + if (pluginItem != nullptr && pluginItem->data(0, PluginRole).isValid()) { + QVariantMap settings = pluginItem->data(0, SettingsRole).toMap(); + + for (int i = 0; i < ui->pluginSettingsList->topLevelItemCount(); ++i) { + const QTreeWidgetItem* item = ui->pluginSettingsList->topLevelItem(i); + settings[item->text(0)] = item->data(1, Qt::DisplayRole); + } + + pluginItem->setData(0, SettingsRole, settings); + } +} diff --git a/src/src/settingsdialogworkarounds.cpp b/src/src/settingsdialogworkarounds.cpp index 2f8360c..820e697 100644 --- a/src/src/settingsdialogworkarounds.cpp +++ b/src/src/settingsdialogworkarounds.cpp @@ -1,239 +1,240 @@ -#include "settingsdialogworkarounds.h" -#include "settings.h" -#include "spawn.h" -#include "ui_settingsdialog.h" -#include -#include -#include - -WorkaroundsSettingsTab::WorkaroundsSettingsTab(Settings& s, SettingsDialog& d) - : SettingsTab(s, d) -{ - // options - ui->forceEnableBox->setChecked(settings().game().forceEnableCoreFiles()); - ui->lockGUIBox->setChecked(settings().interface().lockGUI()); - ui->enableArchiveParsingBox->setChecked(settings().archiveParsing()); - - // steam - QString username, password; - settings().steam().login(username, password); - if (username.length() > 0) - MOBase::log::getDefault().addToBlacklist(username.toStdString(), "STEAM_USERNAME"); - if (password.length() > 0) - MOBase::log::getDefault().addToBlacklist(password.toStdString(), "STEAM_PASSWORD"); - - ui->appIDEdit->setText(settings().steam().appID()); - ui->steamUserEdit->setText(username); - ui->steamPassEdit->setText(password); - - // network - ui->offlineBox->setChecked(settings().network().offlineMode()); - ui->proxyBox->setChecked(settings().network().useProxy()); - ui->useCustomBrowser->setChecked(settings().network().useCustomBrowser()); - ui->browserCommand->setText(settings().network().customBrowserCommand()); - - // buttons - m_ExecutableBlacklist = settings().executablesBlacklist(); - m_SkipFileSuffixes = settings().skipFileSuffixes(); - m_SkipDirectories = settings().skipDirectories(); - - QObject::connect(ui->bsaDateBtn, &QPushButton::clicked, [&] { - on_bsaDateBtn_clicked(); - }); - QObject::connect(ui->execBlacklistBtn, &QPushButton::clicked, [&] { - on_execBlacklistBtn_clicked(); - }); - QObject::connect(ui->skipFileSuffixBtn, &QPushButton::clicked, [&] { - on_skipFileSuffixBtn_clicked(); - }); - QObject::connect(ui->skipDirectoriesBtn, &QPushButton::clicked, [&] { - on_skipDirectoriesBtn_clicked(); - }); - QObject::connect(ui->resetGeometryBtn, &QPushButton::clicked, [&] { - on_resetGeometryBtn_clicked(); - }); -} - -void WorkaroundsSettingsTab::update() -{ - // options - settings().game().setForceEnableCoreFiles(ui->forceEnableBox->isChecked()); - settings().interface().setLockGUI(ui->lockGUIBox->isChecked()); - settings().setArchiveParsing(ui->enableArchiveParsingBox->isChecked()); - - // steam - if (ui->appIDEdit->text() != settings().game().plugin()->steamAPPId()) { - settings().steam().setAppID(ui->appIDEdit->text()); - } else { - settings().steam().setAppID(""); - } - settings().steam().setLogin(ui->steamUserEdit->text(), ui->steamPassEdit->text()); - - // network - settings().network().setOfflineMode(ui->offlineBox->isChecked()); - settings().network().setUseProxy(ui->proxyBox->isChecked()); - settings().network().setUseCustomBrowser(ui->useCustomBrowser->isChecked()); - settings().network().setCustomBrowserCommand(ui->browserCommand->text()); - - // buttons - settings().setExecutablesBlacklist(m_ExecutableBlacklist); - settings().setSkipFileSuffixes(m_SkipFileSuffixes); - settings().setSkipDirectories(m_SkipDirectories); -} - -bool WorkaroundsSettingsTab::changeBlacklistNow(QWidget* parent, Settings& settings) -{ - const auto current = settings.executablesBlacklist(); - - if (auto s = changeBlacklistLater(parent, current)) { - settings.setExecutablesBlacklist(*s); - return true; - } - - return false; -} - -std::optional -WorkaroundsSettingsTab::changeBlacklistLater(QWidget* parent, const QString& current) -{ - bool ok = false; - - QString result = QInputDialog::getMultiLineText( - parent, QObject::tr("Executables Blacklist"), - QObject::tr("Enter one executable per line to be blacklisted from the virtual " - "file system.\n" - "Mods and other virtualized files will not be visible to these " - "executables and\n" - "any executables launched by them.\n\n" - "Example:\n" - " Chrome.exe\n" - " Firefox.exe"), - current.split(";").join("\n"), &ok); - - if (!ok) { - return {}; - } - - QStringList blacklist; - for (auto exec : result.split("\n")) { - if (exec.trimmed().endsWith(".exe", Qt::CaseInsensitive)) { - blacklist << exec.trimmed(); - } - } - - return blacklist.join(";"); -} - -std::optional -WorkaroundsSettingsTab::changeSkipFileSuffixes(QWidget* parent, - const QStringList& current) -{ - bool ok = false; - - QString result = QInputDialog::getMultiLineText( - parent, QObject::tr("Skip File Suffixes"), - QObject::tr( - "Enter one file suffix per line to be skipped / ignored from the virtual " - "file system.\n" - "Not to be confused with file extensions, file suffixes are simply how the " - "filename ends.\n\n" - "Example:\n" - " .txt - Would skip all files that end with .txt, .txt\n" - " some_file.txt - Would skip all files that end with some_file.txt, some_file.txt"), - current.join("\n"), &ok); - - if (!ok) { - return {}; - } - - QStringList fileSuffixes; - for (auto& suffix : result.split("\n")) { - auto trimmed = suffix.trimmed(); - if (!trimmed.isEmpty()) { - fileSuffixes << trimmed; - } - } - - return fileSuffixes; -} - -std::optional -WorkaroundsSettingsTab::changeSkipDirectories(QWidget* parent, - const QStringList& current) -{ - bool ok = false; - - QString result = QInputDialog::getMultiLineText( - parent, QObject::tr("Skip Directories"), - QObject::tr( - "Enter one directory per line to be skipped / ignored from the virtual " - "file system.\n\n" - "Example:\n" - " .git\n" - " instructions"), - current.join("\n"), &ok); - - if (!ok) { - return {}; - } - - QStringList directories; - for (auto& dir : result.split("\n")) { - auto trimmed = dir.trimmed(); - if (!trimmed.isEmpty()) { - directories << trimmed; - } - } - - return directories; -} - -void WorkaroundsSettingsTab::on_execBlacklistBtn_clicked() -{ - if (auto s = changeBlacklistLater(parentWidget(), m_ExecutableBlacklist)) { - m_ExecutableBlacklist = *s; - } -} - -void WorkaroundsSettingsTab::on_skipFileSuffixBtn_clicked() -{ - if (auto s = changeSkipFileSuffixes(parentWidget(), m_SkipFileSuffixes)) { - m_SkipFileSuffixes = *s; - } -} - -void WorkaroundsSettingsTab::on_skipDirectoriesBtn_clicked() -{ - if (auto s = changeSkipDirectories(parentWidget(), m_SkipDirectories)) { - m_SkipDirectories = *s; - } -} - -void WorkaroundsSettingsTab::on_bsaDateBtn_clicked() -{ - const auto* game = qApp->property("managed_game").value(); - // BSA backdating used the Win32 helper.exe for admin rights — the equivalent - // on Linux would just touch the files but it's never been wired up. - QDir dir = game->dataDirectory(); - Q_UNUSED(dir); -} - -void WorkaroundsSettingsTab::on_resetGeometryBtn_clicked() -{ - const auto r = - MOBase::TaskDialog(parentWidget()) - .title(QObject::tr("Restart Mod Organizer")) - .main(QObject::tr("Restart Mod Organizer")) - .content(QObject::tr("Geometries will be reset to their default values.")) - .icon(QMessageBox::Question) - .button({QObject::tr("Restart Mod Organizer"), QMessageBox::Ok}) - .button({QObject::tr("Cancel"), QMessageBox::Cancel}) - .exec(); - - if (r == QMessageBox::Ok) { - settings().geometry().requestReset(); - ExitModOrganizer(Exit::Restart); - dialog().close(); - } -} +#include "settingsdialogworkarounds.h" +#include "settings.h" +#include "spawn.h" +#include "ui_settingsdialog.h" +#include +#include +#include + +WorkaroundsSettingsTab::WorkaroundsSettingsTab(Settings& s, SettingsDialog& d) + : SettingsTab(s, d) +{ + // options + ui->forceEnableBox->setChecked(settings().game().forceEnableCoreFiles()); + ui->lockGUIBox->setChecked(settings().interface().lockGUI()); + ui->enableArchiveParsingBox->setChecked(settings().archiveParsing()); + + // steam + QString username; + QString password; + settings().steam().login(username, password); + if (username.length() > 0) + MOBase::log::getDefault().addToBlacklist(username.toStdString(), "STEAM_USERNAME"); + if (password.length() > 0) + MOBase::log::getDefault().addToBlacklist(password.toStdString(), "STEAM_PASSWORD"); + + ui->appIDEdit->setText(settings().steam().appID()); + ui->steamUserEdit->setText(username); + ui->steamPassEdit->setText(password); + + // network + ui->offlineBox->setChecked(settings().network().offlineMode()); + ui->proxyBox->setChecked(settings().network().useProxy()); + ui->useCustomBrowser->setChecked(settings().network().useCustomBrowser()); + ui->browserCommand->setText(settings().network().customBrowserCommand()); + + // buttons + m_ExecutableBlacklist = settings().executablesBlacklist(); + m_SkipFileSuffixes = settings().skipFileSuffixes(); + m_SkipDirectories = settings().skipDirectories(); + + QObject::connect(ui->bsaDateBtn, &QPushButton::clicked, [&] { + on_bsaDateBtn_clicked(); + }); + QObject::connect(ui->execBlacklistBtn, &QPushButton::clicked, [&] { + on_execBlacklistBtn_clicked(); + }); + QObject::connect(ui->skipFileSuffixBtn, &QPushButton::clicked, [&] { + on_skipFileSuffixBtn_clicked(); + }); + QObject::connect(ui->skipDirectoriesBtn, &QPushButton::clicked, [&] { + on_skipDirectoriesBtn_clicked(); + }); + QObject::connect(ui->resetGeometryBtn, &QPushButton::clicked, [&] { + on_resetGeometryBtn_clicked(); + }); +} + +void WorkaroundsSettingsTab::update() +{ + // options + settings().game().setForceEnableCoreFiles(ui->forceEnableBox->isChecked()); + settings().interface().setLockGUI(ui->lockGUIBox->isChecked()); + settings().setArchiveParsing(ui->enableArchiveParsingBox->isChecked()); + + // steam + if (ui->appIDEdit->text() != settings().game().plugin()->steamAPPId()) { + settings().steam().setAppID(ui->appIDEdit->text()); + } else { + settings().steam().setAppID(""); + } + settings().steam().setLogin(ui->steamUserEdit->text(), ui->steamPassEdit->text()); + + // network + settings().network().setOfflineMode(ui->offlineBox->isChecked()); + settings().network().setUseProxy(ui->proxyBox->isChecked()); + settings().network().setUseCustomBrowser(ui->useCustomBrowser->isChecked()); + settings().network().setCustomBrowserCommand(ui->browserCommand->text()); + + // buttons + settings().setExecutablesBlacklist(m_ExecutableBlacklist); + settings().setSkipFileSuffixes(m_SkipFileSuffixes); + settings().setSkipDirectories(m_SkipDirectories); +} + +bool WorkaroundsSettingsTab::changeBlacklistNow(QWidget* parent, Settings& settings) +{ + const auto current = settings.executablesBlacklist(); + + if (auto s = changeBlacklistLater(parent, current)) { + settings.setExecutablesBlacklist(*s); + return true; + } + + return false; +} + +std::optional +WorkaroundsSettingsTab::changeBlacklistLater(QWidget* parent, const QString& current) +{ + bool ok = false; + + QString result = QInputDialog::getMultiLineText( + parent, QObject::tr("Executables Blacklist"), + QObject::tr("Enter one executable per line to be blacklisted from the virtual " + "file system.\n" + "Mods and other virtualized files will not be visible to these " + "executables and\n" + "any executables launched by them.\n\n" + "Example:\n" + " Chrome.exe\n" + " Firefox.exe"), + current.split(";").join("\n"), &ok); + + if (!ok) { + return {}; + } + + QStringList blacklist; + for (auto exec : result.split("\n")) { + if (exec.trimmed().endsWith(".exe", Qt::CaseInsensitive)) { + blacklist << exec.trimmed(); + } + } + + return blacklist.join(";"); +} + +std::optional +WorkaroundsSettingsTab::changeSkipFileSuffixes(QWidget* parent, + const QStringList& current) +{ + bool ok = false; + + QString result = QInputDialog::getMultiLineText( + parent, QObject::tr("Skip File Suffixes"), + QObject::tr( + "Enter one file suffix per line to be skipped / ignored from the virtual " + "file system.\n" + "Not to be confused with file extensions, file suffixes are simply how the " + "filename ends.\n\n" + "Example:\n" + " .txt - Would skip all files that end with .txt, .txt\n" + " some_file.txt - Would skip all files that end with some_file.txt, some_file.txt"), + current.join("\n"), &ok); + + if (!ok) { + return {}; + } + + QStringList fileSuffixes; + for (auto& suffix : result.split("\n")) { + auto trimmed = suffix.trimmed(); + if (!trimmed.isEmpty()) { + fileSuffixes << trimmed; + } + } + + return fileSuffixes; +} + +std::optional +WorkaroundsSettingsTab::changeSkipDirectories(QWidget* parent, + const QStringList& current) +{ + bool ok = false; + + QString result = QInputDialog::getMultiLineText( + parent, QObject::tr("Skip Directories"), + QObject::tr( + "Enter one directory per line to be skipped / ignored from the virtual " + "file system.\n\n" + "Example:\n" + " .git\n" + " instructions"), + current.join("\n"), &ok); + + if (!ok) { + return {}; + } + + QStringList directories; + for (auto& dir : result.split("\n")) { + auto trimmed = dir.trimmed(); + if (!trimmed.isEmpty()) { + directories << trimmed; + } + } + + return directories; +} + +void WorkaroundsSettingsTab::on_execBlacklistBtn_clicked() +{ + if (auto s = changeBlacklistLater(parentWidget(), m_ExecutableBlacklist)) { + m_ExecutableBlacklist = *s; + } +} + +void WorkaroundsSettingsTab::on_skipFileSuffixBtn_clicked() +{ + if (auto s = changeSkipFileSuffixes(parentWidget(), m_SkipFileSuffixes)) { + m_SkipFileSuffixes = *s; + } +} + +void WorkaroundsSettingsTab::on_skipDirectoriesBtn_clicked() +{ + if (auto s = changeSkipDirectories(parentWidget(), m_SkipDirectories)) { + m_SkipDirectories = *s; + } +} + +void WorkaroundsSettingsTab::on_bsaDateBtn_clicked() +{ + const auto* game = qApp->property("managed_game").value(); + // BSA backdating used the Win32 helper.exe for admin rights — the equivalent + // on Linux would just touch the files but it's never been wired up. + QDir dir = game->dataDirectory(); + Q_UNUSED(dir); +} + +void WorkaroundsSettingsTab::on_resetGeometryBtn_clicked() +{ + const auto r = + MOBase::TaskDialog(parentWidget()) + .title(QObject::tr("Restart Mod Organizer")) + .main(QObject::tr("Restart Mod Organizer")) + .content(QObject::tr("Geometries will be reset to their default values.")) + .icon(QMessageBox::Question) + .button({QObject::tr("Restart Mod Organizer"), QMessageBox::Ok}) + .button({QObject::tr("Cancel"), QMessageBox::Cancel}) + .exec(); + + if (r == QMessageBox::Ok) { + settings().geometry().requestReset(); + ExitModOrganizer(Exit::Restart); + dialog().close(); + } +} diff --git a/src/src/shared/appconfig.cpp b/src/src/shared/appconfig.cpp index 8fe9b30..0a7ef90 100644 --- a/src/src/shared/appconfig.cpp +++ b/src/src/shared/appconfig.cpp @@ -1,71 +1,71 @@ -/* -Copyright (C) 2012 Sebastian Herbord. All rights reserved. - -This file is part of Mod Organizer. - -Mod Organizer is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -Mod Organizer is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with Mod Organizer. If not, see . -*/ - -#include "appconfig.h" - -#include - -#include -#include - -namespace AppConfig -{ - -#define PARWSTRING wstring -#define APPPARAM(partype, parid, value) \ - partype parid() \ - { \ - return value; \ - } -#include "appconfig.inc" - -QString basePath() -{ - const char* envBase = std::getenv("MO2_BASE_DIR"); - if (envBase && envBase[0] != '\0') { - return QString::fromUtf8(envBase); - } - return QCoreApplication::applicationDirPath(); -} - -QString pluginsPath() -{ - const char* envDir = std::getenv("MO2_PLUGINS_DIR"); - if (envDir && envDir[0] != '\0') { - return QString::fromUtf8(envDir); - } - return basePath() + "/" + QString::fromStdWString(pluginPath()); -} - -QString dllsPath() -{ - const char* envDir = std::getenv("MO2_DLLS_DIR"); - if (envDir && envDir[0] != '\0') { - return QString::fromUtf8(envDir); - } - return basePath() + "/dlls"; -} - -namespace MOShared -{ -#undef PARWSTRING -#undef APPPARAM - -} // namespace MOShared -} // namespace AppConfig +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "appconfig.h" + +#include + +#include +#include + +namespace AppConfig +{ + +#define PARWSTRING wstring +#define APPPARAM(partype, parid, value) \ + partype parid() \ + { \ + return value; \ + } +#include "appconfig.inc" + +QString basePath() +{ + const char* envBase = std::getenv("MO2_BASE_DIR"); + if (envBase && envBase[0] != '\0') { + return QString::fromUtf8(envBase); + } + return QCoreApplication::applicationDirPath(); +} + +QString pluginsPath() +{ + const char* envDir = std::getenv("MO2_PLUGINS_DIR"); + if (envDir && envDir[0] != '\0') { + return QString::fromUtf8(envDir); + } + return basePath() + "/" + QString::fromStdWString(pluginPath()); +} + +QString libsPath() +{ + const char* envDir = std::getenv("MO2_LIBS_DIR"); + if (envDir && envDir[0] != '\0') { + return QString::fromUtf8(envDir); + } + return basePath() + "/lib"; +} + +namespace MOShared +{ +#undef PARWSTRING +#undef APPPARAM + +} // namespace MOShared +} // namespace AppConfig diff --git a/src/src/shared/appconfig.h b/src/src/shared/appconfig.h index 0c0f7b4..efbc169 100644 --- a/src/src/shared/appconfig.h +++ b/src/src/shared/appconfig.h @@ -1,58 +1,58 @@ -/* -Copyright (C) 2012 Sebastian Herbord. All rights reserved. - -This file is part of Mod Organizer. - -Mod Organizer is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -Mod Organizer is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with Mod Organizer. If not, see . -*/ - -#ifndef APPCONFIG_H -#define APPCONFIG_H - -#include - -class QString; - -namespace AppConfig -{ - -#define PARWSTRING wstring -#define APPPARAM(partype, parid, value) partype parid(); -#include "appconfig.inc" - -// Returns the application base directory. On Linux, if the MO2_BASE_DIR -// environment variable is set (e.g. by an AppImage wrapper) that value is -// returned; otherwise falls back to QCoreApplication::applicationDirPath(). -QString basePath(); - -// Returns the directory containing MO2 plugins. On Linux, if the -// MO2_PLUGINS_DIR environment variable is set (e.g. when plugins live -// inside a read-only AppImage squashfs) that value is used; otherwise -// falls back to basePath() + "/plugins". -QString pluginsPath(); - -// Returns the directory containing bundled DLLs (7z.so, etc.). -// Respects MO2_DLLS_DIR on Linux, otherwise basePath() + "/dlls". -QString dllsPath(); - -namespace MOShared -{ -#undef PARWSTRING -#undef APPPARAM - -} // namespace MOShared - -} // namespace AppConfig - -#endif // APPCONFIG_H +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef APPCONFIG_H +#define APPCONFIG_H + +#include + +class QString; + +namespace AppConfig +{ + +#define PARWSTRING wstring +#define APPPARAM(partype, parid, value) partype parid(); +#include "appconfig.inc" + +// Returns the application base directory. On Linux, if the MO2_BASE_DIR +// environment variable is set (e.g. by an AppImage wrapper) that value is +// returned; otherwise falls back to QCoreApplication::applicationDirPath(). +QString basePath(); + +// Returns the directory containing MO2 plugins. On Linux, if the +// MO2_PLUGINS_DIR environment variable is set (e.g. when plugins live +// inside a read-only AppImage squashfs) that value is used; otherwise +// falls back to basePath() + "/plugins". +QString pluginsPath(); + +// Returns the directory containing bundled Linux libraries (7z.so, etc.). +// Respects MO2_LIBS_DIR, otherwise basePath() + "/lib". +QString libsPath(); + +namespace MOShared +{ +#undef PARWSTRING +#undef APPPARAM + +} // namespace MOShared + +} // namespace AppConfig + +#endif // APPCONFIG_H diff --git a/src/src/shared/directoryentry.cpp b/src/src/shared/directoryentry.cpp index 2fac06b..e7cd29f 100644 --- a/src/src/shared/directoryentry.cpp +++ b/src/src/shared/directoryentry.cpp @@ -1,956 +1,956 @@ -/* -Copyright (C) 2012 Sebastian Herbord. All rights reserved. - -This file is part of Mod Organizer. - -Mod Organizer is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -Mod Organizer is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with Mod Organizer. If not, see . -*/ - -#include "directoryentry.h" -#include "../envfs.h" -#include "fileentry.h" -#include "filesorigin.h" -#include "originconnection.h" -#include "util.h" -#include "windows_error.h" -#include -#include -#include - -namespace MOShared -{ - -using namespace MOBase; -const int MAXPATH_UNICODE = 32767; - -template -void elapsedImpl(std::chrono::nanoseconds& out, F&& f) -{ - if constexpr (DirectoryStats::EnableInstrumentation) { - const auto start = std::chrono::high_resolution_clock::now(); - f(); - const auto end = std::chrono::high_resolution_clock::now(); - out += (end - start); - } else { - f(); - } -} - -// elapsed() is not optimized out when EnableInstrumentation is false even -// though it's equivalent that this macro -#define elapsed(OUT, F) (F)(); -// #define elapsed(OUT, F) elapsedImpl(OUT, F); - -static bool SupportOptimizedFind() -{ - return true; -} - -bool DirCompareByName::operator()(const DirectoryEntry* lhs, - const DirectoryEntry* rhs) const -{ - return wcscasecmp(lhs->getName().c_str(), rhs->getName().c_str()) < 0; -} - -DirectoryEntry::DirectoryEntry(std::wstring name, DirectoryEntry* parent, int originID) - : m_OriginConnection(new OriginConnection), m_Name(std::move(name)), - m_Parent(parent), m_Populated(false), m_TopLevel(true) -{ - m_FileRegister.reset(new FileRegister(m_OriginConnection)); - m_Origins.insert(originID); -} - -DirectoryEntry::DirectoryEntry(std::wstring name, DirectoryEntry* parent, int originID, - boost::shared_ptr fileRegister, - boost::shared_ptr originConnection) - : m_FileRegister(fileRegister), m_OriginConnection(originConnection), - m_Name(std::move(name)), m_Parent(parent), m_Populated(false), m_TopLevel(false) -{ - m_Origins.insert(originID); -} - -DirectoryEntry::~DirectoryEntry() -{ - clear(); -} - -void DirectoryEntry::clear() -{ - for (auto itor = m_SubDirectories.rbegin(); itor != m_SubDirectories.rend(); ++itor) { - delete *itor; - } - - m_Files.clear(); - m_FilesLookup.clear(); - m_SubDirectories.clear(); - m_SubDirectoriesLookup.clear(); -} - -void DirectoryEntry::addFromOrigin(const std::wstring& originName, - const std::wstring& directory, int priority, - DirectoryStats& stats) -{ - env::DirectoryWalker walker; - addFromOrigin(walker, originName, directory, priority, stats); -} - -void DirectoryEntry::addFromOrigin(env::DirectoryWalker& walker, - const std::wstring& originName, - const std::wstring& directory, int priority, - DirectoryStats& stats) -{ - FilesOrigin& origin = createOrigin(originName, directory, priority, stats); - - if (!directory.empty()) { - addFiles(walker, origin, directory, stats); - } - - m_Populated = true; -} - -void DirectoryEntry::addFromList(const std::wstring& originName, - const std::wstring& directory, env::Directory& root, - int priority, DirectoryStats& stats) -{ - stats = {}; - - FilesOrigin& origin = createOrigin(originName, directory, priority, stats); - addDir(origin, root, stats); -} - -void DirectoryEntry::addDir(FilesOrigin& origin, env::Directory& d, - DirectoryStats& stats) -{ - elapsed(stats.dirTimes, [&] { - for (auto& sd : d.dirs) { - auto* sdirEntry = getSubDirectory(sd, true, stats, origin.getID()); - sdirEntry->addDir(origin, sd, stats); - } - }); - - elapsed(stats.fileTimes, [&] { - for (auto& f : d.files) { - insert(f, origin, L"", -1, stats); - } - }); - - m_Populated = true; -} - -void DirectoryEntry::addFromAllBSAs(const std::wstring& originName, - const std::wstring& directory, int priority, - const std::vector& archives, - const std::set& enabledArchives, - const std::vector& loadOrder, - DirectoryStats& stats) -{ - for (const auto& archive : archives) { - const std::filesystem::path archivePath(archive); - const auto filename = archivePath.filename().wstring(); - - if (!enabledArchives.contains(filename)) { - continue; - } - - const auto filenameLc = ToLowerCopy(filename); - - int order = -1; - - for (auto plugin : loadOrder) { - const auto pluginNameLc = - ToLowerCopy(std::filesystem::path(plugin).stem().wstring()); - - if (filenameLc.starts_with(pluginNameLc + L" - ") || - filenameLc.starts_with(pluginNameLc + L".")) { - auto itor = std::find(loadOrder.begin(), loadOrder.end(), plugin); - if (itor != loadOrder.end()) { - order = std::distance(loadOrder.begin(), itor); - } - } - } - - addFromBSA(originName, directory, archivePath.wstring(), priority, order, stats); - } -} - -void DirectoryEntry::addFromBSA(const std::wstring& originName, - const std::wstring& directory, - const std::wstring& archivePath, int priority, - int order, DirectoryStats& stats) -{ - FilesOrigin& origin = createOrigin(originName, directory, priority, stats); - const auto archiveName = std::filesystem::path(archivePath).filename().wstring(); - - if (containsArchive(archiveName)) { - return; - } - - BSA::Archive archive; - BSA::EErrorCode res = BSA::ERROR_NONE; - - try { - // read() can return an error, but it can also throw if the file is not a - // valid bsa - res = archive.read(ToString(archivePath, false).c_str(), false); - } catch (std::exception& e) { - log::error("invalid bsa '{}', error {}", archivePath, e.what()); - return; - } - - if ((res != BSA::ERROR_NONE) && (res != BSA::ERROR_INVALIDHASHES)) { - log::error("invalid bsa '{}', error {}", archivePath, res); - return; - } - - std::error_code ec; - const auto lwt = std::filesystem::last_write_time(archivePath, ec); - FILETIME ft = {}; - - if (ec) { - log::warn("failed to get last modified date for '{}', {}", archivePath, - ec.message()); - } else { - ft = ToFILETIME(lwt); - } - - addFiles(origin, archive.getRoot(), ft, archiveName, order, stats); - - m_Populated = true; -} - -void DirectoryEntry::propagateOrigin(int origin) -{ - { - std::scoped_lock lock(m_OriginsMutex); - m_Origins.insert(origin); - } - - if (m_Parent != nullptr) { - m_Parent->propagateOrigin(origin); - } -} - -bool DirectoryEntry::originExists(const std::wstring& name) const -{ - return m_OriginConnection->exists(name); -} - -FilesOrigin& DirectoryEntry::getOriginByID(int ID) const -{ - return m_OriginConnection->getByID(ID); -} - -FilesOrigin& DirectoryEntry::getOriginByName(const std::wstring& name) const -{ - return m_OriginConnection->getByName(name); -} - -const FilesOrigin* DirectoryEntry::findOriginByID(int ID) const -{ - return m_OriginConnection->findByID(ID); -} - -int DirectoryEntry::anyOrigin() const -{ - bool ignore; - - for (auto iter = m_Files.begin(); iter != m_Files.end(); ++iter) { - FileEntryPtr entry = m_FileRegister->getFile(iter->second); - if ((entry.get() != nullptr) && !entry->isFromArchive()) { - return entry->getOrigin(ignore); - } - } - - // if we got here, no file directly within this directory is a valid indicator for a - // mod, thus we continue looking in subdirectories - for (DirectoryEntry* entry : m_SubDirectories) { - int res = entry->anyOrigin(); - if (res != InvalidOriginID) { - return res; - } - } - - return *(m_Origins.begin()); -} - -std::vector DirectoryEntry::getFiles() const -{ - std::vector result; - result.reserve(m_Files.size()); - - for (auto iter = m_Files.begin(); iter != m_Files.end(); ++iter) { - result.push_back(m_FileRegister->getFile(iter->second)); - } - - return result; -} - -DirectoryEntry* DirectoryEntry::findSubDirectory(const std::wstring& name, - bool alreadyLowerCase) const -{ - SubDirectoriesLookup::const_iterator itor; - - if (alreadyLowerCase) { - itor = m_SubDirectoriesLookup.find(name); - } else { - itor = m_SubDirectoriesLookup.find(ToLowerCopy(name)); - } - - if (itor == m_SubDirectoriesLookup.end()) { - return nullptr; - } - - return itor->second; -} - -DirectoryEntry* DirectoryEntry::findSubDirectoryRecursive(const std::wstring& path) -{ - DirectoryStats dummy; - return getSubDirectoryRecursive(path, false, dummy, InvalidOriginID); -} - -const FileEntryPtr DirectoryEntry::findFile(const std::wstring& name, - bool alreadyLowerCase) const -{ - FilesLookup::const_iterator iter; - - if (alreadyLowerCase) { - iter = m_FilesLookup.find(DirectoryEntryFileKey(name)); - } else { - iter = m_FilesLookup.find(DirectoryEntryFileKey(ToLowerCopy(name))); - } - - if (iter != m_FilesLookup.end()) { - return m_FileRegister->getFile(iter->second); - } else { - return FileEntryPtr(); - } -} - -const FileEntryPtr DirectoryEntry::findFile(const DirectoryEntryFileKey& key) const -{ - auto iter = m_FilesLookup.find(key); - - if (iter != m_FilesLookup.end()) { - return m_FileRegister->getFile(iter->second); - } else { - return FileEntryPtr(); - } -} - -bool DirectoryEntry::hasFile(const std::wstring& name) const -{ - return m_Files.contains(ToLowerCopy(name)); -} - -bool DirectoryEntry::containsArchive(std::wstring archiveName) -{ - for (auto iter = m_Files.begin(); iter != m_Files.end(); ++iter) { - FileEntryPtr entry = m_FileRegister->getFile(iter->second); - if (entry->isFromArchive(archiveName)) { - return true; - } - } - - return false; -} - -const FileEntryPtr DirectoryEntry::searchFile(const std::wstring& path, - const DirectoryEntry** directory) const -{ - if (directory != nullptr) { - *directory = nullptr; - } - - if ((path.empty()) || (path == L"*")) { - // no file name -> the path ended on a (back-)slash - if (directory != nullptr) { - *directory = this; - } - - return FileEntryPtr(); - } - - const size_t len = path.find_first_of(L"\\/"); - - if (len == std::string::npos) { - // no more path components - auto iter = m_Files.find(ToLowerCopy(path)); - - if (iter != m_Files.end()) { - return m_FileRegister->getFile(iter->second); - } else if (directory != nullptr) { - DirectoryEntry* temp = findSubDirectory(path); - if (temp != nullptr) { - *directory = temp; - } - } - } else { - // file is in a subdirectory, recurse into the matching subdirectory - std::wstring pathComponent = path.substr(0, len); - DirectoryEntry* temp = findSubDirectory(pathComponent); - - if (temp != nullptr) { - if (len >= path.size()) { - log::error("{}", QObject::tr("unexpected end of path")); - return FileEntryPtr(); - } - - return temp->searchFile(path.substr(len + 1), directory); - } - } - - return FileEntryPtr(); -} - -void DirectoryEntry::removeFile(FileIndex index) -{ - removeFileFromList(index); -} - -bool DirectoryEntry::removeFile(const std::wstring& filePath, int* origin) -{ - size_t pos = filePath.find_first_of(L"\\/"); - - if (pos == std::string::npos) { - return this->remove(filePath, origin); - } - - std::wstring dirName = filePath.substr(0, pos); - std::wstring rest = filePath.substr(pos + 1); - - DirectoryStats dummy; - DirectoryEntry* entry = getSubDirectoryRecursive(dirName, false, dummy); - - if (entry != nullptr) { - return entry->removeFile(rest, origin); - } else { - return false; - } -} - -void DirectoryEntry::removeDir(const std::wstring& path) -{ - size_t pos = path.find_first_of(L"\\/"); - - if (pos == std::string::npos) { - for (auto iter = m_SubDirectories.begin(); iter != m_SubDirectories.end(); ++iter) { - DirectoryEntry* entry = *iter; - - if (CaseInsensitiveEqual(entry->getName(), path)) { - entry->removeDirRecursive(); - removeDirectoryFromList(iter); - delete entry; - break; - } - } - } else { - std::wstring dirName = path.substr(0, pos); - std::wstring rest = path.substr(pos + 1); - - DirectoryStats dummy; - DirectoryEntry* entry = getSubDirectoryRecursive(dirName, false, dummy); - - if (entry != nullptr) { - entry->removeDir(rest); - } - } -} - -bool DirectoryEntry::remove(const std::wstring& fileName, int* origin) -{ - const auto lcFileName = ToLowerCopy(fileName); - - auto iter = m_Files.find(lcFileName); - bool b = false; - - if (iter != m_Files.end()) { - if (origin != nullptr) { - FileEntryPtr entry = m_FileRegister->getFile(iter->second); - if (entry.get() != nullptr) { - bool ignore; - *origin = entry->getOrigin(ignore); - } - } - - b = m_FileRegister->removeFile(iter->second); - } - - return b; -} - -bool DirectoryEntry::hasContentsFromOrigin(int originID) const -{ - return m_Origins.contains(originID); -} - -FilesOrigin& DirectoryEntry::createOrigin(const std::wstring& originName, - const std::wstring& directory, int priority, - DirectoryStats& stats) -{ - auto r = m_OriginConnection->getOrCreate(originName, directory, priority, - m_FileRegister, m_OriginConnection, stats); - - if (r.second) { - ++stats.originCreate; - } else { - ++stats.originExists; - } - - return r.first; -} - -void DirectoryEntry::removeFiles(const std::set& indices) -{ - removeFilesFromList(indices); -} - -FileEntryPtr DirectoryEntry::insert(std::wstring_view fileName, FilesOrigin& origin, - FILETIME fileTime, std::wstring_view archive, - int order, DirectoryStats& stats) -{ - std::wstring fileNameLower = ToLowerCopy(fileName); - FileEntryPtr fe; - - DirectoryEntryFileKey key(std::move(fileNameLower)); - - { - std::unique_lock lock(m_FilesMutex); - - FilesLookup::iterator itor; - - elapsed(stats.filesLookupTimes, [&] { - itor = m_FilesLookup.find(key); - }); - - if (itor != m_FilesLookup.end()) { - lock.unlock(); - ++stats.fileExists; - fe = m_FileRegister->getFile(itor->second); - } else { - ++stats.fileCreate; - fe = m_FileRegister->createFile(std::wstring(fileName.begin(), fileName.end()), - this, stats); - - elapsed(stats.addFileTimes, [&] { - addFileToList(std::move(key.value), fe->getIndex()); - }); - - // fileNameLower has moved from this point - } - } - - elapsed(stats.addOriginToFileTimes, [&] { - fe->addOrigin(origin.getID(), fileTime, archive, order); - }); - - elapsed(stats.addFileToOriginTimes, [&] { - origin.addFile(fe->getIndex()); - }); - - return fe; -} - -FileEntryPtr DirectoryEntry::insert(env::File& file, FilesOrigin& origin, - std::wstring_view archive, int order, - DirectoryStats& stats) -{ - FileEntryPtr fe; - - { - std::unique_lock lock(m_FilesMutex); - - FilesMap::iterator itor; - - elapsed(stats.filesLookupTimes, [&] { - itor = m_Files.find(file.lcname); - }); - - if (itor != m_Files.end()) { - lock.unlock(); - ++stats.fileExists; - fe = m_FileRegister->getFile(itor->second); - } else { - ++stats.fileCreate; - fe = m_FileRegister->createFile(std::move(file.name), this, stats); - // file.name has been moved from this point - - elapsed(stats.addFileTimes, [&] { - addFileToList(std::move(file.lcname), fe->getIndex()); - }); - - // file.lcname has been moved from this point - } - } - - elapsed(stats.addOriginToFileTimes, [&] { - fe->addOrigin(origin.getID(), file.lastModified, archive, order); - }); - - elapsed(stats.addFileToOriginTimes, [&] { - origin.addFile(fe->getIndex()); - }); - - return fe; -} - -struct DirectoryEntry::Context -{ - FilesOrigin& origin; - DirectoryStats& stats; - std::stack current; -}; - -void DirectoryEntry::addFiles(env::DirectoryWalker& walker, FilesOrigin& origin, - const std::wstring& path, DirectoryStats& stats) -{ - Context cx = {origin, stats}; - cx.current.push(this); - - // Convert wstring to narrow string for std::filesystem to avoid - // locale-dependent wchar_t conversion issues on glibc. - const bool pathExists = - std::filesystem::exists(QString::fromStdWString(path).toStdString()); - - if (pathExists) { - walker.forEachEntry( - path, &cx, - [](void* pcx, std::wstring_view path) { - onDirectoryStart((Context*)pcx, path); - }, - - [](void* pcx, std::wstring_view path) { - onDirectoryEnd((Context*)pcx, path); - }, - - [](void* pcx, std::wstring_view path, FILETIME ft, uint64_t) { - onFile((Context*)pcx, path, ft); - }); - } -} - -void DirectoryEntry::onDirectoryStart(Context* cx, std::wstring_view path) -{ - elapsed(cx->stats.dirTimes, [&] { - auto* sd = - cx->current.top()->getSubDirectory(path, true, cx->stats, cx->origin.getID()); - - cx->current.push(sd); - }); -} - -void DirectoryEntry::onDirectoryEnd(Context* cx, std::wstring_view path) -{ - elapsed(cx->stats.dirTimes, [&] { - cx->current.pop(); - }); -} - -void DirectoryEntry::onFile(Context* cx, std::wstring_view path, FILETIME ft) -{ - elapsed(cx->stats.fileTimes, [&] { - cx->current.top()->insert(path, cx->origin, ft, L"", -1, cx->stats); - }); -} - -void DirectoryEntry::addFiles(FilesOrigin& origin, const BSA::Folder::Ptr archiveFolder, - FILETIME fileTime, const std::wstring& archiveName, - int order, DirectoryStats& stats) -{ - // add files - const auto fileCount = archiveFolder->getNumFiles(); - for (unsigned int i = 0; i < fileCount; ++i) { - const BSA::File::Ptr file = archiveFolder->getFile(i); - - auto f = insert(ToWString(file->getName(), true), origin, fileTime, archiveName, - order, stats); - - if (f) { - if (file->getUncompressedFileSize() > 0) { - f->setFileSize(file->getFileSize(), file->getUncompressedFileSize()); - } else { - f->setFileSize(file->getFileSize(), FileEntry::NoFileSize); - } - } - } - - // recurse into subdirectories - const auto dirCount = archiveFolder->getNumSubFolders(); - for (unsigned int i = 0; i < dirCount; ++i) { - const BSA::Folder::Ptr folder = archiveFolder->getSubFolder(i); - - DirectoryEntry* folderEntry = getSubDirectoryRecursive( - ToWString(folder->getName(), true), true, stats, origin.getID()); - - folderEntry->addFiles(origin, folder, fileTime, archiveName, order, stats); - } -} - -DirectoryEntry* DirectoryEntry::getSubDirectory(std::wstring_view name, bool create, - DirectoryStats& stats, int originID) -{ - std::wstring nameLc = ToLowerCopy(name); - - std::scoped_lock lock(m_SubDirMutex); - - SubDirectoriesLookup::iterator itor; - elapsed(stats.subdirLookupTimes, [&] { - itor = m_SubDirectoriesLookup.find(nameLc); - }); - - if (itor != m_SubDirectoriesLookup.end()) { - ++stats.subdirExists; - return itor->second; - } - - if (create) { - ++stats.subdirCreate; - - auto* entry = new DirectoryEntry(std::wstring(name.begin(), name.end()), this, - originID, m_FileRegister, m_OriginConnection); - - elapsed(stats.addDirectoryTimes, [&] { - addDirectoryToList(entry, std::move(nameLc)); - // nameLc is moved from this point - }); - - return entry; - } else { - return nullptr; - } -} - -DirectoryEntry* DirectoryEntry::getSubDirectory(env::Directory& dir, bool create, - DirectoryStats& stats, int originID) -{ - SubDirectoriesLookup::iterator itor; - - std::scoped_lock lock(m_SubDirMutex); - - elapsed(stats.subdirLookupTimes, [&] { - itor = m_SubDirectoriesLookup.find(dir.lcname); - }); - - if (itor != m_SubDirectoriesLookup.end()) { - ++stats.subdirExists; - return itor->second; - } - - if (create) { - ++stats.subdirCreate; - - auto* entry = new DirectoryEntry(std::move(dir.name), this, originID, - m_FileRegister, m_OriginConnection); - // dir.name is moved from this point - - elapsed(stats.addDirectoryTimes, [&] { - addDirectoryToList(entry, std::move(dir.lcname)); - }); - - // dir.lcname is moved from this point - - return entry; - } else { - return nullptr; - } -} - -DirectoryEntry* DirectoryEntry::getSubDirectoryRecursive(const std::wstring& path, - bool create, - DirectoryStats& stats, - int originID) -{ - if (path.empty()) { - // path ended with a backslash? - return this; - } - - const size_t pos = path.find_first_of(L"\\/"); - - if (pos == std::wstring::npos) { - return getSubDirectory(path, create, stats); - } else { - DirectoryEntry* nextChild = - getSubDirectory(path.substr(0, pos), create, stats, originID); - - if (nextChild == nullptr) { - return nullptr; - } else { - return nextChild->getSubDirectoryRecursive(path.substr(pos + 1), create, stats, - originID); - } - } -} - -void DirectoryEntry::removeDirRecursive() -{ - while (!m_Files.empty()) { - m_FileRegister->removeFile(m_Files.begin()->second); - } - - m_FilesLookup.clear(); - - for (DirectoryEntry* entry : m_SubDirectories) { - entry->removeDirRecursive(); - delete entry; - } - - m_SubDirectories.clear(); - m_SubDirectoriesLookup.clear(); -} - -void DirectoryEntry::addDirectoryToList(DirectoryEntry* e, std::wstring nameLc) -{ - m_SubDirectories.insert(e); - m_SubDirectoriesLookup.emplace(std::move(nameLc), e); -} - -void DirectoryEntry::removeDirectoryFromList(SubDirectories::iterator itor) -{ - const auto* entry = *itor; - - { - auto itor2 = std::find_if(m_SubDirectoriesLookup.begin(), - m_SubDirectoriesLookup.end(), [&](auto&& d) { - return (d.second == entry); - }); - - if (itor2 == m_SubDirectoriesLookup.end()) { - log::error("entry {} not in sub directories map", entry->getName()); - } else { - m_SubDirectoriesLookup.erase(itor2); - } - } - - m_SubDirectories.erase(itor); -} - -void DirectoryEntry::removeFileFromList(FileIndex index) -{ - auto removeFrom = [&](auto& list) { - auto iter = std::find_if(list.begin(), list.end(), [&index](auto&& pair) { - return (pair.second == index); - }); - - if (iter == list.end()) { - auto f = m_FileRegister->getFile(index); - - if (f) { - log::error("can't remove file '{}', not in directory entry '{}'", f->getName(), - getName()); - } else { - log::error("can't remove file with index {}, not in directory entry '{}' and " - "not in register", - index, getName()); - } - } else { - list.erase(iter); - } - }; - - removeFrom(m_FilesLookup); - removeFrom(m_Files); -} - -void DirectoryEntry::removeFilesFromList(const std::set& indices) -{ - for (auto iter = m_Files.begin(); iter != m_Files.end();) { - if (indices.contains(iter->second)) { - iter = m_Files.erase(iter); - } else { - ++iter; - } - } - - for (auto iter = m_FilesLookup.begin(); iter != m_FilesLookup.end();) { - if (indices.contains(iter->second)) { - iter = m_FilesLookup.erase(iter); - } else { - ++iter; - } - } -} - -void DirectoryEntry::addFileToList(std::wstring fileNameLower, FileIndex index) -{ - m_FilesLookup.emplace(fileNameLower, index); - m_Files.emplace(std::move(fileNameLower), index); - // fileNameLower has been moved from this point -} - -struct DumpFailed : public std::runtime_error -{ - using runtime_error::runtime_error; -}; - -void DirectoryEntry::dump(const std::wstring& file) const -{ - try { - std::string narrowFile(file.begin(), file.end()); - std::FILE* f = std::fopen(narrowFile.c_str(), "wb"); - if (!f) { - auto e = errno; - throw DumpFailed(std::format("failed to open, {} ({})", std::strerror(e), e)); - } - - Guard g([&] { - std::fclose(f); - }); - - dump(f, L"Data"); - } catch (DumpFailed& e) { - log::error("failed to write list to '{}': {}", - QString::fromStdWString(file).toStdString(), e.what()); - } -} - -void DirectoryEntry::dump(std::FILE* f, const std::wstring& parentPath) const -{ - { - std::scoped_lock lock(m_FilesMutex); - - for (auto&& index : m_Files) { - const auto file = m_FileRegister->getFile(index.second); - if (!file) { - continue; - } - - if (file->isFromArchive()) { - // TODO: don't list files from archives. maybe make this an option? - continue; - } - - const auto& o = m_OriginConnection->getByID(file->getOrigin()); - const auto path = parentPath + NativeWPathSep + file->getName(); - const auto line = path + L"\t(" + o.getName() + L")\r\n"; - - const auto lineu8 = MOShared::ToString(line, true); - - if (std::fwrite(lineu8.data(), lineu8.size(), 1, f) != 1) { - const auto e = errno; - throw DumpFailed(std::format("failed to write, {} ({})", std::strerror(e), e)); - } - } - } - - { - std::scoped_lock lock(m_SubDirMutex); - for (auto&& d : m_SubDirectories) { - const auto path = parentPath + NativeWPathSep + d->m_Name; - d->dump(f, path); - } - } -} - -} // namespace MOShared +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "directoryentry.h" +#include "../envfs.h" +#include "fileentry.h" +#include "filesorigin.h" +#include "originconnection.h" +#include "util.h" +#include "windows_error.h" +#include +#include +#include + +namespace MOShared +{ + +using namespace MOBase; +const int MAXPATH_UNICODE = 32767; + +template +void elapsedImpl(std::chrono::nanoseconds& out, F&& f) +{ + if constexpr (DirectoryStats::EnableInstrumentation) { + const auto start = std::chrono::high_resolution_clock::now(); + f(); + const auto end = std::chrono::high_resolution_clock::now(); + out += (end - start); + } else { + f(); + } +} + +// elapsed() is not optimized out when EnableInstrumentation is false even +// though it's equivalent that this macro +#define elapsed(OUT, F) (F)(); +// #define elapsed(OUT, F) elapsedImpl(OUT, F); + +static bool SupportOptimizedFind() +{ + return true; +} + +bool DirCompareByName::operator()(const DirectoryEntry* lhs, + const DirectoryEntry* rhs) const +{ + return wcscasecmp(lhs->getName().c_str(), rhs->getName().c_str()) < 0; +} + +DirectoryEntry::DirectoryEntry(std::wstring name, DirectoryEntry* parent, int originID) + : m_OriginConnection(new OriginConnection), m_Name(std::move(name)), + m_Parent(parent), m_Populated(false), m_TopLevel(true) +{ + m_FileRegister.reset(new FileRegister(m_OriginConnection)); + m_Origins.insert(originID); +} + +DirectoryEntry::DirectoryEntry(std::wstring name, DirectoryEntry* parent, int originID, + boost::shared_ptr fileRegister, + boost::shared_ptr originConnection) + : m_FileRegister(fileRegister), m_OriginConnection(originConnection), + m_Name(std::move(name)), m_Parent(parent), m_Populated(false), m_TopLevel(false) +{ + m_Origins.insert(originID); +} + +DirectoryEntry::~DirectoryEntry() +{ + clear(); +} + +void DirectoryEntry::clear() +{ + for (auto itor = m_SubDirectories.rbegin(); itor != m_SubDirectories.rend(); ++itor) { + delete *itor; + } + + m_Files.clear(); + m_FilesLookup.clear(); + m_SubDirectories.clear(); + m_SubDirectoriesLookup.clear(); +} + +void DirectoryEntry::addFromOrigin(const std::wstring& originName, + const std::wstring& directory, int priority, + DirectoryStats& stats) +{ + env::DirectoryWalker walker; + addFromOrigin(walker, originName, directory, priority, stats); +} + +void DirectoryEntry::addFromOrigin(env::DirectoryWalker& walker, + const std::wstring& originName, + const std::wstring& directory, int priority, + DirectoryStats& stats) +{ + FilesOrigin& origin = createOrigin(originName, directory, priority, stats); + + if (!directory.empty()) { + addFiles(walker, origin, directory, stats); + } + + m_Populated = true; +} + +void DirectoryEntry::addFromList(const std::wstring& originName, + const std::wstring& directory, env::Directory& root, + int priority, DirectoryStats& stats) +{ + stats = {}; + + FilesOrigin& origin = createOrigin(originName, directory, priority, stats); + addDir(origin, root, stats); +} + +void DirectoryEntry::addDir(FilesOrigin& origin, env::Directory& d, + DirectoryStats& stats) +{ + elapsed(stats.dirTimes, [&] { + for (auto& sd : d.dirs) { + auto* sdirEntry = getSubDirectory(sd, true, stats, origin.getID()); + sdirEntry->addDir(origin, sd, stats); + } + }); + + elapsed(stats.fileTimes, [&] { + for (auto& f : d.files) { + insert(f, origin, L"", -1, stats); + } + }); + + m_Populated = true; +} + +void DirectoryEntry::addFromAllBSAs(const std::wstring& originName, + const std::wstring& directory, int priority, + const std::vector& archives, + const std::set& enabledArchives, + const std::vector& loadOrder, + DirectoryStats& stats) +{ + for (const auto& archive : archives) { + const std::filesystem::path archivePath(archive); + const auto filename = archivePath.filename().wstring(); + + if (!enabledArchives.contains(filename)) { + continue; + } + + const auto filenameLc = ToLowerCopy(filename); + + int order = -1; + + for (auto plugin : loadOrder) { + const auto pluginNameLc = + ToLowerCopy(std::filesystem::path(plugin).stem().wstring()); + + if (filenameLc.starts_with(pluginNameLc + L" - ") || + filenameLc.starts_with(pluginNameLc + L".")) { + auto itor = std::find(loadOrder.begin(), loadOrder.end(), plugin); + if (itor != loadOrder.end()) { + order = std::distance(loadOrder.begin(), itor); + } + } + } + + addFromBSA(originName, directory, archivePath.wstring(), priority, order, stats); + } +} + +void DirectoryEntry::addFromBSA(const std::wstring& originName, + const std::wstring& directory, + const std::wstring& archivePath, int priority, + int order, DirectoryStats& stats) +{ + FilesOrigin& origin = createOrigin(originName, directory, priority, stats); + const auto archiveName = std::filesystem::path(archivePath).filename().wstring(); + + if (containsArchive(archiveName)) { + return; + } + + BSA::Archive archive; + BSA::EErrorCode res = BSA::ERROR_NONE; + + try { + // read() can return an error, but it can also throw if the file is not a + // valid bsa + res = archive.read(ToString(archivePath, false).c_str(), false); + } catch (std::exception& e) { + log::error("invalid bsa '{}', error {}", archivePath, e.what()); + return; + } + + if ((res != BSA::ERROR_NONE) && (res != BSA::ERROR_INVALIDHASHES)) { + log::error("invalid bsa '{}', error {}", archivePath, res); + return; + } + + std::error_code ec; + const auto lwt = std::filesystem::last_write_time(archivePath, ec); + FILETIME ft = {}; + + if (ec) { + log::warn("failed to get last modified date for '{}', {}", archivePath, + ec.message()); + } else { + ft = ToFILETIME(lwt); + } + + addFiles(origin, archive.getRoot(), ft, archiveName, order, stats); + + m_Populated = true; +} + +void DirectoryEntry::propagateOrigin(int origin) +{ + { + std::scoped_lock lock(m_OriginsMutex); + m_Origins.insert(origin); + } + + if (m_Parent != nullptr) { + m_Parent->propagateOrigin(origin); + } +} + +bool DirectoryEntry::originExists(const std::wstring& name) const +{ + return m_OriginConnection->exists(name); +} + +FilesOrigin& DirectoryEntry::getOriginByID(int ID) const +{ + return m_OriginConnection->getByID(ID); +} + +FilesOrigin& DirectoryEntry::getOriginByName(const std::wstring& name) const +{ + return m_OriginConnection->getByName(name); +} + +const FilesOrigin* DirectoryEntry::findOriginByID(int ID) const +{ + return m_OriginConnection->findByID(ID); +} + +int DirectoryEntry::anyOrigin() const +{ + bool ignore; + + for (auto iter = m_Files.begin(); iter != m_Files.end(); ++iter) { + FileEntryPtr entry = m_FileRegister->getFile(iter->second); + if ((entry.get() != nullptr) && !entry->isFromArchive()) { + return entry->getOrigin(ignore); + } + } + + // if we got here, no file directly within this directory is a valid indicator for a + // mod, thus we continue looking in subdirectories + for (DirectoryEntry* entry : m_SubDirectories) { + int res = entry->anyOrigin(); + if (res != InvalidOriginID) { + return res; + } + } + + return *(m_Origins.begin()); +} + +std::vector DirectoryEntry::getFiles() const +{ + std::vector result; + result.reserve(m_Files.size()); + + for (auto iter = m_Files.begin(); iter != m_Files.end(); ++iter) { + result.push_back(m_FileRegister->getFile(iter->second)); + } + + return result; +} + +DirectoryEntry* DirectoryEntry::findSubDirectory(const std::wstring& name, + bool alreadyLowerCase) const +{ + SubDirectoriesLookup::const_iterator itor; + + if (alreadyLowerCase) { + itor = m_SubDirectoriesLookup.find(name); + } else { + itor = m_SubDirectoriesLookup.find(ToLowerCopy(name)); + } + + if (itor == m_SubDirectoriesLookup.end()) { + return nullptr; + } + + return itor->second; +} + +DirectoryEntry* DirectoryEntry::findSubDirectoryRecursive(const std::wstring& path) +{ + DirectoryStats dummy; + return getSubDirectoryRecursive(path, false, dummy, InvalidOriginID); +} + +const FileEntryPtr DirectoryEntry::findFile(const std::wstring& name, + bool alreadyLowerCase) const +{ + FilesLookup::const_iterator iter; + + if (alreadyLowerCase) { + iter = m_FilesLookup.find(DirectoryEntryFileKey(name)); + } else { + iter = m_FilesLookup.find(DirectoryEntryFileKey(ToLowerCopy(name))); + } + + if (iter != m_FilesLookup.end()) { + return m_FileRegister->getFile(iter->second); + } else { + return FileEntryPtr(); + } +} + +const FileEntryPtr DirectoryEntry::findFile(const DirectoryEntryFileKey& key) const +{ + auto iter = m_FilesLookup.find(key); + + if (iter != m_FilesLookup.end()) { + return m_FileRegister->getFile(iter->second); + } else { + return FileEntryPtr(); + } +} + +bool DirectoryEntry::hasFile(const std::wstring& name) const +{ + return m_Files.contains(ToLowerCopy(name)); +} + +bool DirectoryEntry::containsArchive(std::wstring archiveName) +{ + for (auto iter = m_Files.begin(); iter != m_Files.end(); ++iter) { + FileEntryPtr entry = m_FileRegister->getFile(iter->second); + if (entry->isFromArchive(archiveName)) { + return true; + } + } + + return false; +} + +const FileEntryPtr DirectoryEntry::searchFile(const std::wstring& path, + const DirectoryEntry** directory) const +{ + if (directory != nullptr) { + *directory = nullptr; + } + + if ((path.empty()) || (path == L"*")) { + // no file name -> the path ended on a (back-)slash + if (directory != nullptr) { + *directory = this; + } + + return FileEntryPtr(); + } + + const size_t len = path.find_first_of(L"\\/"); + + if (len == std::string::npos) { + // no more path components + auto iter = m_Files.find(ToLowerCopy(path)); + + if (iter != m_Files.end()) { + return m_FileRegister->getFile(iter->second); + } else if (directory != nullptr) { + DirectoryEntry* temp = findSubDirectory(path); + if (temp != nullptr) { + *directory = temp; + } + } + } else { + // file is in a subdirectory, recurse into the matching subdirectory + std::wstring pathComponent = path.substr(0, len); + DirectoryEntry* temp = findSubDirectory(pathComponent); + + if (temp != nullptr) { + if (len >= path.size()) { + log::error("{}", QObject::tr("unexpected end of path")); + return FileEntryPtr(); + } + + return temp->searchFile(path.substr(len + 1), directory); + } + } + + return FileEntryPtr(); +} + +void DirectoryEntry::removeFile(FileIndex index) +{ + removeFileFromList(index); +} + +bool DirectoryEntry::removeFile(const std::wstring& filePath, int* origin) +{ + size_t pos = filePath.find_first_of(L"\\/"); + + if (pos == std::string::npos) { + return this->remove(filePath, origin); + } + + std::wstring dirName = filePath.substr(0, pos); + std::wstring rest = filePath.substr(pos + 1); + + DirectoryStats dummy; + DirectoryEntry* entry = getSubDirectoryRecursive(dirName, false, dummy); + + if (entry != nullptr) { + return entry->removeFile(rest, origin); + } else { + return false; + } +} + +void DirectoryEntry::removeDir(const std::wstring& path) +{ + size_t pos = path.find_first_of(L"\\/"); + + if (pos == std::string::npos) { + for (auto iter = m_SubDirectories.begin(); iter != m_SubDirectories.end(); ++iter) { + DirectoryEntry* entry = *iter; + + if (CaseInsensitiveEqual(entry->getName(), path)) { + entry->removeDirRecursive(); + removeDirectoryFromList(iter); + delete entry; + break; + } + } + } else { + std::wstring dirName = path.substr(0, pos); + std::wstring rest = path.substr(pos + 1); + + DirectoryStats dummy; + DirectoryEntry* entry = getSubDirectoryRecursive(dirName, false, dummy); + + if (entry != nullptr) { + entry->removeDir(rest); + } + } +} + +bool DirectoryEntry::remove(const std::wstring& fileName, int* origin) +{ + const auto lcFileName = ToLowerCopy(fileName); + + auto iter = m_Files.find(lcFileName); + bool b = false; + + if (iter != m_Files.end()) { + if (origin != nullptr) { + FileEntryPtr entry = m_FileRegister->getFile(iter->second); + if (entry.get() != nullptr) { + bool ignore; + *origin = entry->getOrigin(ignore); + } + } + + b = m_FileRegister->removeFile(iter->second); + } + + return b; +} + +bool DirectoryEntry::hasContentsFromOrigin(int originID) const +{ + return m_Origins.contains(originID); +} + +FilesOrigin& DirectoryEntry::createOrigin(const std::wstring& originName, + const std::wstring& directory, int priority, + DirectoryStats& stats) +{ + auto r = m_OriginConnection->getOrCreate(originName, directory, priority, + m_FileRegister, m_OriginConnection, stats); + + if (r.second) { + ++stats.originCreate; + } else { + ++stats.originExists; + } + + return r.first; +} + +void DirectoryEntry::removeFiles(const std::set& indices) +{ + removeFilesFromList(indices); +} + +FileEntryPtr DirectoryEntry::insert(std::wstring_view fileName, FilesOrigin& origin, + FILETIME fileTime, std::wstring_view archive, + int order, DirectoryStats& stats) +{ + std::wstring fileNameLower = ToLowerCopy(fileName); + FileEntryPtr fe; + + DirectoryEntryFileKey key(std::move(fileNameLower)); + + { + std::unique_lock lock(m_FilesMutex); + + FilesLookup::iterator itor; + + elapsed(stats.filesLookupTimes, [&] { + itor = m_FilesLookup.find(key); + }); + + if (itor != m_FilesLookup.end()) { + lock.unlock(); + ++stats.fileExists; + fe = m_FileRegister->getFile(itor->second); + } else { + ++stats.fileCreate; + fe = m_FileRegister->createFile(std::wstring(fileName.begin(), fileName.end()), + this, stats); + + elapsed(stats.addFileTimes, [&] { + addFileToList(std::move(key.value), fe->getIndex()); + }); + + // fileNameLower has moved from this point + } + } + + elapsed(stats.addOriginToFileTimes, [&] { + fe->addOrigin(origin.getID(), fileTime, archive, order); + }); + + elapsed(stats.addFileToOriginTimes, [&] { + origin.addFile(fe->getIndex()); + }); + + return fe; +} + +FileEntryPtr DirectoryEntry::insert(env::File& file, FilesOrigin& origin, + std::wstring_view archive, int order, + DirectoryStats& stats) +{ + FileEntryPtr fe; + + { + std::unique_lock lock(m_FilesMutex); + + FilesMap::iterator itor; + + elapsed(stats.filesLookupTimes, [&] { + itor = m_Files.find(file.lcname); + }); + + if (itor != m_Files.end()) { + lock.unlock(); + ++stats.fileExists; + fe = m_FileRegister->getFile(itor->second); + } else { + ++stats.fileCreate; + fe = m_FileRegister->createFile(std::move(file.name), this, stats); + // file.name has been moved from this point + + elapsed(stats.addFileTimes, [&] { + addFileToList(std::move(file.lcname), fe->getIndex()); + }); + + // file.lcname has been moved from this point + } + } + + elapsed(stats.addOriginToFileTimes, [&] { + fe->addOrigin(origin.getID(), file.lastModified, archive, order); + }); + + elapsed(stats.addFileToOriginTimes, [&] { + origin.addFile(fe->getIndex()); + }); + + return fe; +} + +struct DirectoryEntry::Context +{ + FilesOrigin& origin; + DirectoryStats& stats; + std::stack current; +}; + +void DirectoryEntry::addFiles(env::DirectoryWalker& walker, FilesOrigin& origin, + const std::wstring& path, DirectoryStats& stats) +{ + Context cx = {.origin=origin, .stats=stats}; + cx.current.push(this); + + // Convert wstring to narrow string for std::filesystem to avoid + // locale-dependent wchar_t conversion issues on glibc. + const bool pathExists = + std::filesystem::exists(QString::fromStdWString(path).toStdString()); + + if (pathExists) { + env::DirectoryWalker::forEachEntry( + path, &cx, + [](void* pcx, std::wstring_view path) { + onDirectoryStart((Context*)pcx, path); + }, + + [](void* pcx, std::wstring_view path) { + onDirectoryEnd((Context*)pcx, path); + }, + + [](void* pcx, std::wstring_view path, FILETIME ft, uint64_t) { + onFile((Context*)pcx, path, ft); + }); + } +} + +void DirectoryEntry::onDirectoryStart(Context* cx, std::wstring_view path) +{ + elapsed(cx->stats.dirTimes, [&] { + auto* sd = + cx->current.top()->getSubDirectory(path, true, cx->stats, cx->origin.getID()); + + cx->current.push(sd); + }); +} + +void DirectoryEntry::onDirectoryEnd(Context* cx, std::wstring_view path) +{ + elapsed(cx->stats.dirTimes, [&] { + cx->current.pop(); + }); +} + +void DirectoryEntry::onFile(Context* cx, std::wstring_view path, FILETIME ft) +{ + elapsed(cx->stats.fileTimes, [&] { + cx->current.top()->insert(path, cx->origin, ft, L"", -1, cx->stats); + }); +} + +void DirectoryEntry::addFiles(FilesOrigin& origin, const BSA::Folder::Ptr archiveFolder, + FILETIME fileTime, const std::wstring& archiveName, + int order, DirectoryStats& stats) +{ + // add files + const auto fileCount = archiveFolder->getNumFiles(); + for (unsigned int i = 0; i < fileCount; ++i) { + const BSA::File::Ptr file = archiveFolder->getFile(i); + + auto f = insert(ToWString(file->getName(), true), origin, fileTime, archiveName, + order, stats); + + if (f) { + if (file->getUncompressedFileSize() > 0) { + f->setFileSize(file->getFileSize(), file->getUncompressedFileSize()); + } else { + f->setFileSize(file->getFileSize(), FileEntry::NoFileSize); + } + } + } + + // recurse into subdirectories + const auto dirCount = archiveFolder->getNumSubFolders(); + for (unsigned int i = 0; i < dirCount; ++i) { + const BSA::Folder::Ptr folder = archiveFolder->getSubFolder(i); + + DirectoryEntry* folderEntry = getSubDirectoryRecursive( + ToWString(folder->getName(), true), true, stats, origin.getID()); + + folderEntry->addFiles(origin, folder, fileTime, archiveName, order, stats); + } +} + +DirectoryEntry* DirectoryEntry::getSubDirectory(std::wstring_view name, bool create, + DirectoryStats& stats, int originID) +{ + std::wstring nameLc = ToLowerCopy(name); + + std::scoped_lock lock(m_SubDirMutex); + + SubDirectoriesLookup::iterator itor; + elapsed(stats.subdirLookupTimes, [&] { + itor = m_SubDirectoriesLookup.find(nameLc); + }); + + if (itor != m_SubDirectoriesLookup.end()) { + ++stats.subdirExists; + return itor->second; + } + + if (create) { + ++stats.subdirCreate; + + auto* entry = new DirectoryEntry(std::wstring(name.begin(), name.end()), this, + originID, m_FileRegister, m_OriginConnection); + + elapsed(stats.addDirectoryTimes, [&] { + addDirectoryToList(entry, std::move(nameLc)); + // nameLc is moved from this point + }); + + return entry; + } else { + return nullptr; + } +} + +DirectoryEntry* DirectoryEntry::getSubDirectory(env::Directory& dir, bool create, + DirectoryStats& stats, int originID) +{ + SubDirectoriesLookup::iterator itor; + + std::scoped_lock lock(m_SubDirMutex); + + elapsed(stats.subdirLookupTimes, [&] { + itor = m_SubDirectoriesLookup.find(dir.lcname); + }); + + if (itor != m_SubDirectoriesLookup.end()) { + ++stats.subdirExists; + return itor->second; + } + + if (create) { + ++stats.subdirCreate; + + auto* entry = new DirectoryEntry(std::move(dir.name), this, originID, + m_FileRegister, m_OriginConnection); + // dir.name is moved from this point + + elapsed(stats.addDirectoryTimes, [&] { + addDirectoryToList(entry, std::move(dir.lcname)); + }); + + // dir.lcname is moved from this point + + return entry; + } else { + return nullptr; + } +} + +DirectoryEntry* DirectoryEntry::getSubDirectoryRecursive(const std::wstring& path, + bool create, + DirectoryStats& stats, + int originID) +{ + if (path.empty()) { + // path ended with a backslash? + return this; + } + + const size_t pos = path.find_first_of(L"\\/"); + + if (pos == std::wstring::npos) { + return getSubDirectory(path, create, stats); + } else { + DirectoryEntry* nextChild = + getSubDirectory(path.substr(0, pos), create, stats, originID); + + if (nextChild == nullptr) { + return nullptr; + } else { + return nextChild->getSubDirectoryRecursive(path.substr(pos + 1), create, stats, + originID); + } + } +} + +void DirectoryEntry::removeDirRecursive() +{ + while (!m_Files.empty()) { + m_FileRegister->removeFile(m_Files.begin()->second); + } + + m_FilesLookup.clear(); + + for (DirectoryEntry* entry : m_SubDirectories) { + entry->removeDirRecursive(); + delete entry; + } + + m_SubDirectories.clear(); + m_SubDirectoriesLookup.clear(); +} + +void DirectoryEntry::addDirectoryToList(DirectoryEntry* e, std::wstring nameLc) +{ + m_SubDirectories.insert(e); + m_SubDirectoriesLookup.emplace(std::move(nameLc), e); +} + +void DirectoryEntry::removeDirectoryFromList(SubDirectories::iterator itor) +{ + const auto* entry = *itor; + + { + auto itor2 = std::find_if(m_SubDirectoriesLookup.begin(), + m_SubDirectoriesLookup.end(), [&](auto&& d) { + return (d.second == entry); + }); + + if (itor2 == m_SubDirectoriesLookup.end()) { + log::error("entry {} not in sub directories map", entry->getName()); + } else { + m_SubDirectoriesLookup.erase(itor2); + } + } + + m_SubDirectories.erase(itor); +} + +void DirectoryEntry::removeFileFromList(FileIndex index) +{ + auto removeFrom = [&](auto& list) { + auto iter = std::find_if(list.begin(), list.end(), [&index](auto&& pair) { + return (pair.second == index); + }); + + if (iter == list.end()) { + auto f = m_FileRegister->getFile(index); + + if (f) { + log::error("can't remove file '{}', not in directory entry '{}'", f->getName(), + getName()); + } else { + log::error("can't remove file with index {}, not in directory entry '{}' and " + "not in register", + index, getName()); + } + } else { + list.erase(iter); + } + }; + + removeFrom(m_FilesLookup); + removeFrom(m_Files); +} + +void DirectoryEntry::removeFilesFromList(const std::set& indices) +{ + for (auto iter = m_Files.begin(); iter != m_Files.end();) { + if (indices.contains(iter->second)) { + iter = m_Files.erase(iter); + } else { + ++iter; + } + } + + for (auto iter = m_FilesLookup.begin(); iter != m_FilesLookup.end();) { + if (indices.contains(iter->second)) { + iter = m_FilesLookup.erase(iter); + } else { + ++iter; + } + } +} + +void DirectoryEntry::addFileToList(std::wstring fileNameLower, FileIndex index) +{ + m_FilesLookup.emplace(fileNameLower, index); + m_Files.emplace(std::move(fileNameLower), index); + // fileNameLower has been moved from this point +} + +struct DumpFailed : public std::runtime_error +{ + using runtime_error::runtime_error; +}; + +void DirectoryEntry::dump(const std::wstring& file) const +{ + try { + std::string narrowFile(file.begin(), file.end()); + std::FILE* f = std::fopen(narrowFile.c_str(), "wb"); + if (!f) { + auto e = errno; + throw DumpFailed(std::format("failed to open, {} ({})", std::strerror(e), e)); + } + + Guard g([&] { + std::fclose(f); + }); + + dump(f, L"Data"); + } catch (DumpFailed& e) { + log::error("failed to write list to '{}': {}", + QString::fromStdWString(file).toStdString(), e.what()); + } +} + +void DirectoryEntry::dump(std::FILE* f, const std::wstring& parentPath) const +{ + { + std::scoped_lock lock(m_FilesMutex); + + for (auto&& index : m_Files) { + const auto file = m_FileRegister->getFile(index.second); + if (!file) { + continue; + } + + if (file->isFromArchive()) { + // TODO: don't list files from archives. maybe make this an option? + continue; + } + + const auto& o = m_OriginConnection->getByID(file->getOrigin()); + const auto path = parentPath + NativeWPathSep + file->getName(); + const auto line = path + L"\t(" + o.getName() + L")\r\n"; + + const auto lineu8 = MOShared::ToString(line, true); + + if (std::fwrite(lineu8.data(), lineu8.size(), 1, f) != 1) { + const auto e = errno; + throw DumpFailed(std::format("failed to write, {} ({})", std::strerror(e), e)); + } + } + } + + { + std::scoped_lock lock(m_SubDirMutex); + for (auto&& d : m_SubDirectories) { + const auto path = parentPath + NativeWPathSep + d->m_Name; + d->dump(f, path); + } + } +} + +} // namespace MOShared diff --git a/src/src/shared/fileentry.cpp b/src/src/shared/fileentry.cpp index 6f3e5d7..e3d6ef6 100644 --- a/src/src/shared/fileentry.cpp +++ b/src/src/shared/fileentry.cpp @@ -1,256 +1,256 @@ -#include "fileentry.h" -#include "directoryentry.h" -#include "filesorigin.h" -#include "util.h" - -namespace MOShared -{ - -FileEntry::FileEntry() - : m_Index(InvalidFileIndex), m_Origin(-1), m_Parent(nullptr), - m_FileSize(NoFileSize), m_CompressedFileSize(NoFileSize) -{} - -FileEntry::FileEntry(FileIndex index, std::wstring name, DirectoryEntry* parent) - : m_Index(index), m_Name(std::move(name)), m_Origin(-1), m_Archive(L"", -1), - m_Parent(parent), m_FileSize(NoFileSize), m_CompressedFileSize(NoFileSize) -{} - -void FileEntry::addOrigin(OriginID origin, FILETIME fileTime, std::wstring_view archive, - int order) -{ - std::scoped_lock lock(m_OriginsMutex); - - if (m_Parent != nullptr) { - m_Parent->propagateOrigin(origin); - } - - if (m_Origin == -1) { - // If this file has no previous origin, this mod is now the origin with no - // alternatives - m_Origin = origin; - m_FileTime = fileTime; - m_Archive = DataArchiveOrigin(std::wstring(archive.begin(), archive.end()), order); - } else if ((m_Parent != nullptr) && - ((m_Parent->getOriginByID(origin).getPriority() > - m_Parent->getOriginByID(m_Origin).getPriority()) || - (archive.empty() && m_Archive.isValid()))) { - // If this mod has a higher priority than the origin mod OR - // this mod has a loose file and the origin mod has an archived file, - // this mod is now the origin and the previous origin is the first alternative - - auto itor = - std::find_if(m_Alternatives.begin(), m_Alternatives.end(), [&](auto&& i) { - return i.originID() == m_Origin; - }); - - if (itor == m_Alternatives.end()) { - m_Alternatives.push_back({m_Origin, m_Archive}); - } - - m_Origin = origin; - m_FileTime = fileTime; - m_Archive = DataArchiveOrigin(std::wstring(archive.begin(), archive.end()), order); - } else { - // This mod is just an alternative - bool found = false; - - if (m_Origin == origin) { - // already an origin - return; - } - - for (auto iter = m_Alternatives.begin(); iter != m_Alternatives.end(); ++iter) { - if (iter->originID() == origin) { - // already an origin - return; - } - - if ((m_Parent != nullptr) && - (m_Parent->getOriginByID(iter->originID()).getPriority() < - m_Parent->getOriginByID(origin).getPriority())) { - m_Alternatives.insert( - iter, {origin, {std::wstring(archive.begin(), archive.end()), order}}); - found = true; - break; - } - } - - if (!found) { - m_Alternatives.push_back( - {origin, {std::wstring(archive.begin(), archive.end()), order}}); - } - } -} - -bool FileEntry::removeOrigin(OriginID origin) -{ - std::scoped_lock lock(m_OriginsMutex); - - if (m_Origin == origin) { - if (!m_Alternatives.empty()) { - // find alternative with the highest priority - auto currentIter = m_Alternatives.begin(); - for (auto iter = m_Alternatives.begin(); iter != m_Alternatives.end(); ++iter) { - if (iter->originID() != origin) { - // Both files are not from archives. - if (!iter->isFromArchive() && !currentIter->isFromArchive()) { - if ((m_Parent->getOriginByID(iter->originID()).getPriority() > - m_Parent->getOriginByID(currentIter->originID()).getPriority())) { - currentIter = iter; - } - } else { - // Both files are from archives - if (iter->isFromArchive() && currentIter->isFromArchive()) { - if (iter->archive().order() > currentIter->archive().order()) { - currentIter = iter; - } - } else { - // Only one of the two is an archive, so we change currentIter only if he - // is the archive one. - if (currentIter->isFromArchive()) { - currentIter = iter; - } - } - } - } - } - - OriginID currentID = currentIter->originID(); - m_Archive = currentIter->archive(); - m_Alternatives.erase(currentIter); - - m_Origin = currentID; - } else { - m_Origin = -1; - m_Archive = DataArchiveOrigin(L"", -1); - return true; - } - } else { - auto newEnd = - std::remove_if(m_Alternatives.begin(), m_Alternatives.end(), [&](auto& i) { - return i.originID() == origin; - }); - - if (newEnd != m_Alternatives.end()) { - m_Alternatives.erase(newEnd, m_Alternatives.end()); - } - } - return false; -} - -void FileEntry::sortOrigins() -{ - std::scoped_lock lock(m_OriginsMutex); - - m_Alternatives.push_back({m_Origin, m_Archive}); - - std::sort(m_Alternatives.begin(), m_Alternatives.end(), [&](auto&& LHS, auto&& RHS) { - if (!LHS.isFromArchive() && !RHS.isFromArchive()) { - int l = m_Parent->getOriginByID(LHS.originID()).getPriority(); - if (l < 0) { - l = INT_MAX; - } - - int r = m_Parent->getOriginByID(RHS.originID()).getPriority(); - if (r < 0) { - r = INT_MAX; - } - - return l < r; - } - - if (LHS.isFromArchive() && RHS.isFromArchive()) { - int l = LHS.archive().order(); - if (l < 0) - l = INT_MAX; - int r = RHS.archive().order(); - if (r < 0) - r = INT_MAX; - - return l < r; - } - - if (RHS.isFromArchive()) { - return false; - } - - return true; - }); - - if (!m_Alternatives.empty()) { - m_Origin = m_Alternatives.back().originID(); - m_Archive = m_Alternatives.back().archive(); - m_Alternatives.pop_back(); - } -} - -bool FileEntry::isFromArchive(std::wstring archiveName) const -{ - std::scoped_lock lock(m_OriginsMutex); - - if (archiveName.empty()) { - return m_Archive.isValid(); - } - - if (m_Archive.name().compare(archiveName) == 0) { - return true; - } - - for (const auto& alternative : m_Alternatives) { - if (alternative.archive().name().compare(archiveName) == 0) { - return true; - } - } - - return false; -} - -std::wstring FileEntry::getFullPath(OriginID originID) const -{ - std::scoped_lock lock(m_OriginsMutex); - - if (originID == InvalidOriginID) { - bool ignore = false; - originID = getOrigin(ignore); - } - - // base directory for origin - const auto* o = m_Parent->findOriginByID(originID); - if (!o) { - return {}; - } - - std::wstring result = o->getPath(); - - // all intermediate directories - recurseParents(result, m_Parent); - - return result + NativeWPathSep + m_Name; -} - -std::wstring FileEntry::getRelativePath() const -{ - std::wstring result; - - // all intermediate directories - recurseParents(result, m_Parent); - - return result + NativeWPathSep + m_Name; -} - -bool FileEntry::recurseParents(std::wstring& path, const DirectoryEntry* parent) const -{ - if (parent == nullptr) { - return false; - } else { - // don't append the topmost parent because it is the virtual data-root - if (recurseParents(path, parent->getParent())) { - path.append(1, NativeWPathSep).append(parent->getName()); - } - - return true; - } -} - -} // namespace MOShared +#include "fileentry.h" +#include "directoryentry.h" +#include "filesorigin.h" +#include "util.h" + +namespace MOShared +{ + +FileEntry::FileEntry() + : m_Index(InvalidFileIndex), m_Origin(-1), m_Parent(nullptr), + m_FileSize(NoFileSize), m_CompressedFileSize(NoFileSize) +{} + +FileEntry::FileEntry(FileIndex index, std::wstring name, DirectoryEntry* parent) + : m_Index(index), m_Name(std::move(name)), m_Origin(-1), m_Archive(L"", -1), + m_Parent(parent), m_FileSize(NoFileSize), m_CompressedFileSize(NoFileSize) +{} + +void FileEntry::addOrigin(OriginID origin, FILETIME fileTime, std::wstring_view archive, + int order) +{ + std::scoped_lock lock(m_OriginsMutex); + + if (m_Parent != nullptr) { + m_Parent->propagateOrigin(origin); + } + + if (m_Origin == -1) { + // If this file has no previous origin, this mod is now the origin with no + // alternatives + m_Origin = origin; + m_FileTime = fileTime; + m_Archive = DataArchiveOrigin(std::wstring(archive.begin(), archive.end()), order); + } else if ((m_Parent != nullptr) && + ((m_Parent->getOriginByID(origin).getPriority() > + m_Parent->getOriginByID(m_Origin).getPriority()) || + (archive.empty() && m_Archive.isValid()))) { + // If this mod has a higher priority than the origin mod OR + // this mod has a loose file and the origin mod has an archived file, + // this mod is now the origin and the previous origin is the first alternative + + auto itor = + std::find_if(m_Alternatives.begin(), m_Alternatives.end(), [&](auto&& i) { + return i.originID() == m_Origin; + }); + + if (itor == m_Alternatives.end()) { + m_Alternatives.emplace_back(m_Origin, m_Archive); + } + + m_Origin = origin; + m_FileTime = fileTime; + m_Archive = DataArchiveOrigin(std::wstring(archive.begin(), archive.end()), order); + } else { + // This mod is just an alternative + bool found = false; + + if (m_Origin == origin) { + // already an origin + return; + } + + for (auto iter = m_Alternatives.begin(); iter != m_Alternatives.end(); ++iter) { + if (iter->originID() == origin) { + // already an origin + return; + } + + if ((m_Parent != nullptr) && + (m_Parent->getOriginByID(iter->originID()).getPriority() < + m_Parent->getOriginByID(origin).getPriority())) { + m_Alternatives.insert( + iter, {origin, {std::wstring(archive.begin(), archive.end()), order}}); + found = true; + break; + } + } + + if (!found) { + m_Alternatives.push_back( + {origin, {std::wstring(archive.begin(), archive.end()), order}}); + } + } +} + +bool FileEntry::removeOrigin(OriginID origin) +{ + std::scoped_lock lock(m_OriginsMutex); + + if (m_Origin == origin) { + if (!m_Alternatives.empty()) { + // find alternative with the highest priority + auto currentIter = m_Alternatives.begin(); + for (auto iter = m_Alternatives.begin(); iter != m_Alternatives.end(); ++iter) { + if (iter->originID() != origin) { + // Both files are not from archives. + if (!iter->isFromArchive() && !currentIter->isFromArchive()) { + if ((m_Parent->getOriginByID(iter->originID()).getPriority() > + m_Parent->getOriginByID(currentIter->originID()).getPriority())) { + currentIter = iter; + } + } else { + // Both files are from archives + if (iter->isFromArchive() && currentIter->isFromArchive()) { + if (iter->archive().order() > currentIter->archive().order()) { + currentIter = iter; + } + } else { + // Only one of the two is an archive, so we change currentIter only if he + // is the archive one. + if (currentIter->isFromArchive()) { + currentIter = iter; + } + } + } + } + } + + OriginID currentID = currentIter->originID(); + m_Archive = currentIter->archive(); + m_Alternatives.erase(currentIter); + + m_Origin = currentID; + } else { + m_Origin = -1; + m_Archive = DataArchiveOrigin(L"", -1); + return true; + } + } else { + auto newEnd = + std::remove_if(m_Alternatives.begin(), m_Alternatives.end(), [&](auto& i) { + return i.originID() == origin; + }); + + if (newEnd != m_Alternatives.end()) { + m_Alternatives.erase(newEnd, m_Alternatives.end()); + } + } + return false; +} + +void FileEntry::sortOrigins() +{ + std::scoped_lock lock(m_OriginsMutex); + + m_Alternatives.emplace_back(m_Origin, m_Archive); + + std::sort(m_Alternatives.begin(), m_Alternatives.end(), [&](auto&& LHS, auto&& RHS) { + if (!LHS.isFromArchive() && !RHS.isFromArchive()) { + int l = m_Parent->getOriginByID(LHS.originID()).getPriority(); + if (l < 0) { + l = INT_MAX; + } + + int r = m_Parent->getOriginByID(RHS.originID()).getPriority(); + if (r < 0) { + r = INT_MAX; + } + + return l < r; + } + + if (LHS.isFromArchive() && RHS.isFromArchive()) { + int l = LHS.archive().order(); + if (l < 0) + l = INT_MAX; + int r = RHS.archive().order(); + if (r < 0) + r = INT_MAX; + + return l < r; + } + + if (RHS.isFromArchive()) { + return false; + } + + return true; + }); + + if (!m_Alternatives.empty()) { + m_Origin = m_Alternatives.back().originID(); + m_Archive = m_Alternatives.back().archive(); + m_Alternatives.pop_back(); + } +} + +bool FileEntry::isFromArchive(std::wstring archiveName) const +{ + std::scoped_lock lock(m_OriginsMutex); + + if (archiveName.empty()) { + return m_Archive.isValid(); + } + + if (m_Archive.name().compare(archiveName) == 0) { + return true; + } + + for (const auto& alternative : m_Alternatives) { + if (alternative.archive().name().compare(archiveName) == 0) { + return true; + } + } + + return false; +} + +std::wstring FileEntry::getFullPath(OriginID originID) const +{ + std::scoped_lock lock(m_OriginsMutex); + + if (originID == InvalidOriginID) { + bool ignore = false; + originID = getOrigin(ignore); + } + + // base directory for origin + const auto* o = m_Parent->findOriginByID(originID); + if (!o) { + return {}; + } + + std::wstring result = o->getPath(); + + // all intermediate directories + recurseParents(result, m_Parent); + + return result + NativeWPathSep + m_Name; +} + +std::wstring FileEntry::getRelativePath() const +{ + std::wstring result; + + // all intermediate directories + recurseParents(result, m_Parent); + + return result + NativeWPathSep + m_Name; +} + +bool FileEntry::recurseParents(std::wstring& path, const DirectoryEntry* parent) const +{ + if (parent == nullptr) { + return false; + } else { + // don't append the topmost parent because it is the virtual data-root + if (recurseParents(path, parent->getParent())) { + path.append(1, NativeWPathSep).append(parent->getName()); + } + + return true; + } +} + +} // namespace MOShared diff --git a/src/src/shared/fileregisterfwd.h b/src/src/shared/fileregisterfwd.h index 940eb9c..e03b24e 100644 --- a/src/src/shared/fileregisterfwd.h +++ b/src/src/shared/fileregisterfwd.h @@ -1,121 +1,121 @@ -#ifndef MO_REGISTER_FILEREGISTERFWD_INCLUDED -#define MO_REGISTER_FILEREGISTERFWD_INCLUDED - -class DirectoryRefreshProgress; - -namespace MOShared -{ - -struct DirectoryEntryFileKey -{ - DirectoryEntryFileKey(std::wstring v) : value(std::move(v)), hash(getHash(value)) {} - - bool operator==(const DirectoryEntryFileKey& o) const { return (value == o.value); } - - static std::size_t getHash(const std::wstring& value) - { - return std::hash()(value); - } - - std::wstring value; - const std::size_t hash; -}; - -class DirectoryEntry; -class OriginConnection; -class FileRegister; -class FilesOrigin; -class FileEntry; -struct DirectoryStats; - -using FileEntryPtr = boost::shared_ptr; -using FileIndex = unsigned int; -using OriginID = int; - -constexpr FileIndex InvalidFileIndex = UINT_MAX; -constexpr OriginID InvalidOriginID = -1; - -// if a file is in an archive, name is the name of the bsa and order -// is the order of the associated plugin in the plugins list -// is a file is not in an archive, archiveName is empty and order is usually -// -1 -class DataArchiveOrigin -{ - std::wstring name_ = L""; - int order_ = -1; - -public: - int order() const { return order_; } - const std::wstring& name() const { return name_; } - - bool isValid() const { return !name_.empty(); } - - DataArchiveOrigin(std::wstring name, int order) - : name_(std::move(name)), order_(order) - {} - - DataArchiveOrigin() = default; -}; - -class FileAlternative -{ - OriginID originID_ = -1; - DataArchiveOrigin archive_; - -public: - OriginID originID() const { return originID_; } - const DataArchiveOrigin& archive() const { return archive_; } - - bool isFromArchive() const { return archive_.isValid(); } - - FileAlternative() = default; - - FileAlternative(OriginID originID, DataArchiveOrigin archive) - : originID_(originID), archive_(std::move(archive)) - {} -}; - -using AlternativesVector = std::vector; - -struct DirectoryStats -{ - static constexpr bool EnableInstrumentation = false; - - std::string mod; - - std::chrono::nanoseconds dirTimes; - std::chrono::nanoseconds fileTimes; - std::chrono::nanoseconds sortTimes; - - std::chrono::nanoseconds subdirLookupTimes; - std::chrono::nanoseconds addDirectoryTimes; - - std::chrono::nanoseconds filesLookupTimes; - std::chrono::nanoseconds addFileTimes; - std::chrono::nanoseconds addOriginToFileTimes; - std::chrono::nanoseconds addFileToOriginTimes; - std::chrono::nanoseconds addFileToRegisterTimes; - - int64_t originExists; - int64_t originCreate; - int64_t originsNeededEnabled; - - int64_t subdirExists; - int64_t subdirCreate; - - int64_t fileExists; - int64_t fileCreate; - int64_t filesInsertedInRegister; - int64_t filesAssignedInRegister; - - DirectoryStats(); - - DirectoryStats& operator+=(const DirectoryStats& o); - - static std::string csvHeader(); - std::string toCsv() const; -}; - -} // namespace MOShared - -#endif // MO_REGISTER_FILEREGISTERFWD_INCLUDED +#ifndef MO_REGISTER_FILEREGISTERFWD_INCLUDED +#define MO_REGISTER_FILEREGISTERFWD_INCLUDED + +class DirectoryRefreshProgress; + +namespace MOShared +{ + +struct DirectoryEntryFileKey +{ + DirectoryEntryFileKey(std::wstring v) : value(std::move(v)), hash(getHash(value)) {} + + bool operator==(const DirectoryEntryFileKey& o) const { return (value == o.value); } + + static std::size_t getHash(const std::wstring& value) + { + return std::hash()(value); + } + + std::wstring value; + const std::size_t hash; +}; + +class DirectoryEntry; +class OriginConnection; +class FileRegister; +class FilesOrigin; +class FileEntry; +struct DirectoryStats; + +using FileEntryPtr = boost::shared_ptr; +using FileIndex = unsigned int; +using OriginID = int; + +constexpr FileIndex InvalidFileIndex = UINT_MAX; +constexpr OriginID InvalidOriginID = -1; + +// if a file is in an archive, name is the name of the bsa and order +// is the order of the associated plugin in the plugins list +// is a file is not in an archive, archiveName is empty and order is usually +// -1 +class DataArchiveOrigin +{ + std::wstring name_; + int order_ = -1; + +public: + int order() const { return order_; } + const std::wstring& name() const { return name_; } + + bool isValid() const { return !name_.empty(); } + + DataArchiveOrigin(std::wstring name, int order) + : name_(std::move(name)), order_(order) + {} + + DataArchiveOrigin() = default; +}; + +class FileAlternative +{ + OriginID originID_ = -1; + DataArchiveOrigin archive_; + +public: + OriginID originID() const { return originID_; } + const DataArchiveOrigin& archive() const { return archive_; } + + bool isFromArchive() const { return archive_.isValid(); } + + FileAlternative() = default; + + FileAlternative(OriginID originID, DataArchiveOrigin archive) + : originID_(originID), archive_(std::move(archive)) + {} +}; + +using AlternativesVector = std::vector; + +struct DirectoryStats +{ + static constexpr bool EnableInstrumentation = false; + + std::string mod; + + std::chrono::nanoseconds dirTimes; + std::chrono::nanoseconds fileTimes; + std::chrono::nanoseconds sortTimes; + + std::chrono::nanoseconds subdirLookupTimes; + std::chrono::nanoseconds addDirectoryTimes; + + std::chrono::nanoseconds filesLookupTimes; + std::chrono::nanoseconds addFileTimes; + std::chrono::nanoseconds addOriginToFileTimes; + std::chrono::nanoseconds addFileToOriginTimes; + std::chrono::nanoseconds addFileToRegisterTimes; + + int64_t originExists; + int64_t originCreate; + int64_t originsNeededEnabled; + + int64_t subdirExists; + int64_t subdirCreate; + + int64_t fileExists; + int64_t fileCreate; + int64_t filesInsertedInRegister; + int64_t filesAssignedInRegister; + + DirectoryStats(); + + DirectoryStats& operator+=(const DirectoryStats& o); + + static std::string csvHeader(); + std::string toCsv() const; +}; + +} // namespace MOShared + +#endif // MO_REGISTER_FILEREGISTERFWD_INCLUDED diff --git a/src/src/spawn.cpp b/src/src/spawn.cpp index f51c043..ef7d04b 100644 --- a/src/src/spawn.cpp +++ b/src/src/spawn.cpp @@ -47,7 +47,7 @@ along with Mod Organizer. If not, see . #include #include -#include +#include #include using namespace MOBase; @@ -735,12 +735,12 @@ FileExecutionTypes getFileExecutionType(const QFileInfo& target) FileExecutionContext getFileExecutionContext(QWidget* parent, const QFileInfo& target) { if (isExeFile(target)) { - return {target, "", FileExecutionTypes::Executable}; + return {.binary=target, .arguments="", .type=FileExecutionTypes::Executable}; } if (isBatchFile(target)) { - return {getCmdPath(), QString("\"%1\"").arg(target.absoluteFilePath()), - FileExecutionTypes::Executable}; + return {.binary=getCmdPath(), .arguments=QString("\"%1\"").arg(target.absoluteFilePath()), + .type=FileExecutionTypes::Executable}; } if (isJavaFile(target)) { @@ -752,14 +752,14 @@ FileExecutionContext getFileExecutionContext(QWidget* parent, const QFileInfo& t } if (!java.isEmpty()) { - return {QFileInfo(java), - QString("-jar \"%1\"") + return {.binary=QFileInfo(java), + .arguments=QString("-jar \"%1\"") .arg(QDir::toNativeSeparators(target.absoluteFilePath())), - FileExecutionTypes::Executable}; + .type=FileExecutionTypes::Executable}; } } - return {{}, {}, FileExecutionTypes::Other}; + return {.binary={}, .arguments={}, .type=FileExecutionTypes::Other}; } } // namespace spawn diff --git a/src/src/uilocker.cpp b/src/src/uilocker.cpp index 09fc5ac..ce984e6 100644 --- a/src/src/uilocker.cpp +++ b/src/src/uilocker.cpp @@ -1,635 +1,635 @@ -#include "uilocker.h" -#include "mainwindow.h" -#include -#include -#include - -class UILockerInterface -{ -public: - UILockerInterface(QWidget* mainUI) - : m_mainUI(mainUI) - { - m_timer.reset(new QTimer); - QObject::connect(m_timer.get(), &QTimer::timeout, [&] { - checkTarget(); - }); - m_timer->start(200); - - set(); - } - - ~UILockerInterface() - { - if (m_topLevel) { - delete m_topLevel.data(); - } - } - - void checkTarget() - { - if (set()) { - update(m_reason); - } - } - - bool set() - { - QWidget* newTarget = findTarget(); - if (m_topLevel && newTarget == m_target) { - return false; - } - - m_target = newTarget; - - QFrame* center = nullptr; - - if (m_target) { - center = createOverlay(m_target); - } else { - center = createDialog(); - } - - createMessageLabel(); - createInfoLabel(); - createButtonsPanel(); - - center->layout()->addWidget(m_message); - center->layout()->addWidget(m_info); - center->layout()->addWidget(m_buttons); - - m_topLevel->setFocusPolicy(Qt::TabFocus); - m_topLevel->setFocus(); - m_topLevel->show(); - m_topLevel->setEnabled(true); - - m_topLevel->raise(); - m_topLevel->activateWindow(); - - return true; - } - - void update(UILocker::Reasons reason) - { - m_reason = reason; - updateMessage(reason); - updateButtons(reason); - setInfo(m_labels); - } - - void setInfo(const QStringList& labels) - { - const int MaxLabels = 2; - - m_labels = labels; - - QString s; - - if (labels.size() > MaxLabels) { - s = labels.mid(0, MaxLabels).join(", ") + "..."; - } else { - s = labels.join(", "); - } - - m_info->setText(s); - } - - QWidget* topLevel() { return m_topLevel.data(); } - -private: - class Filter : public QObject - { - public: - std::function resized; - std::function closed; - - protected: - bool eventFilter(QObject* o, QEvent* e) override - { - if (e->type() == QEvent::Resize) { - if (resized) { - resized(); - } - } else if (e->type() == QEvent::Close) { - if (closed) { - closed(); - } - } - - return QObject::eventFilter(o, e); - } - }; - - std::unique_ptr m_timer; - QWidget* m_mainUI; - QWidget* m_target{nullptr}; - QPointer m_topLevel; - QLabel* m_message{nullptr}; - QLabel* m_info{nullptr}; - QStringList m_labels; - QWidget* m_buttons{nullptr}; - std::unique_ptr m_filter; - UILocker::Reasons m_reason{UILocker::NoReason}; - - bool hasMainUI() const { return (m_target != nullptr); } - - QWidget* findTarget() - { - auto isValidTarget = [](QWidget* w) { - // skip message boxes - if (dynamic_cast(w)) { - return false; - } - - // skip invisible widgets - if (!w->isVisible()) { - return false; - } - - // skip windows that are too small - if (w->height() < 150) { - return false; - } - - return true; - }; - - // find a modal dialog - QWidget* w = QApplication::activeModalWidget(); - - while (w && w != m_mainUI) { - if (isValidTarget(w)) { - return w; - } - - w = w->parentWidget(); - } - - // find a non-modal dialog that's a child of the main window - if (m_mainUI) { - const auto topLevels = QApplication::topLevelWidgets(); - - for (auto* w : topLevels) { - if (w && w->parentWidget() == m_mainUI) { - if (isValidTarget(w)) { - return w; - } - } - } - } - - return m_mainUI; - } - - static QWidget* createTransparentWidget(QWidget* parent = nullptr) - { - auto* w = new QWidget(parent); - - w->setWindowOpacity(0); - w->setAttribute(Qt::WA_NoSystemBackground); - w->setAttribute(Qt::WA_TranslucentBackground); - - return w; - } - - QFrame* createOverlay(QWidget* mainUI) - { - if (m_topLevel) { - delete m_topLevel.data(); - m_topLevel.clear(); - } - - m_topLevel = createTransparentWidget(mainUI); - m_topLevel->setWindowFlags(m_topLevel->windowFlags() & Qt::FramelessWindowHint); - m_topLevel->setGeometry(mainUI->rect()); - - m_filter.reset(new Filter); - m_filter->resized = [=, this] { - m_topLevel->setGeometry(mainUI->rect()); - }; - m_filter->closed = [=, this] { - checkTarget(); - }; - - mainUI->installEventFilter(m_filter.get()); - - return createFrame(); - } - - QFrame* createDialog() - { - if (m_topLevel) { - delete m_topLevel.data(); - m_topLevel.clear(); - } - - m_topLevel = new QDialog; - - return createFrame(); - } - - QFrame* createFrame() - { - auto* frame = new QFrame; - auto* ly = new QVBoxLayout(frame); - - if (hasMainUI()) { - frame->setFrameStyle(QFrame::StyledPanel); - frame->setLineWidth(1); - frame->setAutoFillBackground(true); - - auto* shadow = new QGraphicsDropShadowEffect; - shadow->setBlurRadius(50); - shadow->setOffset(0); - shadow->setColor(QColor(0, 0, 0, 100)); - frame->setGraphicsEffect(shadow); - } else { - ly->setContentsMargins(0, 0, 0, 0); - } - - auto* grid = new QGridLayout(m_topLevel.data()); - grid->addWidget(createTransparentWidget(), 0, 1); - grid->addWidget(createTransparentWidget(), 2, 1); - grid->addWidget(createTransparentWidget(), 1, 0); - grid->addWidget(createTransparentWidget(), 1, 2); - grid->addWidget(frame, 1, 1); - - if (!hasMainUI()) { - grid->setContentsMargins(0, 0, 0, 0); - } - - grid->setRowStretch(0, 1); - grid->setRowStretch(2, 1); - grid->setColumnStretch(0, 1); - grid->setColumnStretch(2, 1); - - return frame; - } - - void createMessageLabel() - { - m_message = new QLabel; - m_message->setAlignment(Qt::AlignCenter | Qt::AlignHCenter); - } - - void createInfoLabel() - { - m_info = new QLabel(" "); - m_info->setAlignment(Qt::AlignCenter | Qt::AlignHCenter); - } - - void createButtonsPanel() - { - m_buttons = new QWidget; - m_buttons->setLayout(new QHBoxLayout); - } - - void updateMessage(UILocker::Reasons reason) - { - switch (reason) { - case UILocker::LockUI: { - QString s; - - if (hasMainUI()) { - s = QObject::tr("Mod Organizer is locked while the application is running."); - } else { - s = QObject::tr("Mod Organizer is currently running an application."); - } - - m_message->setText(s); - - break; - } - - case UILocker::OutputRequired: { - m_message->setText( - QObject::tr("The application must run to completion because its output is " - "required.")); - - break; - } - - case UILocker::PreventExit: { - m_message->setText(QObject::tr( - "Mod Organizer is waiting on an application to close before exiting.")); - - break; - } - } - } - - void updateButtons(UILocker::Reasons reason) - { - MOBase::deleteChildWidgets(m_buttons); - auto* ly = m_buttons->layout(); - - switch (reason) { - case UILocker::LockUI: // fall-through - case UILocker::OutputRequired: { - auto* unlock = new QPushButton(QObject::tr("Unlock")); - - QObject::connect(unlock, &QPushButton::clicked, [&] { - UILocker::instance().onForceUnlock(); - }); - - ly->addWidget(unlock); - - break; - } - - case UILocker::PreventExit: { - auto* exit = new QPushButton(QObject::tr("Exit Now")); - QObject::connect(exit, &QPushButton::clicked, [&] { - UILocker::instance().onForceUnlock(); - }); - - ly->addWidget(exit); - - auto* cancel = new QPushButton(QObject::tr("Cancel")); - QObject::connect(cancel, &QPushButton::clicked, [&] { - UILocker::instance().onCancel(); - }); - - ly->addWidget(cancel); - - break; - } - } - } -}; - -UILocker::Session::~Session() -{ - unlock(); -} - -void UILocker::Session::unlock() -{ - QMetaObject::invokeMethod(qApp, [this] { - UILocker::instance().unlock(this); - }); -} - -void UILocker::Session::setInfo(DWORD pid, const QString& name) -{ - { - std::scoped_lock lock(m_mutex); - m_pid = pid; - m_name = name; - } - - QMetaObject::invokeMethod(qApp, [this] { - UILocker::instance().updateLabel(); - }); -} - -DWORD UILocker::Session::pid() const -{ - std::scoped_lock lock(m_mutex); - return m_pid; -} - -const QString& UILocker::Session::name() const -{ - std::scoped_lock lock(m_mutex); - return m_name; -} - -UILocker::Results UILocker::Session::result() -{ - return UILocker::instance().result(); -} - -static UILocker* g_instance = nullptr; - -UILocker::UILocker() : m_result(NoResult) -{ - Q_ASSERT(!g_instance); - g_instance = this; -} - -UILocker::~UILocker() -{ - const auto v = m_sessions; - - for (auto& wp : v) { - if (auto s = wp.lock()) { - unlock(s.get()); - } - } - - g_instance = nullptr; -} - -UILocker& UILocker::instance() -{ - Q_ASSERT(g_instance); - return *g_instance; -} - -void UILocker::setUserInterface(QWidget* parent) -{ - m_parent = parent; -} - -std::shared_ptr UILocker::lock(Reasons reason) -{ - m_result = StillLocked; - createUi(reason); - - auto ls = std::make_shared(); - m_sessions.push_back(ls); - - updateLabel(); - - return ls; -} - -bool UILocker::locked() const -{ - return !m_sessions.empty(); -} - -void UILocker::unlock(Session* s) -{ - auto itor = m_sessions.begin(); - for (;;) { - if (itor == m_sessions.end()) { - break; - } - - if (auto ss = itor->lock()) { - if (ss.get() == s) { - itor = m_sessions.erase(itor); - continue; - } - } else { - itor = m_sessions.erase(itor); - continue; - } - - ++itor; - } - - if (m_sessions.empty()) { - m_ui.reset(); - enableAll(); - } else { - updateLabel(); - } -} - -void UILocker::unlockCurrent() -{ - if (m_sessions.empty()) { - return; - } - - auto s = m_sessions.back().lock(); - if (!s) { - m_sessions.pop_back(); - return; - } - - unlock(s.get()); -} - -void UILocker::updateLabel() -{ - if (!m_ui) { - // this can happen if the lock overlay was destroyed while a cross-thread - // call for updateLabel() was in flight - return; - } - - QStringList labels; - - for (auto itor = m_sessions.rbegin(); itor != m_sessions.rend(); ++itor) { - if (auto ss = itor->lock()) { - labels.push_back(QString("%1 (%2)").arg(ss->name()).arg(ss->pid())); - } - } - - m_ui->setInfo(labels); -} - -UILocker::Results UILocker::result() const -{ - return m_result; -} - -void UILocker::createUi(Reasons reason) -{ - if (!m_ui) { - m_ui.reset(new UILockerInterface(m_parent)); - } - - m_ui->update(reason); - - disableAll(); -} - -void UILocker::onForceUnlock() -{ - m_result = ForceUnlocked; - unlockCurrent(); -} - -void UILocker::onCancel() -{ - m_result = Cancelled; - unlockCurrent(); -} - -template -QList findChildrenImmediate(QWidget* parent) -{ - return parent->findChildren(QString(), Qt::FindDirectChildrenOnly); -} - -void UILocker::disableAll() -{ - // the goal is to disable the main window and any dialog that's opened, - // without disabling the overlay itself - // - // the overlay might be a regular widget overlayed on top of a window, or an - // actual dialog if a shortcut is launched when MO isn't running - - // top level widgets include the main window and dialogs - for (auto* w : QApplication::topLevelWidgets()) { - if (auto* mw = dynamic_cast(w)) { - // this is the main window, disable the central widgets and the stuff - // around it - - disable(mw->centralWidget()); - disable(mw->menuBar()); - disable(mw->statusBar()); - - // every toolbar - for (auto* tb : findChildrenImmediate(w)) { - disable(tb); - } - - // every docked widget - for (auto* d : findChildrenImmediate(w)) { - disable(d); - } - } - - if (auto* d = dynamic_cast(w)) { - // this is a dialog - - if (d == m_ui->topLevel()) { - // but it's the overlay itself, skip it; this happens if a shortcut is - // launched but MO itself isn't running, in which case the lock ui is - // a dialog, not an overlay - continue; - } - - // disable all the dialog's immediate children, except for the overlay - // itself or other dialogs - for (auto* child : findChildrenImmediate(d)) { - if (child == m_ui->topLevel()) { - // this is the overlay itself, skip it - continue; - } - - if (dynamic_cast(child)) { - // this is a child dialog, skip it - // - // this typically happens when there's a second level modal dialog, - // like the create instance dialog that's opened from the instance - // manager - // - // the lock overlay is probably a child of that dialog, so disabling - // the dialog would also disable the buttons on the lock overlay - // - // since this is a dialog, it will be part of `topLevel` from the main - // loop and will be handled correctly later - continue; - } - - disable(child); - } - } - } -} - -void UILocker::enableAll() -{ - for (auto w : m_disabled) { - if (w) { - w->setEnabled(true); - } - } - - m_disabled.clear(); -} - -void UILocker::disable(QWidget* w) -{ - if (w->isEnabled()) { - w->setEnabled(false); - m_disabled.push_back(w); - } -} +#include "uilocker.h" +#include "mainwindow.h" +#include +#include +#include + +class UILockerInterface +{ +public: + UILockerInterface(QWidget* mainUI) + : m_mainUI(mainUI) + { + m_timer.reset(new QTimer); + QObject::connect(m_timer.get(), &QTimer::timeout, [&] { + checkTarget(); + }); + m_timer->start(200); + + set(); + } + + ~UILockerInterface() + { + if (m_topLevel) { + delete m_topLevel.data(); + } + } + + void checkTarget() + { + if (set()) { + update(m_reason); + } + } + + bool set() + { + QWidget* newTarget = findTarget(); + if (m_topLevel && newTarget == m_target) { + return false; + } + + m_target = newTarget; + + QFrame* center = nullptr; + + if (m_target) { + center = createOverlay(m_target); + } else { + center = createDialog(); + } + + createMessageLabel(); + createInfoLabel(); + createButtonsPanel(); + + center->layout()->addWidget(m_message); + center->layout()->addWidget(m_info); + center->layout()->addWidget(m_buttons); + + m_topLevel->setFocusPolicy(Qt::TabFocus); + m_topLevel->setFocus(); + m_topLevel->show(); + m_topLevel->setEnabled(true); + + m_topLevel->raise(); + m_topLevel->activateWindow(); + + return true; + } + + void update(UILocker::Reasons reason) + { + m_reason = reason; + updateMessage(reason); + updateButtons(reason); + setInfo(m_labels); + } + + void setInfo(const QStringList& labels) + { + const int MaxLabels = 2; + + m_labels = labels; + + QString s; + + if (labels.size() > MaxLabels) { + s = labels.mid(0, MaxLabels).join(", ") + "..."; + } else { + s = labels.join(", "); + } + + m_info->setText(s); + } + + QWidget* topLevel() { return m_topLevel.data(); } + +private: + class Filter : public QObject + { + public: + std::function resized; + std::function closed; + + protected: + bool eventFilter(QObject* o, QEvent* e) override + { + if (e->type() == QEvent::Resize) { + if (resized) { + resized(); + } + } else if (e->type() == QEvent::Close) { + if (closed) { + closed(); + } + } + + return QObject::eventFilter(o, e); + } + }; + + std::unique_ptr m_timer; + QWidget* m_mainUI; + QWidget* m_target{nullptr}; + QPointer m_topLevel; + QLabel* m_message{nullptr}; + QLabel* m_info{nullptr}; + QStringList m_labels; + QWidget* m_buttons{nullptr}; + std::unique_ptr m_filter; + UILocker::Reasons m_reason{UILocker::NoReason}; + + bool hasMainUI() const { return (m_target != nullptr); } + + QWidget* findTarget() + { + auto isValidTarget = [](QWidget* w) { + // skip message boxes + if (dynamic_cast(w)) { + return false; + } + + // skip invisible widgets + if (!w->isVisible()) { + return false; + } + + // skip windows that are too small + if (w->height() < 150) { + return false; + } + + return true; + }; + + // find a modal dialog + QWidget* w = QApplication::activeModalWidget(); + + while (w && w != m_mainUI) { + if (isValidTarget(w)) { + return w; + } + + w = w->parentWidget(); + } + + // find a non-modal dialog that's a child of the main window + if (m_mainUI) { + const auto topLevels = QApplication::topLevelWidgets(); + + for (auto* w : topLevels) { + if (w && w->parentWidget() == m_mainUI) { + if (isValidTarget(w)) { + return w; + } + } + } + } + + return m_mainUI; + } + + static QWidget* createTransparentWidget(QWidget* parent = nullptr) + { + auto* w = new QWidget(parent); + + w->setWindowOpacity(0); + w->setAttribute(Qt::WA_NoSystemBackground); + w->setAttribute(Qt::WA_TranslucentBackground); + + return w; + } + + QFrame* createOverlay(QWidget* mainUI) + { + if (m_topLevel) { + delete m_topLevel.data(); + m_topLevel.clear(); + } + + m_topLevel = createTransparentWidget(mainUI); + m_topLevel->setWindowFlags(m_topLevel->windowFlags() & Qt::FramelessWindowHint); + m_topLevel->setGeometry(mainUI->rect()); + + m_filter.reset(new Filter); + m_filter->resized = [=, this] { + m_topLevel->setGeometry(mainUI->rect()); + }; + m_filter->closed = [=, this] { + checkTarget(); + }; + + mainUI->installEventFilter(m_filter.get()); + + return createFrame(); + } + + QFrame* createDialog() + { + if (m_topLevel) { + delete m_topLevel.data(); + m_topLevel.clear(); + } + + m_topLevel = new QDialog; + + return createFrame(); + } + + QFrame* createFrame() + { + auto* frame = new QFrame; + auto* ly = new QVBoxLayout(frame); + + if (hasMainUI()) { + frame->setFrameStyle(QFrame::StyledPanel); + frame->setLineWidth(1); + frame->setAutoFillBackground(true); + + auto* shadow = new QGraphicsDropShadowEffect; + shadow->setBlurRadius(50); + shadow->setOffset(0); + shadow->setColor(QColor(0, 0, 0, 100)); + frame->setGraphicsEffect(shadow); + } else { + ly->setContentsMargins(0, 0, 0, 0); + } + + auto* grid = new QGridLayout(m_topLevel.data()); + grid->addWidget(createTransparentWidget(), 0, 1); + grid->addWidget(createTransparentWidget(), 2, 1); + grid->addWidget(createTransparentWidget(), 1, 0); + grid->addWidget(createTransparentWidget(), 1, 2); + grid->addWidget(frame, 1, 1); + + if (!hasMainUI()) { + grid->setContentsMargins(0, 0, 0, 0); + } + + grid->setRowStretch(0, 1); + grid->setRowStretch(2, 1); + grid->setColumnStretch(0, 1); + grid->setColumnStretch(2, 1); + + return frame; + } + + void createMessageLabel() + { + m_message = new QLabel; + m_message->setAlignment(Qt::AlignCenter | Qt::AlignHCenter); + } + + void createInfoLabel() + { + m_info = new QLabel(" "); + m_info->setAlignment(Qt::AlignCenter | Qt::AlignHCenter); + } + + void createButtonsPanel() + { + m_buttons = new QWidget; + m_buttons->setLayout(new QHBoxLayout); + } + + void updateMessage(UILocker::Reasons reason) + { + switch (reason) { + case UILocker::LockUI: { + QString s; + + if (hasMainUI()) { + s = QObject::tr("Mod Organizer is locked while the application is running."); + } else { + s = QObject::tr("Mod Organizer is currently running an application."); + } + + m_message->setText(s); + + break; + } + + case UILocker::OutputRequired: { + m_message->setText( + QObject::tr("The application must run to completion because its output is " + "required.")); + + break; + } + + case UILocker::PreventExit: { + m_message->setText(QObject::tr( + "Mod Organizer is waiting on an application to close before exiting.")); + + break; + } + } + } + + void updateButtons(UILocker::Reasons reason) + { + MOBase::deleteChildWidgets(m_buttons); + auto* ly = m_buttons->layout(); + + switch (reason) { + case UILocker::LockUI: // fall-through + case UILocker::OutputRequired: { + auto* unlock = new QPushButton(QObject::tr("Unlock")); + + QObject::connect(unlock, &QPushButton::clicked, [&] { + UILocker::instance().onForceUnlock(); + }); + + ly->addWidget(unlock); + + break; + } + + case UILocker::PreventExit: { + auto* exit = new QPushButton(QObject::tr("Exit Now")); + QObject::connect(exit, &QPushButton::clicked, [&] { + UILocker::instance().onForceUnlock(); + }); + + ly->addWidget(exit); + + auto* cancel = new QPushButton(QObject::tr("Cancel")); + QObject::connect(cancel, &QPushButton::clicked, [&] { + UILocker::instance().onCancel(); + }); + + ly->addWidget(cancel); + + break; + } + } + } +}; + +UILocker::Session::~Session() +{ + unlock(); +} + +void UILocker::Session::unlock() +{ + QMetaObject::invokeMethod(qApp, [this] { + UILocker::instance().unlock(this); + }); +} + +void UILocker::Session::setInfo(DWORD pid, const QString& name) +{ + { + std::scoped_lock lock(m_mutex); + m_pid = pid; + m_name = name; + } + + QMetaObject::invokeMethod(qApp, [this] { + UILocker::instance().updateLabel(); + }); +} + +DWORD UILocker::Session::pid() const +{ + std::scoped_lock lock(m_mutex); + return m_pid; +} + +const QString& UILocker::Session::name() const +{ + std::scoped_lock lock(m_mutex); + return m_name; +} + +UILocker::Results UILocker::Session::result() +{ + return UILocker::instance().result(); +} + +static UILocker* g_instance = nullptr; + +UILocker::UILocker() : m_result(NoResult) +{ + Q_ASSERT(!g_instance); + g_instance = this; +} + +UILocker::~UILocker() +{ + const auto v = m_sessions; + + for (const auto& wp : v) { + if (auto s = wp.lock()) { + unlock(s.get()); + } + } + + g_instance = nullptr; +} + +UILocker& UILocker::instance() +{ + Q_ASSERT(g_instance); + return *g_instance; +} + +void UILocker::setUserInterface(QWidget* parent) +{ + m_parent = parent; +} + +std::shared_ptr UILocker::lock(Reasons reason) +{ + m_result = StillLocked; + createUi(reason); + + auto ls = std::make_shared(); + m_sessions.push_back(ls); + + updateLabel(); + + return ls; +} + +bool UILocker::locked() const +{ + return !m_sessions.empty(); +} + +void UILocker::unlock(Session* s) +{ + auto itor = m_sessions.begin(); + for (;;) { + if (itor == m_sessions.end()) { + break; + } + + if (auto ss = itor->lock()) { + if (ss.get() == s) { + itor = m_sessions.erase(itor); + continue; + } + } else { + itor = m_sessions.erase(itor); + continue; + } + + ++itor; + } + + if (m_sessions.empty()) { + m_ui.reset(); + enableAll(); + } else { + updateLabel(); + } +} + +void UILocker::unlockCurrent() +{ + if (m_sessions.empty()) { + return; + } + + auto s = m_sessions.back().lock(); + if (!s) { + m_sessions.pop_back(); + return; + } + + unlock(s.get()); +} + +void UILocker::updateLabel() +{ + if (!m_ui) { + // this can happen if the lock overlay was destroyed while a cross-thread + // call for updateLabel() was in flight + return; + } + + QStringList labels; + + for (auto itor = m_sessions.rbegin(); itor != m_sessions.rend(); ++itor) { + if (auto ss = itor->lock()) { + labels.push_back(QString("%1 (%2)").arg(ss->name()).arg(ss->pid())); + } + } + + m_ui->setInfo(labels); +} + +UILocker::Results UILocker::result() const +{ + return m_result; +} + +void UILocker::createUi(Reasons reason) +{ + if (!m_ui) { + m_ui.reset(new UILockerInterface(m_parent)); + } + + m_ui->update(reason); + + disableAll(); +} + +void UILocker::onForceUnlock() +{ + m_result = ForceUnlocked; + unlockCurrent(); +} + +void UILocker::onCancel() +{ + m_result = Cancelled; + unlockCurrent(); +} + +template +QList findChildrenImmediate(QWidget* parent) +{ + return parent->findChildren(QString(), Qt::FindDirectChildrenOnly); +} + +void UILocker::disableAll() +{ + // the goal is to disable the main window and any dialog that's opened, + // without disabling the overlay itself + // + // the overlay might be a regular widget overlayed on top of a window, or an + // actual dialog if a shortcut is launched when MO isn't running + + // top level widgets include the main window and dialogs + for (auto* w : QApplication::topLevelWidgets()) { + if (auto* mw = dynamic_cast(w)) { + // this is the main window, disable the central widgets and the stuff + // around it + + disable(mw->centralWidget()); + disable(mw->menuBar()); + disable(mw->statusBar()); + + // every toolbar + for (auto* tb : findChildrenImmediate(w)) { + disable(tb); + } + + // every docked widget + for (auto* d : findChildrenImmediate(w)) { + disable(d); + } + } + + if (auto* d = dynamic_cast(w)) { + // this is a dialog + + if (d == m_ui->topLevel()) { + // but it's the overlay itself, skip it; this happens if a shortcut is + // launched but MO itself isn't running, in which case the lock ui is + // a dialog, not an overlay + continue; + } + + // disable all the dialog's immediate children, except for the overlay + // itself or other dialogs + for (auto* child : findChildrenImmediate(d)) { + if (child == m_ui->topLevel()) { + // this is the overlay itself, skip it + continue; + } + + if (dynamic_cast(child)) { + // this is a child dialog, skip it + // + // this typically happens when there's a second level modal dialog, + // like the create instance dialog that's opened from the instance + // manager + // + // the lock overlay is probably a child of that dialog, so disabling + // the dialog would also disable the buttons on the lock overlay + // + // since this is a dialog, it will be part of `topLevel` from the main + // loop and will be handled correctly later + continue; + } + + disable(child); + } + } + } +} + +void UILocker::enableAll() +{ + for (auto w : m_disabled) { + if (w) { + w->setEnabled(true); + } + } + + m_disabled.clear(); +} + +void UILocker::disable(QWidget* w) +{ + if (w->isEnabled()) { + w->setEnabled(false); + m_disabled.emplace_back(w); + } +} diff --git a/src/src/vdfparser.h b/src/src/vdfparser.h index faafdb6..0deeace 100644 --- a/src/src/vdfparser.h +++ b/src/src/vdfparser.h @@ -37,7 +37,7 @@ public: /// Get a string value by key. QString getString(const QString& key) const { - if (auto* v = get(key); v && v->isString()) + if (const auto* v = get(key); v && v->isString()) return v->asString(); return {}; } diff --git a/src/src/version.rc b/src/src/version.rc deleted file mode 100644 index 477a7dc..0000000 --- a/src/src/version.rc +++ /dev/null @@ -1,37 +0,0 @@ -#include "Winver.h" - -// If VS_FF_PRERELEASE is not set, MO labels the build as a release and uses VER_FILEVERSION to determine version number. -// Otherwise, if letters are used in VER_FILEVERSION_STR, uses the full MOBase::VersionInfo parser -// Otherwise, uses the numbers from VER_FILEVERSION and sets the release type as pre-alpha -#define VER_FILEVERSION 2,5,2 -#define VER_FILEVERSION_STR "2.5.2\0" - -VS_VERSION_INFO VERSIONINFO -FILEVERSION VER_FILEVERSION -PRODUCTVERSION VER_FILEVERSION -FILEFLAGSMASK VS_FFI_FILEFLAGSMASK -FILEFLAGS (0) -FILEOS VOS__WINDOWS32 -FILETYPE VFT_APP -FILESUBTYPE (0) -BEGIN - BLOCK "StringFileInfo" - BEGIN - BLOCK "040904B0" - BEGIN - VALUE "FileVersion", VER_FILEVERSION_STR - VALUE "CompanyName", "Mod Organizer 2 Team\0" - VALUE "FileDescription", "Mod Organizer 2 GUI\0" - VALUE "OriginalFilename", "ModOrganizer.exe\0" - VALUE "InternalName", "ModOrganizer2\0" - VALUE "LegalCopyright", "Copyright 2011-2016 Sebastian Herbord\r\nCopyright 2016-2026 Mod Organizer 2 contributors\0" - VALUE "ProductName", "Mod Organizer 2\0" - VALUE "ProductVersion", VER_FILEVERSION_STR - END - END - - BLOCK "VarFileInfo" - BEGIN - VALUE "Translation", 0x0409, 1200 - END -END diff --git a/src/src/vfs/mo2filesystem.cpp b/src/src/vfs/mo2filesystem.cpp index 08f873c..5de9e70 100644 --- a/src/src/vfs/mo2filesystem.cpp +++ b/src/src/vfs/mo2filesystem.cpp @@ -531,16 +531,16 @@ std::vector buildDirEntries( std::vector entries; entries.reserve(children.size() + 2); - entries.push_back(Mo2FsContext::DirEntry{selfIno, ".", true}); - entries.push_back(Mo2FsContext::DirEntry{1, "..", true}); + entries.push_back(Mo2FsContext::DirEntry{.ino=selfIno, .name=".", .is_dir=true}); + entries.push_back(Mo2FsContext::DirEntry{.ino=1, .name="..", .is_dir=true}); std::unique_lock lock(ctx->inode_mutex); for (const auto& child : children) { const std::string childPath = joinPath(path, child.name); entries.push_back( - Mo2FsContext::DirEntry{ctx->inodes->getOrCreate(childPath), child.name, - child.is_dir, child.size, child.mtime, - child.real_path, child.cached_mode}); + Mo2FsContext::DirEntry{.ino=ctx->inodes->getOrCreate(childPath), .name=child.name, + .is_dir=child.is_dir, .size=child.size, .mtime=child.mtime, + .real_path=child.real_path, .cached_mode=child.cached_mode}); } return entries; diff --git a/src/src/vfs/vfstree.cpp b/src/src/vfs/vfstree.cpp index f3e0f42..303537d 100644 --- a/src/src/vfs/vfstree.cpp +++ b/src/src/vfs/vfstree.cpp @@ -283,7 +283,7 @@ void VfsNode::insertFile(const std::vector& components, if (i + 1 == components.size()) { auto fileNode = std::make_unique(); fileNode->is_directory = false; - fileNode->file_info = {real_path, size, mtime, origin, is_backing}; + fileNode->file_info = {.real_path=real_path, .size=size, .mtime=mtime, .origin=origin, .is_backing=is_backing}; current->dir_info.children[key] = std::move(fileNode); return; } -- cgit v1.3.1