diff options
| author | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-04-12 21:04:15 -0500 |
|---|---|---|
| committer | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-04-12 21:04:15 -0500 |
| commit | 3949dcfce95af4bd305f258ff5b170d7d50435f6 (patch) | |
| tree | 4c768be256c40f31794c3311f0a2c22933a6705a /libs/installer_bsplugins/src/BSPluginList/PluginsWidget.cpp | |
| parent | 892070f3dec1dc690983fd862880e37e711c2516 (diff) | |
VFS perf fixes, icon/stylesheet compat, download filename fix
VFS:
- Open backing fd at mo2_open time for read-only handles so mo2_read can
splice without re-opening the file on every read call.
- Bump RLIMIT_NOFILE at mount time to fit the resulting fd pressure when
the game keeps hundreds of BSAs open.
- Dedicated node_cache_mutex so resolveByInode / mo2_lookup stop racing
unordered_map writes while multiple tree_mutex readers are active.
- max_read=1MB + matching conn->max_read and raised max_readahead/max_write
so the kernel merges Wine's small sequential reads into bigger FUSE
requests.
- Per-op wall-clock counters in mo2_init() logs so we can distinguish VFS
latency from game-side work.
Proton launch:
- Write dxvk.conf to the prefix and set DXVK_CONFIG_FILE to force
dxvk.enableGraphicsPipelineLibrary=False, avoiding long GPL compile
stalls on first run.
UI / plugin compat:
- Clamp IconDelegate's per-icon width to [8, 16] so narrow content
columns don't trigger QIcon::pixmap(0,0) returning null and logging
"failed to load icon" every repaint.
- Pre-create lowercase symlinks in the stylesheet directory so QSS files
authored on Windows resolve url(foo.svg) when on-disk files are Foo.svg.
- Flip content icons empty-chessboard.png and facegen.png to 8-bit RGBA
so Qt's built-in PNG reader loads them uniformly.
- Add mobase.Version.canonicalString shim for legacy Python plugins that
still call the old VersionInfo method on appVersion()'s return.
- BSPluginList: compare normalized plugin name lists instead of byte
hashes so the post-run case-fix refresh stops spuriously firing the
"load order changed" dialog.
- BSPluginList disabled by default.
Download manager:
- Parse CDN URL with QUrl + QUrlQuery and read the filename from the
response-content-disposition query param (RFC 6266 quoted / unquoted /
ext-value forms), not QFileInfo on the raw URL.
- When the API or URL gives us a CDN object key (no archive extension),
prefer the actual Content-Disposition header from the live response in
metaDataChanged and downloadFinished.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'libs/installer_bsplugins/src/BSPluginList/PluginsWidget.cpp')
| -rw-r--r-- | libs/installer_bsplugins/src/BSPluginList/PluginsWidget.cpp | 833 |
1 files changed, 833 insertions, 0 deletions
diff --git a/libs/installer_bsplugins/src/BSPluginList/PluginsWidget.cpp b/libs/installer_bsplugins/src/BSPluginList/PluginsWidget.cpp new file mode 100644 index 0000000..a73725f --- /dev/null +++ b/libs/installer_bsplugins/src/BSPluginList/PluginsWidget.cpp @@ -0,0 +1,833 @@ +#include "PluginsWidget.h" + +#include "BSPluginInfo/PluginInfoDialog.h" +#include "GUI/MessageDialog.h" +#include "GUI/SelectionDialog.h" +#include "MOPlugin/Settings.h" +#include "MOTools/Loot.h" +#include "PluginListContextMenu.h" +#include "PluginSortFilterProxyModel.h" +#include "ui_pluginswidget.h" + +#include <game_features/gameplugins.h> +#include <game_features/igamefeatures.h> + +#include <boost/range/adaptor/reversed.hpp> + +#include <QApplication> +#include <QCryptographicHash> +#include <QMenu> +#include <QMessageBox> +#include <QStandardPaths> + +using namespace Qt::Literals::StringLiterals; + +namespace BSPluginList +{ + +PluginsWidget::PluginsWidget(MOBase::IOrganizer* organizer, + IPanelInterface* panelInterface, QWidget* parent) + : QWidget(parent), ui{new Ui_PluginsWidget()}, m_PanelInterface{panelInterface}, + m_Organizer{organizer} +{ + ui->setupUi(this); + + m_PluginList = new TESData::PluginList(organizer); + m_PluginListModel = new PluginListModel(m_PluginList); + m_SortProxy = new PluginSortFilterProxyModel(); + m_SortProxy->setSourceModel(m_PluginListModel); + m_GroupProxy = new PluginGroupProxyModel(organizer); + m_GroupProxy->setSourceModel(m_SortProxy); + ui->pluginList->setModel(m_GroupProxy); + ui->pluginList->setup(); + ui->pluginList->sortByColumn(PluginListModel::COL_PRIORITY, Qt::AscendingOrder); + ui->pluginList->expandAll(); + optionsMenu = listOptionsMenu(); + ui->listOptionsBtn->setMenu(optionsMenu); + + // LOOT integration stripped for Fluorine port — sort button is useless. + ui->sortButton->setVisible(false); + + // monitor main window for close event + topLevelWidget()->installEventFilter(this); + + restoreState(); + + connect(m_PluginList, &TESData::PluginList::pluginsListChanged, this, + &PluginsWidget::updatePluginCount); + + connect(m_PluginListModel, &PluginListModel::pluginStatesChanged, ui->pluginList, + &PluginListView::updateOverwriteMarkers); + connect(m_PluginListModel, &PluginListModel::pluginOrderChanged, ui->pluginList, + &PluginListView::updateOverwriteMarkers); + connect(m_PluginListModel, &QAbstractItemModel::modelReset, ui->pluginList, + &PluginListView::clearOverwriteMarkers); + + connect(m_PluginListModel, &PluginListModel::pluginStatesChanged, this, + &PluginsWidget::updatePluginCount); + + connect(m_GroupProxy, &QAbstractItemModel::modelReset, [this]() { + ui->pluginList->scrollToTop(); + }); + + connect(ui->pluginList, &QTreeView::collapsed, this, + &PluginsWidget::onGroupCollapsed); + connect(ui->pluginList, &QTreeView::expanded, this, &PluginsWidget::onGroupExpanded); + connect(ui->pluginList->selectionModel(), &QItemSelectionModel::selectionChanged, + this, &PluginsWidget::onSelectionChanged); + + panelInterface->onPanelActivated( + std::bind_front(&PluginsWidget::onPanelActivated, this)); + panelInterface->onSelectedOriginsChanged( + std::bind_front(&PluginsWidget::onSelectedOriginsChanged, this)); + + organizer->onAboutToRun(std::bind_front(&PluginsWidget::onAboutToRun, this)); + organizer->onFinishedRun(std::bind_front(&PluginsWidget::onFinishedRun, this)); + + organizer->modList()->onModStateChanged( + std::bind_front(&PluginsWidget::onModStateChanged, this)); + + Settings::instance()->onSettingChanged( + std::bind_front(&PluginsWidget::onSettingChanged, this)); + + synchronizePluginLists(organizer); + updatePluginCount(); +} + +PluginsWidget::~PluginsWidget() noexcept +{ + delete ui; + delete optionsMenu; + + delete m_PluginList; + delete m_PluginListModel; + delete m_SortProxy; + delete m_GroupProxy; +} + +void PluginsWidget::updatePluginCount() +{ + int activeMasterCount = 0; + int activeMediumMasterCount = 0; + int activeLightMasterCount = 0; + int activeRegularCount = 0; + int masterCount = 0; + int mediumMasterCount = 0; + int lightMasterCount = 0; + int regularCount = 0; + int activeVisibleCount = 0; + + const auto gameFeatures = m_Organizer->gameFeatures(); + const auto tesSupport = + gameFeatures ? gameFeatures->gameFeature<MOBase::GamePlugins>() : nullptr; + + const bool lightPluginsAreSupported = + tesSupport && tesSupport->lightPluginsAreSupported(); + const bool mediumPluginsAreSupported = + tesSupport && tesSupport->mediumPluginsAreSupported(); + + for (int i = 0, count = m_PluginListModel->rowCount(); i < count; ++i) { + const auto index = m_PluginListModel->index(i, 0); + const auto id = index.data(PluginListModel::IndexRole).toInt(); + const auto info = m_PluginList->getPlugin(id); + + if (!info) + continue; + + const bool active = info->enabled() || info->isAlwaysEnabled(); + const bool visible = m_SortProxy->filterAcceptsRow(index.row(), index.parent()); + if (info->isMediumFile()) { + ++mediumMasterCount; + activeMediumMasterCount += active ? 1 : 0; + activeVisibleCount += visible && active ? 1 : 0; + } else if (info->isSmallFile()) { + ++lightMasterCount; + activeLightMasterCount += active ? 1 : 0; + activeVisibleCount += visible && active ? 1 : 0; + } else if (info->isMasterFile()) { + ++masterCount; + activeMasterCount += active ? 1 : 0; + activeVisibleCount += visible && active ? 1 : 0; + } else { + ++regularCount; + activeRegularCount += active ? 1 : 0; + activeVisibleCount += visible && active ? 1 : 0; + } + } + + const int activeCount = activeMasterCount + activeMediumMasterCount + + activeLightMasterCount + activeRegularCount; + const int totalCount = + masterCount + mediumMasterCount + lightMasterCount + regularCount; + + ui->activePluginsCounter->display(activeVisibleCount); + + QString toolTip; + toolTip.reserve(575); + toolTip += uR"(<table cellspacing="6">)"_s + uR"(<tr><th>%1</th><th>%2</th><th>%3</th></tr>)"_s.arg(tr("Type")) + .arg(tr("Active"), -12) + .arg(tr("Total")); + + const QString row = uR"(<tr><td>%1:</td><td align=right>%2 </td>)"_s + uR"(<td align=right>%3</td></tr>)"_s; + + toolTip += row.arg(tr("All plugins")).arg(activeCount).arg(totalCount); + toolTip += row.arg(tr("ESMs")).arg(activeMasterCount).arg(masterCount); + toolTip += row.arg(tr("ESPs")).arg(activeRegularCount).arg(regularCount); + toolTip += row.arg(tr("ESMs+ESPs")) + .arg(activeMasterCount + activeRegularCount) + .arg(masterCount + regularCount); + if (mediumPluginsAreSupported) + toolTip += row.arg(tr("ESHs")).arg(activeMediumMasterCount).arg(mediumMasterCount); + if (lightPluginsAreSupported) + toolTip += row.arg(tr("ESLs")).arg(activeLightMasterCount).arg(lightMasterCount); + toolTip += uR"(</table>)"_s; + + ui->activePluginsCounter->setToolTip(toolTip); +} + +void PluginsWidget::on_espFilterEdit_textChanged(const QString& filter) +{ + m_SortProxy->updateFilter(filter); + + if (!filter.isEmpty()) { + setStyleSheet("QTreeView { border: 2px ridge #f00; }"); + ui->activePluginsCounter->setStyleSheet("QLCDNumber { border: 2px ridge #f00; }"); + } else { + setStyleSheet(""); + ui->activePluginsCounter->setStyleSheet(""); + } + updatePluginCount(); +} + +bool PluginsWidget::eventFilter(QObject* watched, QEvent* event) +{ + if (event->type() == QEvent::Close) { + saveState(); + m_PluginList->writePluginLists(); + } + + return QWidget::eventFilter(watched, event); +} + +void PluginsWidget::changeEvent(QEvent* event) +{ + QWidget::changeEvent(event); + switch (event->type()) { + case QEvent::LanguageChange: + ui->retranslateUi(this); + break; + default: + break; + } +} + +void PluginsWidget::onGroupCollapsed(const QModelIndex& index) +{ + if (ui->pluginList->selectionModel()->isSelected(index)) { + onSelectionChanged(); + } +} + +void PluginsWidget::onGroupExpanded(const QModelIndex& index) +{ + if (ui->pluginList->selectionModel()->isSelected(index)) { + onSelectionChanged(); + } +} + +void PluginsWidget::onSelectionChanged() +{ + QList<QString> selectedFiles; + std::function<void(const QModelIndex&)> addFiles; + addFiles = [&](const QModelIndex& index) { + if (index.model()->hasChildren(index)) { + if (ui->pluginList->isExpanded(index)) { + return; + } + + for (int i = 0, count = index.model()->rowCount(index); i < count; ++i) { + addFiles(index.model()->index(i, 0, index)); + } + } else { + if (const auto info = + index.data(PluginListModel::InfoRole).value<const TESData::FileInfo*>()) { + selectedFiles.append(info->name()); + } + } + }; + + for (const auto& index : ui->pluginList->selectionModel()->selectedRows()) { + addFiles(index); + } + + m_PanelInterface->setSelectedFiles(selectedFiles); +} + +void PluginsWidget::onPanelActivated() +{ + m_PluginListModel->refresh(); +} + +void PluginsWidget::onSelectedOriginsChanged(const QList<QString>& origins) +{ + ui->pluginList->setHighlightedOrigins(origins); +} + +void PluginsWidget::toggleHideForceEnabled() +{ + const bool doHide = toggleForceEnabled->isChecked(); + m_SortProxy->hideForceEnabledFiles(doHide); + updatePluginCount(); + + Settings::instance()->set("hide_force_enabled", doHide); +} + +void PluginsWidget::toggleIgnoreMasterConflicts() +{ + const bool doIgnore = toggleIgnoreMasters->isChecked(); + Settings::instance()->set("ignore_master_conflicts", doIgnore); + + m_PluginListModel->invalidateConflicts(); +} + +constexpr auto PATTERN_BACKUP_GLOB = R"/(.????_??_??_??_??_??)/"; +constexpr auto PATTERN_BACKUP_REGEX = R"/(\.(\d\d\d\d_\d\d_\d\d_\d\d_\d\d_\d\d))/"; +constexpr auto PATTERN_BACKUP_DATE = R"/(yyyy_MM_dd_hh_mm_ss)/"; + +static QString queryRestore(const QString& filePath, QWidget* parent = nullptr) +{ + QFileInfo pluginFileInfo(filePath); + QString pattern = pluginFileInfo.fileName() + ".*"; + QFileInfoList files = pluginFileInfo.absoluteDir().entryInfoList( + QStringList(pattern), QDir::Files, QDir::Name); + + GUI::SelectionDialog dialog(QObject::tr("Choose backup to restore"), parent); + QRegularExpression exp(QRegularExpression::anchoredPattern(pluginFileInfo.fileName() + + PATTERN_BACKUP_REGEX)); + QRegularExpression exp2( + QRegularExpression::anchoredPattern(pluginFileInfo.fileName() + "\\.(.*)")); + for (const QFileInfo& info : boost::adaptors::reverse(files)) { + auto match = exp.match(info.fileName()); + auto match2 = exp2.match(info.fileName()); + if (match.hasMatch()) { + QDateTime 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), "", match2.captured(1)); + } + } + + if (dialog.numChoices() == 0) { + QMessageBox::information(parent, QObject::tr("No Backups"), + QObject::tr("There are no backups to restore")); + return QString(); + } + + if (dialog.exec() == QDialog::Accepted) { + return dialog.getChoiceData().toString(); + } else { + return QString(); + } +} + +void PluginsWidget::displayPluginInformation(const QModelIndex& index) +{ + const int id = index.data(PluginListModel::IndexRole).toInt(); + const auto fileName = m_PluginList->getPlugin(id)->name(); + const auto parent = topLevelWidget(); + BSPluginInfo::PluginInfoDialog dialog{m_Organizer, m_PluginList, fileName, parent}; + dialog.exec(); + + const bool ignoreMasters = + Settings::instance()->get<bool>("ignore_master_conflicts", false); + toggleIgnoreMasters->setChecked(ignoreMasters); + m_PluginListModel->invalidateConflicts(); +} + +void PluginsWidget::on_pluginList_customContextMenuRequested(const QPoint& pos) +{ + PluginListContextMenu menu{ui->pluginList->indexAt(pos), m_PluginListModel, + ui->pluginList, m_Organizer->modList(), m_PluginList}; + + connect(&menu, &PluginListContextMenu::openModInformation, + [this](const QModelIndex& index) { + const int id = index.data(PluginListModel::IndexRole).toInt(); + const auto fileName = m_PluginList->getPlugin(id)->name(); + m_PanelInterface->displayOriginInformation(fileName); + }); + + connect(&menu, &PluginListContextMenu::openPluginInformation, this, + &PluginsWidget::displayPluginInformation); + + const QPoint p = ui->pluginList->viewport()->mapToGlobal(pos); + menu.exec(p); +} + +void PluginsWidget::on_pluginList_doubleClicked(const QModelIndex& index) +{ + bool ok; + const int id = index.data(PluginListModel::IndexRole).toInt(&ok); + if (ok) { + Qt::KeyboardModifiers modifiers = QApplication::queryKeyboardModifiers(); + if (modifiers.testFlag(Qt::ControlModifier)) { + const auto origin = m_PluginList->getOriginName(id); + const auto modInfo = m_Organizer->modList()->getMod(origin); + + if (modInfo) { + MOBase::shell::Explore(modInfo->absolutePath()); + } + } else { + displayPluginInformation(index); + } + } else if (ui->pluginList->model()->hasChildren(index)) { + ui->pluginList->setExpanded(index, !ui->pluginList->isExpanded(index)); + } +} + +void PluginsWidget::on_pluginList_openOriginExplorer(const QModelIndex& index) +{ + const int id = index.data(PluginListModel::IndexRole).toInt(); + const auto origin = m_PluginList->getOriginName(id); + const auto modInfo = m_Organizer->modList()->getMod(origin); + + if (modInfo == nullptr) { + return; + } + + MOBase::shell::Explore(modInfo->absolutePath()); +} + +void PluginsWidget::on_sortButton_clicked() +{ + // LOOT integration stripped for Fluorine port. + QMessageBox::information( + topLevelWidget(), tr("Sorting plugins"), + tr("LOOT integration is not available in this build.")); +} + +static bool tryRestore(const QString& filePath, const QString& identifier, + bool required, QWidget* parent = nullptr) +{ + const auto backupName = filePath + "." + identifier; + if (required || QFileInfo::exists(backupName)) { + return MOBase::shellCopy(backupName, filePath, true, parent); + } else { + return !QFileInfo::exists(filePath) || MOBase::shellDeleteQuiet(filePath, parent); + } +} + +void PluginsWidget::on_restoreButton_clicked() +{ + const auto app = this->topLevelWidget(); + const auto profilePath = QDir(m_Organizer->profilePath()); + const auto pluginsName = QDir::cleanPath(profilePath.absoluteFilePath("plugins.txt")); + + QString choice = queryRestore(pluginsName, app); + if (!choice.isEmpty()) { + const auto groupsName = + QDir::cleanPath(profilePath.absoluteFilePath("plugingroups.txt")); + const auto loadOrderName = + QDir::cleanPath(profilePath.absoluteFilePath("loadorder.txt")); + + if (!tryRestore(pluginsName, choice, true, app) || + !tryRestore(loadOrderName, choice, true, app) || + !tryRestore(groupsName, choice, false, app)) { + const auto e = ::GetLastError(); + + QMessageBox::critical( + this, tr("Restore failed"), + tr("Failed to restore the backup. Errorcode: %1") + .arg(QString::fromStdWString(MOBase::formatSystemMessage(e)))); + } + m_PluginListModel->invalidate(); + } +} + +static bool createBackup(const QString& filePath, const QString& identifier, + QWidget* parent = nullptr) +{ + QString outPath = filePath + "." + identifier; + if (MOBase::shellCopy(QStringList(filePath), QStringList(outPath), parent)) { + QFileInfo fileInfo(filePath); + MOBase::removeOldFiles(fileInfo.absolutePath(), + fileInfo.fileName() + PATTERN_BACKUP_GLOB, 10, QDir::Name); + return true; + } else { + return false; + } +} + +static bool createBackup(const QString& filePath, const QDateTime& time, + QWidget* parent = nullptr) +{ + return createBackup(filePath, time.toString(PATTERN_BACKUP_DATE), parent); +} + +void PluginsWidget::on_saveButton_clicked() +{ + m_PluginList->writePluginLists(); + + const auto app = this->topLevelWidget(); + const auto profilePath = QDir(m_Organizer->profilePath()); + const auto pluginsName = QDir::cleanPath(profilePath.absoluteFilePath("plugins.txt")); + const auto groupsName = + QDir::cleanPath(profilePath.absoluteFilePath("plugingroups.txt")); + const auto loadOrderName = + QDir::cleanPath(profilePath.absoluteFilePath("loadorder.txt")); + const auto lockedOrderName = + QDir::cleanPath(profilePath.absoluteFilePath("lockedorder.txt")); + + const QDateTime now = QDateTime::currentDateTime(); + + if (createBackup(pluginsName, now, app) && createBackup(loadOrderName, now, app) && + createBackup(groupsName, now, app) && createBackup(lockedOrderName, now, app)) { + GUI::MessageDialog::showMessage(tr("Backup of load order created"), app); + } +} + +QMenu* PluginsWidget::listOptionsMenu() +{ + QMenu* const menu = new QMenu(this); + toggleForceEnabled = menu->addAction(tr("Hide force-enabled files"), this, + &PluginsWidget::toggleHideForceEnabled); + toggleForceEnabled->setCheckable(true); + + toggleIgnoreMasters = menu->addAction(tr("Ignore conflicts with masters"), this, + &PluginsWidget::toggleIgnoreMasterConflicts); + toggleIgnoreMasters->setCheckable(true); + + menu->addSeparator(); + + menu->addAction(tr("Collapse all"), [this]() { + ui->pluginList->collapseAll(); + ui->pluginList->scrollToTop(); + }); + menu->addAction(tr("Expand all"), [this]() { + ui->pluginList->expandAll(); + ui->pluginList->scrollToTop(); + }); + + menu->addSeparator(); + + menu->addAction(tr("Enable all"), [this]() { + if (QMessageBox::question(topLevelWidget(), tr("Confirm"), + tr("Really enable all plugins?"), + QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { + m_PluginListModel->setEnabledAll(true); + } + }); + menu->addAction(tr("Disable all"), [this]() { + if (QMessageBox::question(topLevelWidget(), tr("Confirm"), + tr("Really disable all plugins?"), + QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { + m_PluginListModel->setEnabledAll(false); + } + }); + + return menu; +} + +void PluginsWidget::saveState() +{ + auto* const settings = Settings::instance(); + settings->saveState(ui->pluginList->header()); + // Fluorine port: saveTreeExpandState recurses the model, which on window + // close is being torn down by the time the close event fires → SIGSEGV + // inside the visitRows traversal. Column widths above still save fine. + // Tree expansion state is a minor UX loss; not worth the crash. +} + +void PluginsWidget::restoreState() +{ + const auto* const settings = Settings::instance(); + settings->restoreState(ui->pluginList->header()); + settings->restoreTreeExpandState(ui->pluginList); + + const bool doHide = settings->get<bool>("hide_force_enabled", false); + toggleForceEnabled->setChecked(doHide); + toggleHideForceEnabled(); + + const bool doIgnore = settings->get<bool>("ignore_master_conflicts", false); + toggleIgnoreMasters->setChecked(doIgnore); + toggleIgnoreMasterConflicts(); +} + +static bool containsPlugin(const MOBase::IModInterface* mod, const MOBase::IPluginGame* game) +{ + const auto fileTree = mod ? mod->fileTree() : nullptr; + if (!fileTree) + return false; + std::shared_ptr<const MOBase::IFileTree> searchDir; + if (!game->modDataDirectory().isEmpty()) { + searchDir = fileTree->findDirectory(game->modDataDirectory()); + } else { + searchDir = fileTree; + } + if (searchDir) { + return std::ranges::any_of(*searchDir, [&](auto&& entry) { + if (!entry) + return false; + const QString filename = entry->name(); + return filename.endsWith(u".esp"_s, Qt::CaseInsensitive) || + filename.endsWith(u".esm"_s, Qt::CaseInsensitive) || + filename.endsWith(u".esl"_s, Qt::CaseInsensitive); + }); + } + return false; +} + +void PluginsWidget::onModStateChanged( + const std::map<QString, MOBase::IModList::ModStates>& mods) +{ + // HACK: the virtual file tree won't update until the next refresh, so keep track of + // any mods that might be newly activated + const auto modList = m_Organizer->modList(); + if (!modList) + return; + + for (const auto& [modName, modState] : mods) { + const auto mod = modList->getMod(modName); + if (containsPlugin(mod, m_Organizer->managedGame())) { + m_PluginList->notifyPendingState(modName, modState); + } + } + m_PluginListModel->refresh(); +} + +bool PluginsWidget::onAboutToRun([[maybe_unused]] const QString& binary) +{ + m_PluginList->writePluginLists(); + + const auto profilePath = QDir(m_Organizer->profilePath()); + const auto pluginsName = QDir::cleanPath(profilePath.absoluteFilePath("plugins.txt")); + const auto loadOrderName = + QDir::cleanPath(profilePath.absoluteFilePath("loadorder.txt")); + const auto parent = this->topLevelWidget(); + + if (QFileInfo::exists(pluginsName + ".snapshot")) { + MOBase::shellDeleteQuiet(pluginsName + ".snapshot", parent); + } + + if (QFileInfo::exists(loadOrderName + ".snapshot")) { + MOBase::shellDeleteQuiet(loadOrderName + ".snapshot", parent); + } + + if (QFileInfo(binary).fileName().compare("lootcli.exe") != 0) { + createBackup(pluginsName, "snapshot", parent); + createBackup(loadOrderName, "snapshot", parent); + m_IsRunningApp = true; + } + + return true; +} + +void PluginsWidget::onFinishedRun(const QString& binary, + [[maybe_unused]] unsigned int exitCode) +{ + const auto binaryName = QFileInfo(binary).fileName(); + if (binaryName.compare("lootcli.exe", Qt::CaseInsensitive) == 0) { + return; + } + + // queue up behind the vanilla callbacks which might not have run yet, so we can react + // after loadorder.txt changes + m_Organizer->onNextRefresh([=, this]() { + m_PluginList->refresh(); + checkLoadOrderChanged(binaryName); + m_IsRunningApp = false; + m_ExternalStatesChanged = false; + }); +} + +void PluginsWidget::onSettingChanged(const QString& key, + [[maybe_unused]] const QVariant& oldValue, + const QVariant& newValue) +{ + if (key == u"enable_sort_button"_s) { + // Sort button permanently hidden — no LOOT integration. + ui->sortButton->setVisible(false); + } +} + +static QByteArray hashFile(const QString& filePath) +{ + QCryptographicHash hash{QCryptographicHash::Sha1}; + QFile file{filePath}; + if (file.open(QIODevice::ReadOnly)) { + hash.addData(file.readAll()); + } else { + return ""_ba; + } + + return hash.result(); +} + +// Return a normalized signature of a plugins.txt / loadorder.txt so the load- +// order-changed check can tell a cosmetic rewrite (case fix, line-ending flip, +// trailing newline, BOM, stray blank line) apart from an actual reorder or add/ +// remove. On a case-sensitive filesystem MO2 often rewrites loadorder.txt +// during post-run refresh to match the on-disk case of each plugin, which +// would otherwise fire the warning on every launch. +static QStringList normalizedPluginList(const QString& filePath) +{ + QFile file{filePath}; + if (!file.open(QIODevice::ReadOnly)) { + return {}; + } + + QStringList out; + const QByteArray raw = file.readAll(); + for (const QByteArray& rawLine : raw.split('\n')) { + QString line = QString::fromUtf8(rawLine).trimmed(); + if (line.isEmpty() || line.startsWith('#')) { + continue; + } + // plugins.txt may prefix active entries with '*' — strip it so a re-sort + // that flips active state order isn't treated as a load-order edit here. + if (line.startsWith('*')) { + line = line.mid(1); + } + out.append(line.toLower()); + } + return out; +} + +void PluginsWidget::checkLoadOrderChanged(const QString& binaryName) +{ + const auto profilePath = QDir(m_Organizer->profilePath()); + const auto pluginsName = QDir::cleanPath(profilePath.absoluteFilePath("plugins.txt")); + const auto loadOrderName = + QDir::cleanPath(profilePath.absoluteFilePath("loadorder.txt")); + const auto parent = this->topLevelWidget(); + + const auto pluginsSnapshot = pluginsName + ".snapshot"; + const auto loadOrderSnapshot = loadOrderName + ".snapshot"; + + const auto pluginsFile = QFileInfo(pluginsName); + const auto loadOrderFile = QFileInfo(loadOrderName); + + if (!QFileInfo(loadOrderSnapshot).exists()) + return; + + const bool enableWarning = Settings::instance()->externalChangeWarning(); + + // Compare normalized plugin name lists instead of raw byte hashes: MO2's + // post-run refresh rewrites loadorder.txt with filesystem-correct case, + // which flips the sha1 even when the actual order/contents didn't change. + const bool loadOrderChanged = + normalizedPluginList(loadOrderName) != normalizedPluginList(loadOrderSnapshot); + const bool pluginsChanged = + normalizedPluginList(pluginsName) != normalizedPluginList(pluginsSnapshot); + + // we just refreshed and rewrote loadorder.txt if plugins.txt changed + if (enableWarning && + (m_ExternalStatesChanged || loadOrderChanged || pluginsChanged)) { + + if (false) { + // LOOT integration removed for Fluorine port. + } else { + const auto response = QMessageBox::warning( + parent, tr("Load order changed"), + tr("Load order was changed while running %1. Keep changes?").arg(binaryName), + QMessageBox::Yes | QMessageBox::No); + + if (response == QMessageBox::No) { + if (!tryRestore(pluginsName, "snapshot", true, parent) || + !tryRestore(loadOrderName, "snapshot", true, parent)) { + const auto e = ::GetLastError(); + + QMessageBox::critical( + this, tr("Restore failed"), + tr("Failed to restore the backup. Errorcode: %1") + .arg(QString::fromStdWString(MOBase::formatSystemMessage(e)))); + + return; + } + } + } + } + + MOBase::shellDeleteQuiet(pluginsSnapshot, parent); + MOBase::shellDeleteQuiet(loadOrderSnapshot, parent); + m_PluginListModel->invalidate(); +} + +void PluginsWidget::importLootGroups() +{ + // LOOT integration removed for Fluorine port. +} + +void PluginsWidget::synchronizePluginLists(MOBase::IOrganizer* organizer) +{ + MOBase::IPluginList* const ipluginlist = organizer->pluginList(); + if (ipluginlist == nullptr || ipluginlist == m_PluginList) { + return; + } + + std::function<void()> startRefresh = [this] { + m_OrganizerRefreshing = true; + m_PluginList->flushPendingStates(); + // if we just finished running an application, we want the vanilla plugin list to + // finish reading and rewriting the load order files so that we don't end up + // ignoring the change + if (!m_IsRunningApp) { + // Fluorine port: non-invalidating refresh. Upstream uses invalidate() + // which re-parses every plugin through the TES reader on each organizer + // refresh — over FUSE VFS with 100+ plugins that freezes the GUI for + // seconds on every mod enable/disable. Incremental refresh only parses + // new plugins; cached ones stay. + m_PluginListModel->refresh(); + } + }; + + organizer->onNextRefresh(startRefresh, false); + + ipluginlist->onRefreshed([this, organizer, startRefresh]() { + if (m_OrganizerRefreshing) { + m_OrganizerRefreshing = false; + organizer->onNextRefresh(startRefresh, false); + } + }); + + ipluginlist->onPluginMoved( + [this](const QString& name, int oldPriority, int newPriority) { + if (m_OrganizerRefreshing) + return; + m_PluginListModel->movePlugin(name, oldPriority, newPriority); + }); + + ipluginlist->onPluginStateChanged( + [this](const std::map<QString, MOBase::IPluginList::PluginStates>& infos) { + if (m_OrganizerRefreshing) + return; + m_PluginListModel->changePluginStates(infos); + }); + + m_PluginList->onPluginMoved([=, this](const QString& name, + [[maybe_unused]] int oldPriority, + int newPriority) { + if (m_PluginList->isRefreshing()) + return; + + ipluginlist->setPriority(name, newPriority); + }); + + m_PluginList->onPluginStateChanged( + [=, this](const std::map<QString, MOBase::IPluginList::PluginStates>& infos) { + if (m_IsRunningApp) { + m_ExternalStatesChanged = true; + } + + if (m_PluginList->isRefreshing() || infos.empty()) + return; + + for (const auto& [name, state] : infos) { + m_PanelInterface->setPluginState(name, + state == MOBase::IPluginList::STATE_ACTIVE); + } + }); +} + +} // namespace BSPluginList |
