From 549b84b0f8815737c93913b200dde3821a062cb0 Mon Sep 17 00:00:00 2001 From: SulfurNitride Date: Sat, 16 May 2026 19:51:40 -0500 Subject: mo2: catch up to upstream 2.5.3 Betas 3-11 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-area sync against the upstream MO2 2.5.3 beta line. Roughly three logical sections share the diff: Catch-up (Betas 3-11): - nxmaccessmanager: drop "supporter" from validRoles so non-premium supporters get the right download path (Beta 10) - mainwindow: disable tutorial triggers at the trigger sites; Qt 6.11 lockup on child windows (Beta 4) - basic_games: add Kingdom Come Deliverance 2, Slay the Spire 2 (Betas 11, 6) - uibase: add NXM collections parsing fields/accessors + (?:nxm|modl) scheme support in NXMUrl (Beta 9) - Starfield: blueprintPrefix() on IPluginGame; plugins.txt write suppression for blueprint plugins; hasInvalidBlueprint / hasUnpairedBlueprint diagnoses; Title-keyed content catalog consolidation (Betas 3-5) - downloadmanager + systemtraymanager + organizercore + iuserinterface + settings: add Beta 11 "show notifications when downloads complete or fail" toggle and the showNotification plumbing - nxmaccessmanager: restore upstream re-entry guard for OAuth refresh and the styled OAuth response page (Beta 11) - organizer_en.ts and game-bethesda *_en.ts: pull upstream HEAD strings Workarounds tab removed: - Delete settingsdialogworkarounds.{h,cpp}; drop the tab in settingsdialog.ui; move "Enable archives parsing" into General - Hardcode GameSettings::forceEnableCoreFiles() to true so the primary-master toggle-off bug (DLCs unchecked on tab refresh) can't recur; setter becomes a no-op - No-op stubs for offlineMode, useProxy, useCustomBrowser, Steam appID/credentials, executablesBlacklist, skipFileSuffixes, skipDirectories — UI gone, accessors keep call sites green - settingsdialognexus drops the custom-browser wiring; spawn drops the "Change the blacklist" Retry path Beta 9 download manager port: - fileID-first match in nxmFilesAvailable and nxmFileInfoFromMd5Available; filename remains the fallback - NexusInterface::isActiveFileStatus() helper; rewrite of nxmUpdatesAvailable to use pickNewestVersion / findUpdateChainSuccessors / resolveInstalledFileId. ARCHIVED_HIDDEN now correctly treated as inactive - std::optional reservedID on DownloadInfo factories and on the three addDownload overloads; startDownloadURLs and startDownloadURLWithMeta return the canonical DownloadID instead of m_ActiveDownloads.size()-1 (which was the index, not an ID) - QHash m_ByID maintained alongside m_ActiveDownloads at every mutation site; downloadInfoByID is now O(1) via m_ByID.value(id, nullptr) LOOT remains intentionally stripped (lootcli build cruft cleaned up separately). BSA wide-path skipped — Linux UTF-8 paths already work. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/src/downloadmanager.cpp | 161 ++-- src/src/downloadmanager.h | 28 +- src/src/iuserinterface.h | 5 + src/src/mainwindow.cpp | 264 ++++--- src/src/mainwindow.h | 4 + src/src/nexusinterface.h | 25 + src/src/nxmaccessmanager.cpp | 30 +- src/src/organizer_en.ts | 1290 +++++++++++++++++---------------- src/src/organizercore.cpp | 8 + src/src/organizercore.h | 5 + src/src/settings.cpp | 149 ++-- src/src/settings.h | 5 + src/src/settingsdialog.cpp | 3 - src/src/settingsdialog.ui | 463 +----------- src/src/settingsdialoggeneral.cpp | 6 + src/src/settingsdialognexus.cpp | 28 - src/src/settingsdialognexus.h | 2 - src/src/settingsdialogworkarounds.cpp | 238 ------ src/src/settingsdialogworkarounds.h | 49 -- src/src/spawn.cpp | 31 +- src/src/systemtraymanager.cpp | 15 + src/src/systemtraymanager.h | 4 + 22 files changed, 1138 insertions(+), 1675 deletions(-) delete mode 100644 src/src/settingsdialogworkarounds.cpp delete mode 100644 src/src/settingsdialogworkarounds.h (limited to 'src') diff --git a/src/src/downloadmanager.cpp b/src/src/downloadmanager.cpp index d7e5ce5..46e99aa 100644 --- a/src/src/downloadmanager.cpp +++ b/src/src/downloadmanager.cpp @@ -85,10 +85,11 @@ int DownloadManager::m_DirWatcherDisabler = 0; DownloadManager::DownloadInfo* DownloadManager::DownloadInfo::createNew(const ModRepositoryFileInfo* fileInfo, - const QStringList& URLs) + const QStringList& URLs, + std::optional reservedID) { DownloadInfo* info = new DownloadInfo; - info->m_DownloadID = s_NextDownloadID++; + info->m_DownloadID = reservedID.value_or(s_NextDownloadID++); info->m_StartTime.start(); info->m_PreResumeSize = 0LL; info->m_Progress = std::make_pair(0, "0.0 B/s "); @@ -107,7 +108,8 @@ DownloadManager::DownloadInfo::createNew(const ModRepositoryFileInfo* fileInfo, DownloadManager::DownloadInfo* DownloadManager::DownloadInfo::createFromMeta(const QString& filePath, bool showHidden, const QString outputDirectory, - std::optional fileSize) + std::optional fileSize, + std::optional reservedID) { DownloadInfo* info = new DownloadInfo; @@ -144,7 +146,7 @@ DownloadManager::DownloadInfo::createFromMeta(const QString& filePath, bool show } } - info->m_DownloadID = s_NextDownloadID++; + info->m_DownloadID = reservedID.value_or(s_NextDownloadID++); info->m_Output.setFileName(filePath); info->m_TotalSize = fileSize ? *fileSize : QFileInfo(filePath).size(); info->m_PreResumeSize = info->m_TotalSize; @@ -269,6 +271,7 @@ DownloadManager::~DownloadManager() delete *iter; } m_ActiveDownloads.clear(); + m_ByID.clear(); } void DownloadManager::setParentWidget(QWidget* w) @@ -370,6 +373,7 @@ void DownloadManager::refreshList() iter != m_ActiveDownloads.end();) { if (((*iter)->m_State == STATE_READY) || ((*iter)->m_State == STATE_INSTALLED) || ((*iter)->m_State == STATE_UNINSTALLED)) { + m_ByID.remove((*iter)->m_DownloadID); delete *iter; iter = m_ActiveDownloads.erase(iter); } else { @@ -454,6 +458,7 @@ void DownloadManager::refreshList() } cx.self.m_ActiveDownloads.push_front(info); + cx.self.m_ByID.insert(info->m_DownloadID, info); cx.seen.insert(std::move(lc)); cx.seen.insert( QFileInfo(info->m_Output.fileName()).fileName().toLower().toStdWString()); @@ -499,7 +504,8 @@ void DownloadManager::queryDownloadListInfo() } bool DownloadManager::addDownload(const QStringList& URLs, QString gameName, int modID, - int fileID, const ModRepositoryFileInfo* fileInfo) + int fileID, const ModRepositoryFileInfo* fileInfo, + std::optional reservedID) { // Parse the URL properly instead of feeding it to QFileInfo — QFileInfo // doesn't understand URLs, so its fileName() can pull in query-string junk @@ -561,7 +567,7 @@ bool DownloadManager::addDownload(const QStringList& URLs, QString gameName, int QNetworkRequest::AlwaysNetwork); request.setHttp2Configuration(h2Conf); return addDownload(m_NexusInterface->getAccessManager()->get(request), URLs, fileName, - gameName, modID, fileID, fileInfo); + gameName, modID, fileID, fileInfo, reservedID); } bool DownloadManager::addDownload(QNetworkReply* reply, @@ -578,11 +584,12 @@ bool DownloadManager::addDownload(QNetworkReply* reply, bool DownloadManager::addDownload(QNetworkReply* reply, const QStringList& URLs, const QString& fileName, QString gameName, int modID, - int fileID, const ModRepositoryFileInfo* fileInfo) + int fileID, const ModRepositoryFileInfo* fileInfo, + std::optional reservedID) { // download invoked from an already open network reply (i.e. download link in the // browser) - DownloadInfo* newDownload = DownloadInfo::createNew(fileInfo, URLs); + DownloadInfo* newDownload = DownloadInfo::createNew(fileInfo, URLs, reservedID); QString baseName = fileName; if (!fileInfo->fileName.isEmpty()) { @@ -675,6 +682,7 @@ void DownloadManager::startDownload(QNetworkReply* reply, DownloadInfo* newDownl emit aboutToUpdate(); m_ActiveDownloads.append(newDownload); + m_ByID.insert(newDownload->m_DownloadID, newDownload); emit update(-1); emit downloadAdded(); @@ -986,6 +994,7 @@ void DownloadManager::removeDownload(int index, bool deleteFile) if ((removeAll && (downloadState >= STATE_READY)) || (removeState == downloadState)) { removeFile(index, deleteFile); + m_ByID.remove((*iter)->m_DownloadID); delete *iter; iter = m_ActiveDownloads.erase(iter); } else { @@ -1001,6 +1010,7 @@ void DownloadManager::removeDownload(int index, bool deleteFile) } removeFile(index, deleteFile); + m_ByID.remove(m_ActiveDownloads.at(index)->m_DownloadID); delete m_ActiveDownloads.at(index); m_ActiveDownloads.erase(m_ActiveDownloads.begin() + index); } @@ -1115,15 +1125,11 @@ void DownloadManager::resumeDownloadInt(int 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; - } + // O(1) via m_ByID; the prior linear scan over m_ActiveDownloads couldn't + // distinguish "never existed" from "valid but waiting on a callback that + // already erased it," so late callbacks would dereference freed memory. + // Callers treat nullptr as the canonical "stale ID" signal. + return m_ByID.value(id, nullptr); } void DownloadManager::queryInfo(int index) @@ -1425,8 +1431,11 @@ QString DownloadManager::getDisplayName(int index) const throw MyException(tr("display name: invalid download index %1").arg(index)); } - DownloadInfo* info = m_ActiveDownloads.at(index); + return getDisplayNameForInfo(m_ActiveDownloads.at(index)); +} +QString DownloadManager::getDisplayNameForInfo(const DownloadInfo* info) const +{ QTextDocument doc; if (!info->m_FileInfo->name.isEmpty()) { doc.setHtml(info->m_FileInfo->name); @@ -1861,6 +1870,11 @@ void DownloadManager::nxmDescriptionAvailable(QString, int, QVariant userData, info->m_FileInfo->modName = doc.toPlainText(); if (info->m_FileInfo->fileID != 0) { setState(info, STATE_READY); + if (m_OrganizerCore->settings().interface().showDownloadNotifications()) { + m_OrganizerCore->showNotification( + tr("Download complete"), + tr("%1 is ready to be installed.").arg(getDisplayNameForInfo(info))); + } } else { setState(info, STATE_FETCHINGFILEINFO); } @@ -1893,33 +1907,59 @@ void DownloadManager::nxmFilesAvailable(QString, int, QVariant userData, alternativeLocalName = match.captured(1); } + // Copy the matched Nexus file metadata onto info. Used by both passes + // below so the fileID-priority path and the filename-fallback path stay + // consistent. + auto applyMatch = [&info](const QVariantMap& fileInfo) { + 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(); + }; + 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; + // Pass 1: prefer matching by Nexus file_id when we already have one. + // file_id is canonical; filename can disagree after a local rename or + // a Nexus-side filename change. + if (info->m_FileInfo->fileID != 0) { + for (const QVariant& file : files) { + QVariantMap const fileInfo = file.toMap(); + if (fileInfo["file_id"].toInt() == info->m_FileInfo->fileID) { + applyMatch(fileInfo); + found = true; + break; + } + } + } + + // Pass 2: fall back to filename matching (also the only path when we + // don't have a usable fileID yet). + if (!found) { + 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)) { + applyMatch(fileInfo); + found = true; + break; } - 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; } } @@ -2049,9 +2089,14 @@ bool ServerByPreference(const ServerList::container& preferredServers, int DownloadManager::startDownloadURLs(const QStringList& urls) { + // Reserve the download ID up front so the caller (plugin via IOrganizer) + // gets the canonical ID rather than a stale m_ActiveDownloads index. + const unsigned int reservedID = DownloadInfo::newDownloadID(); ModRepositoryFileInfo const info; - addDownload(urls, "", -1, -1, &info); - return m_ActiveDownloads.size() - 1; + if (!addDownload(urls, "", -1, -1, &info, reservedID)) { + return 0; + } + return static_cast(reservedID); } int DownloadManager::startDownloadURLWithMeta(const QString& url, const QString& name, @@ -2059,15 +2104,16 @@ int DownloadManager::startDownloadURLWithMeta(const QString& url, const QString& const QString& version, const QString& source) { + const unsigned int reservedID = DownloadInfo::newDownloadID(); ModRepositoryFileInfo info; info.name = name; info.modName = modName; info.version = version; info.repository = source; - if (!addDownload(QStringList() << url, "", -1, -1, &info)) { + if (!addDownload(QStringList() << url, "", -1, -1, &info, reservedID)) { return 0; } - return m_ActiveDownloads.size() - 1; + return static_cast(reservedID); } int DownloadManager::startDownloadNexusFile(const QString& gameName, int modID, @@ -2182,6 +2228,21 @@ void DownloadManager::nxmFileInfoFromMd5Available(QString gameName, QVariant use auto resultlist = resultData.toList(); int chosenIdx = resultlist.count() > 1 ? -1 : 0; + // Prefer matching by Nexus file_id when we already have one. file_id is + // canonical; the filename pass below can mis-pick after a local rename + // or a Nexus-side filename change. + if (chosenIdx < 0 && info->m_FileInfo->fileID != 0) { + for (int i = 0; i < resultlist.count(); i++) { + auto results = resultlist[i].toMap(); + auto fileDetails = results["file_details"].toMap(); + + if (fileDetails["file_id"].toInt() == info->m_FileInfo->fileID) { + chosenIdx = i; + break; + } + } + } + // Look for the exact file name if (chosenIdx < 0) { for (int i = 0; i < resultlist.count(); i++) { @@ -2315,6 +2376,7 @@ void DownloadManager::nxmRequestFailed(QString gameName, int modID, int fileID, if (info->m_FileInfo->modID == modID) { if (info->m_State < STATE_FETCHINGMODINFO) { + m_ByID.remove(info->m_DownloadID); m_ActiveDownloads.erase(iter); delete info; } else { @@ -2372,6 +2434,12 @@ void DownloadManager::downloadFinished(int index) emit showMessage(tr("Download failed: %1 (%2)") .arg(reply->errorString()) .arg(reply->error())); + if (m_OrganizerCore->settings().interface().showDownloadNotifications()) { + m_OrganizerCore->showNotification( + tr("Download failed"), + tr("%1 failed to download.").arg(getDisplayNameForInfo(info)), + QSystemTrayIcon::MessageIcon::Critical); + } } error = true; setState(info, STATE_ERROR); @@ -2390,6 +2458,7 @@ void DownloadManager::downloadFinished(int index) if (info->m_State == STATE_CANCELED || (info->m_Tries == 0 && error)) { emit aboutToUpdate(); info->m_Output.remove(); + m_ByID.remove(info->m_DownloadID); delete info; m_ActiveDownloads.erase(m_ActiveDownloads.begin() + index); if (error) diff --git a/src/src/downloadmanager.h b/src/src/downloadmanager.h index dce9069..9a4b568 100644 --- a/src/src/downloadmanager.h +++ b/src/src/downloadmanager.h @@ -117,10 +117,20 @@ private: bool m_Hidden{false}; static DownloadInfo* createNew(const MOBase::ModRepositoryFileInfo* fileInfo, - const QStringList& URLs); + const QStringList& URLs, + std::optional reservedID = {}); static DownloadInfo* createFromMeta(const QString& filePath, bool showHidden, QString outputDirectory, - std::optional fileSize = {}); + std::optional fileSize = {}, + std::optional reservedID = {}); + + /** + * @brief Allocate a fresh download ID without creating a DownloadInfo. + * Used by callers (notably addNXMDownload) that want to dedupe a + * pending NXM link against the ID it will eventually own, before + * the actual DownloadInfo is materialized. + */ + static unsigned int newDownloadID() { return s_NextDownloadID++; } /** * @brief rename the file @@ -238,7 +248,8 @@ public: bool addDownload(QNetworkReply* reply, const QStringList& URLs, const QString& fileName, QString gameName, int modID, int fileID = 0, const MOBase::ModRepositoryFileInfo* fileInfo = - new MOBase::ModRepositoryFileInfo()); + new MOBase::ModRepositoryFileInfo(), + std::optional reservedID = {}); /** * @brief start a download using a nxm-link @@ -290,6 +301,8 @@ public: **/ QString getDisplayName(int index) const; + QString getDisplayNameForInfo(const DownloadInfo* info) const; + /** * @brief retrieve the filename of the download specified by index * @@ -572,7 +585,8 @@ private: *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); + const MOBase::ModRepositoryFileInfo* fileInfo, + std::optional reservedID = {}); // important: the caller has to lock the list-mutex, otherwise the // DownloadInfo-pointer might get invalidated at any time @@ -609,6 +623,12 @@ private: QVector m_ActiveDownloads; + // O(1) DownloadID → DownloadInfo* lookup, maintained in parallel with + // m_ActiveDownloads. Lets late callbacks detect a stale ID via + // m_ByID.contains() instead of a linear m_ActiveDownloads scan that + // can't distinguish "not found" from "found but invalidated". + QHash m_ByID; + QString m_OutputDirectory; std::set m_RequestIDs; QVector m_AlphabeticalTranslation; diff --git a/src/src/iuserinterface.h b/src/src/iuserinterface.h index 9ff0df1..8ebdef5 100644 --- a/src/src/iuserinterface.h +++ b/src/src/iuserinterface.h @@ -4,6 +4,7 @@ #include "modinfodialogfwd.h" #include #include +#include #include #include #include @@ -27,6 +28,10 @@ public: virtual MOBase::DelayedFileWriterBase& archivesWriter() = 0; virtual QMainWindow* mainWindow() = 0; + + virtual void showNotification(const QString& title, const QString& message, + QSystemTrayIcon::MessageIcon icon = + QSystemTrayIcon::MessageIcon::Information) = 0; }; #endif // IUSERINTERFACE_H diff --git a/src/src/mainwindow.cpp b/src/src/mainwindow.cpp index ffdd4bb..941b7c7 100644 --- a/src/src/mainwindow.cpp +++ b/src/src/mainwindow.cpp @@ -1309,11 +1309,13 @@ void MainWindow::showEvent(QShowEvent* event) connect(this, SIGNAL(styleChanged(QString)), this, SLOT(updateStyle(QString))); // only the first time the window becomes visible + /* Disabling tutorials m_Tutorial.registerControl(); hookUpWindowTutorials(); - + */ if (m_OrganizerCore.settings().firstStart()) { + /* Disabling tutorials QString firstStepsTutorial = ToQString(AppConfig::firstStepsTutorial()); if (TutorialManager::instance().hasTutorial(firstStepsTutorial)) { if (shouldStartTutorial()) { @@ -1329,7 +1331,7 @@ void MainWindow::showEvent(QShowEvent* event) 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 " @@ -2367,6 +2369,14 @@ QMainWindow* MainWindow::mainWindow() return this; } +void MainWindow::showNotification(const QString& title, const QString& message, + QSystemTrayIcon::MessageIcon icon) +{ + if (m_SystemTrayManager) { + m_SystemTrayManager->showNotification(title, message, icon); + } +} + void MainWindow::on_tabWidget_currentChanged(int index) { QWidget* currentWidget = ui->tabWidget->widget(index); @@ -3385,138 +3395,184 @@ void MainWindow::finishUpdateInfo(const NxmUpdateInfoData& data) } } -void MainWindow::nxmUpdatesAvailable(QString gameName, int modID, QVariant userData, - QVariant resultData, int requestID) +namespace { - 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(); +/** + * @brief Walk the file_updates chain starting at installedFileId and return + * the ordered list of successor file_ids (oldest first). + */ +std::vector +findUpdateChainSuccessors(int installedFileId, + const QHash& updatesByOldId) +{ + std::vector successors; + QSet visited; + int currentId = installedFileId; + + while (true) { + const auto updateIt = updatesByOldId.constFind(currentId); + if (updateIt == updatesByOldId.constEnd()) { break; } - } - - std::vector const modsList = ModInfo::getByModID(gameNameReal, modID); - bool requiresInfo = false; + currentId = updateIt.value()["new_file_id"].toInt(); + if (visited.contains(currentId)) { + break; + } + visited.insert(currentId); + successors.push_back(currentId); + } - for (const auto& mod : modsList) { - QString validNewVersion; - int newModStatus = -1; - QString const installedFile = QFileInfo(mod->installationFile()).fileName(); + return successors; +} - if (!installedFile.isEmpty()) { - QVariantMap foundFileData; +/** + * @brief Resolve the Nexus file_id of a mod's installed file. We don't yet + * persist a nexusFileId on ModInfoRegular like upstream does, so fall + * back to matching by filename against both the files list and the + * update chain (archived-hidden files can disappear from the files list + * but still appear in the update chain). + */ +std::optional resolveInstalledFileId(const ModInfo::Ptr& mod, + const QList& files, + const QList& fileUpdates) +{ + const QString installedFileName = QFileInfo(mod->installationFile()).fileName(); + if (installedFileName.isEmpty()) { + return std::nullopt; + } - // update the file status - for (const auto& file : files) { - QVariantMap fileData = file.toMap(); + for (const auto& file : files) { + const auto fileData = file.toMap(); + if (fileData["file_name"].toString().compare(installedFileName, + Qt::CaseInsensitive) == 0) { + return fileData["file_id"].toInt(); + } + } - if (fileData["file_name"].toString().compare(installedFile, - Qt::CaseInsensitive) == 0) { - foundFileData = fileData; - newModStatus = foundFileData["category_id"].toInt(); + for (const auto& updateEntry : fileUpdates) { + const auto updateData = updateEntry.toMap(); + if (installedFileName.compare(updateData["old_file_name"].toString(), + Qt::CaseInsensitive) == 0) { + return updateData["old_file_id"].toInt(); + } + if (installedFileName.compare(updateData["new_file_name"].toString(), + Qt::CaseInsensitive) == 0) { + return updateData["new_file_id"].toInt(); + } + } + return std::nullopt; +} - if (newModStatus != NexusInterface::FileStatus::OLD_VERSION && - newModStatus != NexusInterface::FileStatus::REMOVED && - newModStatus != NexusInterface::FileStatus::ARCHIVED) { +/** + * @brief Pick the newest version string for an installed file by walking + * the update chain. Active files prefer an active successor; obsolete + * files also accept the latest downloadable (listed, not removed) + * successor; both fall back to the file's own version. + */ +QString pickNewestVersion(int installedFileId, bool fileIsActive, + const QHash& filesById, + const QHash& updatesByOldId) +{ + const auto chainSuccessors = + findUpdateChainSuccessors(installedFileId, updatesByOldId); - // since the file is still active if there are no updates for it, use this - // as current version - validNewVersion = foundFileData["version"].toString(); - } - break; - } - } + std::optional latestActiveSuccessor; + std::optional latestDownloadableSuccessor; - 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; - } + for (auto it = chainSuccessors.rbegin(); it != chainSuccessors.rend(); ++it) { + const int successorId = *it; + const auto succIt = filesById.constFind(successorId); - // look for updates of the file - int currentUpdateId = -1; + if (succIt == filesById.constEnd()) { + // Not in files list — effectively archived and hidden by the author. + continue; + } - // 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(); + const int succStatus = succIt.value()["category_id"].toInt(); - if (installedFile.compare(updateData["old_file_name"].toString(), - Qt::CaseInsensitive) == 0) { - currentUpdateId = updateData["old_file_id"].toInt(); - break; - } - } + if (NexusInterface::isActiveFileStatus(succStatus)) { + latestActiveSuccessor = successorId; + break; + } - bool foundActiveUpdate = false; + if (!latestDownloadableSuccessor && + succStatus != NexusInterface::FileStatus::REMOVED) { + latestDownloadableSuccessor = successorId; + } + } - // there is at least one update - if (currentUpdateId > 0) { - bool lookForMoreUpdates = true; + std::optional versionSourceId; + if (latestActiveSuccessor) { + versionSourceId = latestActiveSuccessor; + } else if (!fileIsActive && latestDownloadableSuccessor) { + versionSourceId = latestDownloadableSuccessor; + } else { + versionSourceId = installedFileId; + } - // follow the update chain until there are no more updates - while (lookForMoreUpdates) { - lookForMoreUpdates = false; + return filesById.value(*versionSourceId)["version"].toString(); +} - for (const auto& updateEntry : fileUpdates) { - const QVariantMap& updateData = updateEntry.toMap(); +} // namespace - if (currentUpdateId == updateData["old_file_id"].toInt()) { - currentUpdateId = updateData["new_file_id"].toInt(); +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(); - // check if the new file is still active - for (const auto& file : files) { - const QVariantMap& fileData = file.toMap(); + QString gameShortName; + for (IPluginGame* game : m_PluginContainer.plugins()) { + if (game->gameNexusName() == gameName) { + gameShortName = game->gameShortName(); + break; + } + } - if (currentUpdateId == fileData["file_id"].toInt()) { - int const updateStatus = fileData["category_id"].toInt(); + QHash filesById; + filesById.reserve(files.size()); + for (const auto& file : files) { + const QVariantMap fileData = file.toMap(); + filesById.insert(fileData["file_id"].toInt(), fileData); + } - if (updateStatus != NexusInterface::FileStatus::OLD_VERSION && - updateStatus != NexusInterface::FileStatus::REMOVED && - updateStatus != NexusInterface::FileStatus::ARCHIVED) { + QHash updatesByOldId; + updatesByOldId.reserve(fileUpdates.size()); + for (const auto& updateEntry : fileUpdates) { + const QVariantMap updateData = updateEntry.toMap(); + updatesByOldId.insert(updateData["old_file_id"].toInt(), updateData); + } - // new version is active, so record it - validNewVersion = fileData["version"].toString(); - foundActiveUpdate = true; - } - break; - } - } + bool requiresInfo = false; + std::vector const installedMods = ModInfo::getByModID(gameShortName, modID); - lookForMoreUpdates = true; - break; - } - } - } - } + for (const auto& mod : installedMods) { + mod->setLastNexusUpdate(QDateTime::currentDateTimeUtc()); - // 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 + const auto installedFileId = resolveInstalledFileId(mod, files, fileUpdates); + if (!installedFileId) { + // Manually-created mod (modID set via the edit dialog) or anything we + // can't tie back to a Nexus file by filename. Fall back to the global + // mod page version. requiresInfo = true; + continue; } - if (newModStatus > 0) { - mod->setNexusFileStatus(newModStatus); + int nexusFileStatus = NexusInterface::FileStatus::ARCHIVED_HIDDEN; + if (const auto fileIt = filesById.constFind(*installedFileId); + fileIt != filesById.constEnd()) { + nexusFileStatus = fileIt.value()["category_id"].toInt(); } + mod->setNexusFileStatus(nexusFileStatus); - if (!validNewVersion.isEmpty()) { - mod->setNewestVersion(validNewVersion); - mod->setLastNexusUpdate(QDateTime::currentDateTimeUtc()); + const QString newestVersionValue = pickNewestVersion( + *installedFileId, NexusInterface::isActiveFileStatus(nexusFileStatus), + filesById, updatesByOldId); + if (!newestVersionValue.isEmpty()) { + mod->setNewestVersion(newestVersionValue); } } @@ -3524,7 +3580,7 @@ void MainWindow::nxmUpdatesAvailable(QString gameName, int modID, QVariant userD ui->modList->invalidateFilter(); if (requiresInfo) { - NexusInterface::instance().requestModInfo(gameNameReal, modID, this, QVariant(), + NexusInterface::instance().requestModInfo(gameShortName, modID, this, QVariant(), QString()); } } diff --git a/src/src/mainwindow.h b/src/src/mainwindow.h index 9af07bd..6443862 100644 --- a/src/src/mainwindow.h +++ b/src/src/mainwindow.h @@ -132,6 +132,10 @@ public: QMainWindow* mainWindow() override; + void showNotification(const QString& title, const QString& message, + QSystemTrayIcon::MessageIcon icon = + QSystemTrayIcon::MessageIcon::Information) override; + bool addProfile(); void updateBSAList(const QStringList& defaultArchives, const QStringList& activeArchives) override; diff --git a/src/src/nexusinterface.h b/src/src/nexusinterface.h index e8e2798..1fa8792 100644 --- a/src/src/nexusinterface.h +++ b/src/src/nexusinterface.h @@ -183,6 +183,31 @@ public: // listed we can assume they were hidden. }; + /** + * @brief Whether a Nexus file category represents a file that is still + * listed and considered current on the mod page. + * + * Inactive (false) refers to files that have been marked as obsolete or have been + * removed. + */ + static bool isActiveFileStatus(int status) + { + switch (status) { + case FileStatus::MAIN: + case FileStatus::UPDATE: + case FileStatus::OPTIONAL_FILE: + case FileStatus::MISCELLANEOUS: + return true; + case FileStatus::OLD_VERSION: + case FileStatus::REMOVED: + case FileStatus::ARCHIVED: + case FileStatus::ARCHIVED_HIDDEN: + return false; + default: + return false; + } + } + public: static APILimits defaultAPILimits(); static APILimits parseLimits(const QNetworkReply* reply); diff --git a/src/src/nxmaccessmanager.cpp b/src/src/nxmaccessmanager.cpp index 9e00401..e5d2e71 100644 --- a/src/src/nxmaccessmanager.cpp +++ b/src/src/nxmaccessmanager.cpp @@ -31,6 +31,7 @@ along with Mod Organizer. If not, see . #include #include #include +#include #include #include #include @@ -329,7 +330,7 @@ void ValidationAttempt::onFinished() const QString id = data.value("sub").toString(); const QString name = data.value("name").toString(); const auto roles = data.value("membership_roles").toArray(); - QStringList validRoles = {"premium", "lifetimepremium", "supporter"}; + QStringList validRoles = {"premium", "lifetimepremium"}; bool premium = false; for (auto role : roles) { QString roleVal = role.toString(); @@ -982,11 +983,15 @@ bool NXMAccessManager::validateWaiting() const void NXMAccessManager::connectOrRefresh(const NexusOAuthTokens tokens) { + if (m_NexusOAuth->status() != QAbstractOAuth::Status::NotAuthenticated && + m_NexusOAuth->status() != QAbstractOAuth::Status::Granted) + return; const auto clientId = NexusOAuth::clientId(); if (clientId.isEmpty()) { handleOAuthError(QObject::tr("No OAuth client id configured.")); return; } + m_ValidationState = NotChecked; m_NexusOAuth->setAuthorizationUrl(QUrl(NexusOAuth::authorizeUrl())); m_NexusOAuth->setTokenUrl(QUrl(NexusOAuth::tokenUrl())); m_NexusOAuth->setClientIdentifier(clientId); @@ -995,10 +1000,27 @@ void NXMAccessManager::connectOrRefresh(const NexusOAuthTokens tokens) m_NexusOAuth->setRequestedScopeTokens(scope); m_NexusOAuthReplyHandler->close(); m_NexusOAuthReplyHandler->setCallbackPath(QUrl(NexusOAuth::redirectUri()).path()); + QFile logo(":/MO/gui/app_icon"); + logo.open(QIODevice::ReadOnly); + QByteArray imageData = logo.readAll(); + logo.close(); + QByteArray base64Data = imageData.toBase64(); + QString imageSrc = + QString("data:image/png;base64,") + QString::fromLatin1(base64Data); m_NexusOAuthReplyHandler->setCallbackText( - QObject::tr("

Mod Organizer

Authorization complete. You " - "may close this " - "window.

")); + QString("\n" + "\"Mod\n") + .arg(imageSrc) + + QObject::tr("

Authorization complete.
You may close this " + "window.

\n")); if (!m_NexusOAuthReplyHandler->listen(QHostAddress::LocalHost, NexusOAuth::redirectPort())) { handleOAuthError(QObject::tr("Failed to bind to localhost on port %1.") diff --git a/src/src/organizer_en.ts b/src/src/organizer_en.ts index 0575bee..abcf2fd 100644 --- a/src/src/organizer_en.ts +++ b/src/src/organizer_en.ts @@ -710,42 +710,37 @@ p, li { white-space: pre-wrap; } - - Enter API Key Manually - - - - + <h3>Confirmation</h3> - + The instance is about to be created. Review the information below and press 'Finish'. - + Instance creation log - + Launch the new instance - + < Back - + Next > - + Cancel @@ -1008,145 +1003,150 @@ p, li { white-space: pre-wrap; } + Visit the uploader's profile + + + + Open File - + Open Meta File - - - + + + Reveal in Explorer - - + + Delete... - + Un-Hide - + Hide - - + + Cancel - + Pause - + Resume - + Delete Installed Downloads... - + Delete Uninstalled Downloads... - + Delete All Downloads... - + Hide Installed... - + Hide Uninstalled... - + Hide All... - + Un-Hide All... - + Delete download - + Move to the Recycle Bin - - - + + + Delete Files? - + This will remove all finished downloads from this list and from disk. Are you absolutely sure you want to proceed? - + This will remove all installed downloads from this list and from disk. Are you absolutely sure you want to proceed? - + This will remove all uninstalled downloads from this list and from disk. Are you absolutely sure you want to proceed? - - - + + + Hide Files? - + This will remove all finished downloads from this list (but NOT from disk). - + This will remove all installed downloads from this list (but NOT from disk). - + This will remove all uninstalled downloads from this list (but NOT from disk). @@ -1154,22 +1154,22 @@ Are you absolutely sure you want to proceed? DownloadManager - + failed to rename "%1" to "%2" - + Memory allocation error (in refreshing directory). - + Query Metadata - + There are %1 downloads with incomplete metadata. Do you want to fetch all incomplete metadata? @@ -1177,32 +1177,32 @@ API requests will be consumed, and Mod Organizer may stutter. - + failed to download %1: could not open output file: %2 - + Download again? - + 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. - + Wrong Game - + The download link is for a mod for "%1" but this instance of MO has been set up for "%2". - + There is already a download queued for this file. Mod %1 @@ -1210,12 +1210,12 @@ File %2 - + Already Queued - + There is already a download started for this file. Mod %1: %2 @@ -1223,287 +1223,297 @@ File %3: %4 - + Already Started - - + + remove: invalid download index %1 - + failed to delete %1 - + failed to delete meta file for %1 - + restore: invalid download index: %1 - + cancel: invalid download index %1 - + pause: invalid download index %1 - + resume: invalid download index %1 - + resume (int): invalid download index %1 - + No known download urls. Sorry, this download can't be resumed. - - + + query: invalid download index %1 - + Please enter the Nexus mod ID - + Mod ID: - + Please select the source game code for %1 - + Hashing download file '%1' - + Cancel - + VisitNexus: invalid download index %1 - + Nexus ID for this Mod is unknown - + + VisitUploaderProfile: invalid download index %1 + + + + + Uploader for this Mod is unknown + + + + OpenFile: invalid download index %1 - + OpenFileInDownloadsFolder: invalid download index %1 - + get pending: invalid download index %1 - + get path: invalid download index %1 - + Main - + Update - + Optional - + Old - + Miscellaneous - + Deleted - + Archived - + Unknown - + display name: invalid download index %1 - + file name: invalid download index %1 - + file time: invalid download index %1 - + file size: invalid download index %1 - + progress: invalid download index %1 - + state: invalid download index %1 - + infocomplete: invalid download index %1 - - - + + + mod id: invalid download index %1 - + ishidden: invalid download index %1 - + file info: invalid download index %1 - + mark installed: invalid download index %1 - + mark uninstalled: invalid download index %1 - + %1% - %2 - ~%3 - + Memory allocation error (in processing progress event). - + Memory allocation error (in processing downloaded data). - + Information updated - - + + No matching file found on Nexus! Maybe this file is no longer available or it was renamed? - + No file on Nexus matches the selected file by name. Please manually choose the correct one. - + No download server available. Please try again later. - + Failed to request file info from nexus: %1 - + Warning: Content type is: %1 - + Download header content length: %1 downloaded file size: %2 - + Download failed: %1 (%2) - + We were unable to download the file due to errors after four retries. There may be an issue with the Nexus servers. - + failed to re-open %1 - + Unable to write download to drive (return %1). Check the drive's available storage. @@ -1514,12 +1524,12 @@ Canceling download "%2"... DownloadsTab - + Query Metadata - + Cannot query metadata while offline mode is enabled. Do you want to disable offline mode? @@ -2393,12 +2403,12 @@ This is likely due to a corrupted or incompatible download or unrecognized archi - 7z.so not found + 7z.dll not found - 7z.so isn't valid + 7z.dll isn't valid @@ -3896,120 +3906,120 @@ You will have to visit the mod page on the %1 Nexus site to change your mind. - + Thank you! - + Thank you for your endorsement! - + Mod ID %1 no longer seems to be available on Nexus. - + Error %1: Request to Nexus failed: %2 - - + + failed to read %1: %2 - + Error - + failed to extract %1 (errorcode %2) - + Extract BSA - + This archive contains invalid hashes. Some files may be broken. - + Extract... - + Remove '%1' from the toolbar - + Backup of load order created - + Choose backup to restore - + This file might be left over following a crash or power loss event. Check its contents before restoring. - + No Backups - + There are no backups to restore - - + + Restore failed - - + + Failed to restore the backup. Errorcode: %1 - + Backup of mod list created - + A file with the same name has already been downloaded. What would you like to do? - + Overwrite - + Rename new file - + Ignore file @@ -4478,12 +4488,12 @@ p, li { white-space: pre-wrap; } ModInfoRegular - + %1 contains no esp/esm/esl and no asset (textures, meshes, interface, ...) directory - + Categories: <br> @@ -4584,178 +4594,198 @@ p, li { white-space: pre-wrap; } - + invalid - + installed version: "%1", newest version: "%2" - + 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". - + This file has been marked as "Old". There is most likely an updated version of this file available. - + This file has been marked as "Deleted"! You may want to check for an update or remove the nexus ID from this mod! - + %1 minute(s) and %2 second(s) - + This mod will be available to check in %2. - + Categories: <br> - + Invalid name - + Name is already in use by another mod - + Confirm - + Are you sure you want to remove "%1"? - + Conflicts - + Flags - + Content - + Mod Name - + Version - + Priority - + Category - + + Author + + + + + Uploader + + + + Source Game - + Nexus ID - + Installation - + Notes - - + + unknown - + Name of your mods - + Version of the mod (if available) - + Installation priority of your mod. The higher, the more "important" it is and thus overwrites files from mods with lower priority. - + Primary category of the mod. - + + Author(s) of the mod. + + + + + Uploader of the mod. This is not necessarily the same as the author. + + + + The source game which was the origin of this mod. - + Id of the mod as used on Nexus. - + Indicators of file conflicts between mods. - + Emblems to highlight things that might require attention. - + Depicts the content of the mod: - + Time this mod was installed - + User notes about the mod @@ -4852,8 +4882,8 @@ p, li { white-space: pre-wrap; } - - + + Open in Explorer @@ -4869,13 +4899,13 @@ p, li { white-space: pre-wrap; } - + Select Color... - + Reset Color @@ -4891,121 +4921,127 @@ p, li { white-space: pre-wrap; } - + Ignore missing data - + Mark as converted/working - + Visit on Nexus - - + + + Visit the uploader's profile + + + + + Visit on %1 - + Change versioning scheme - + Force-check updates - + Un-ignore update - + Ignore update - + Enable selected - + Disable selected - + Rename Mod... - + Reinstall Mod - + Remove Mod... - + Create Backup - + Restore hidden files - + Un-Endorse - - + + Endorse - + Won't endorse - + Endorsement state unknown - + Remap Category (From Nexus) - + Start tracking - + Stop tracking - + Tracked state unknown @@ -5124,7 +5160,7 @@ p, li { white-space: pre-wrap; } ModListSortProxy - + Drag&Drop is only supported when sorting by priority @@ -5153,17 +5189,17 @@ Please enter the name: - + Exception: - + Unknown exception - + <Multiple> @@ -5182,7 +5218,7 @@ Please enter the name: - + Create Mod... @@ -5194,7 +5230,7 @@ Please enter a name: - + A mod with this name already exists @@ -5226,8 +5262,8 @@ Please enter a name: - - + + Confirm @@ -5239,8 +5275,8 @@ Please enter a name: - - + + Are you sure? @@ -5317,178 +5353,197 @@ You can also use online editors and converters instead. - Nexus_ID + Mod_Author - Mod_Nexus_URL + Mod_Uploader - Mod_Version + Nexus_ID - Install_Date + Mod_Nexus_URL + Mod_Uploader_URL + + + + + Mod_Version + + + + + Install_Date + + + + Download_File_Name - + export failed: %1 - + Failed to display overwrite dialog: %1 - + Set Priority - + Set the priority of the selected mods - + failed to rename mod: %1 - + Remove the following mods?<br><ul>%1</ul> - + failed to remove mod: %1 - + Continue? - + The versioning scheme decides which version is considered newer than another. This function will guess the versioning scheme under the assumption that the installed version is outdated. - + Sorry - + I don't know a versioning scheme where %1 is newer than %2. - - Opening Nexus Links + + Nexus Links - - You are trying to open %1 links to Nexus Mods. Are you sure you want to do this? + + + Web Pages - - - Opening Web Pages + + Uploader Profiles - - - You are trying to open %1 Web Pages. Are you sure you want to do this? + + Opening %1 - - - + + You are trying to open %1 %2. Are you sure you want to do this? + + + + + + Failed - + Installation file no longer exists - + Mods installed with old versions of MO can't be reinstalled in this way. - + Failed to create backup. - + Restore all hidden files in the following mods?<br><ul>%1</ul> - + About to restore all hidden files in: - + Endorsing multiple mods will take a while. Please wait... - + Overwrite? - + This will replace the existing mod "%1". Continue? - + failed to remove mod "%1" - + failed to rename "%1" to "%2" - + Move successful. - + This will move all files from overwrite into a new, regular mod. Please enter a name: - + About to recursively delete: @@ -5510,122 +5565,80 @@ Please enter a name: NexusConnectionUI - + Connected. - + Not connected. - + Disconnected. - - Checking API key... + + Authorizing with Nexus... - - Received API key. + + Received authorization from Nexus. - + Received user account information - + + Linked with Nexus successfully. - - Failed to set API key + + Failed to store OAuth tokens. NexusInterface - + Please pick the mod ID for "%1" - + You must authorize MO2 in Settings -> Nexus to use the Nexus API. - + 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. - + 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. - + empty response - + invalid response - - NexusManualKeyDialog - - - Manual Nexus API Key - - - - - 1. Get your personal API key - - - - - Open Browser - - - - - 2. Enter your personal API key - - - - - Paste - - - - - Clear - - - - - Enter API key here - - - - - <b>Sharing this key with anyone could get your Nexus account banned. You can always revoke this key from your account on the Nexus website.</b> - - - NexusTab @@ -5996,48 +6009,93 @@ Continue? PluginContainer - + + Plugin + + + + + Diagnose + + + + + Game + + + + + Installer + + + + + Mod Page + + + + + Preview + + + + + Tool + + + + + Proxy + + + + + File Mapper + + + + Plugin error - + Mod Organizer failed to load the plugin '%1' last time it was started. - + 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. - + Skip this plugin - + Blacklist this plugin - + Load this plugin - + Some plugins could not be loaded - - + + Description missing - + The following plugins could not be loaded. The reason may be missing dependencies (i.e. python) or an outdated version: @@ -6397,49 +6455,6 @@ Continue? - - PluginTypeName - - - Diagnose - - - - - Game - - - - - Installer - - - - - Mod Page - - - - - Preview - - - - - Tool - - - - - Proxy - - - - - File Mapper - - - PreviewDialog @@ -6998,7 +7013,7 @@ p, li { white-space: pre-wrap; } - + Instance type: %1 @@ -7008,193 +7023,192 @@ p, li { white-space: pre-wrap; } - + Find game installation for %1 - + Find game installation - - + + Unrecognized game - + The folder %1 does not seem to contain a game Mod Organizer can manage. - + See details for the list of supported games. - + No installation found - + Browse... - + The folder must contain a valid game installation - + Microsoft Store game - + 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. - - - + + + Use this folder for %1 - + Use this folder - - - + + + I know what I'm doing - - - - - + + + + - - - - - - - + + + + + + + Cancel - + The folder %1 does not seem to contain an installation for <span style="white-space: nowrap; font-weight: bold;">%2</span> or for any other game Mod Organizer can manage. - + Incorrect game - + The folder %1 seems to contain an installation for <span style="white-space: nowrap; font-weight: bold;">%2</span>, not <span style="white-space: nowrap; font-weight: bold;">%3</span>. - + Manage %1 instead - + Instance location: %1 - + Instance name: %1 - + Profile settings: - + Local INIs: %1 - - - + + + yes - - - + + + no - + Local Saves: %1 - + Automatic Archive Invalidation: %1 - - + + Base directory: %1 - + Downloads - + Mods - + Profiles - + Overwrite - + Game: %1 - + Game location: %1 @@ -7300,7 +7314,7 @@ Destination: - invalid 7z.so: %1 + invalid 7-zip32.dll: %1 @@ -7438,12 +7452,12 @@ Destination: - + Mod Organizer - + An instance of Mod Organizer is already running @@ -7515,94 +7529,114 @@ Destination: - + Connecting to Nexus... - + Waiting for Nexus... - + Opened Nexus in browser. - + Switch to your browser and accept the request. - + Finished. - - No answer from Nexus. + + An unknown error has occurred. + + + + + No OAuth client id configured. + + + + + <html><body><h2>Mod Organizer</h2><p>Authorization complete. You may close this window.</p></body></html> - - - A firewall might be blocking Mod Organizer. + + Failed to bind to localhost on port %1. - - Nexus closed the connection. + + Authorization failed (%1) - + + Internal error: OAuth flow is missing. + + + + + Invalid OAuth token payload. + + + + Cancelled. - + Failed to request %1 - - + + Cancelled - + Internal error - + HTTP code %1 - + Invalid JSON - + Bad response - - API key is empty + + + Access token is empty - + SSL error - + Timed out @@ -7675,19 +7709,19 @@ This program is known to cause issues with Mod Organizer, such as freezing or bl - - - + + + attempt to store setting for unknown plugin "%1" - + Failed - + Failed to start the helper application: %1 @@ -7734,40 +7768,33 @@ This program is known to cause issues with Mod Organizer, such as freezing or bl - - - - Enter API Key Manually - - - - - - + + + Connect to Nexus - - - - - + + + + + N/A - + Executables (*.exe) - + All Files (*.*) - + Select the browser executable @@ -7924,196 +7951,196 @@ Example: - + This error typically happens because an antivirus has deleted critical files from Mod Organizer's installation folder or has made them generally inaccessible. Add an exclusion for Mod Organizer's installation folder in your antivirus, reinstall Mod Organizer and try again. - + This error typically happens because an antivirus is preventing Mod Organizer from starting programs. Add an exclusion for Mod Organizer's installation folder in your antivirus and try again. - + The file '%1' does not exist. - + The working directory '%1' does not exist. - - - - + + + + Cannot start Steam - + The path to the Steam executable cannot be found. You might try reinstalling Steam. - - - + + + Continue without starting Steam - - + + The program may fail to launch. - + Cannot launch program - - - + + + Cannot start %1 - + Cannot launch helper - - + + Elevation required - + This program is requesting to run as administrator but Mod Organizer itself is not running as administrator. Running programs as administrator is typically unnecessary as long as the game and Mod Organizer have been installed outside "Program Files". You can restart Mod Organizer as administrator and try launching the program again. - - + + Restart Mod Organizer as administrator - - + + You must allow "helper.exe" to make changes to the system. - + Launch Steam - + This program requires Steam - + Mod Organizer has detected that this program likely requires Steam to be running to function properly. - + Start Steam - - + + The program might fail to run. - + Steam is running as administrator - + Running Steam as administrator is typically unnecessary and can cause problems when Mod Organizer itself is not running as administrator. You can restart Mod Organizer as administrator and try launching the program again. - - - + + + Continue - + Event Log not running - + The Event Log service is not running - + The Windows Event Log service is not running. This can prevent USVFS from running properly and your mods may not be recognized by the program being launched. - - + + Your mods might not work. - + Blacklisted program - + The program %1 is blacklisted - + The program you are attempting to launch is blacklisted in the virtual filesystem. This will likely prevent it from seeing any mods, INI files or any other virtualized files. - + Change the blacklist - + Waiting - + Please press OK once you're logged into steam. - + Select binary - + Binary @@ -8730,7 +8757,7 @@ If you disable this feature, MO will only display official DLCs this way. Please - + ... @@ -8847,205 +8874,195 @@ If you disable this feature, MO will only display official DLCs this way. Please - Manually enter the API key and try to login + Clear the stored Nexus authorization and force reauthorization. - Enter API Key Manually - - - - - Clear the stored Nexus API key and force reauthorization. - - - - Disconnect from Nexus - - + + Options - + Endorsement Integration - + Tracked Integration - + Use Nexus category mappings - - + + <html><head/><body><p>By default, a counter is displayed in the bottom right corner. This informs the user of their remaining API requests. The Nexus API becomes unusable once these API requests run out. Checking this option will hide that counter.</p></body></html> - + Hide API Request Counter - + Associate with "Download with manager" links - + Remove cache and cookies. - + Clear Cache - + Servers - + Known Servers (updated on download) - + Preferred Servers (Drag & Drop) - + Plugins - + Author: - + Version: - + Description: - + Enabled - + Key - + Value - + No plugin found. - + Blacklisted Plugins (use <del> to remove): - + Workarounds - + If checked, files (i.e. esps, esms and bsas) belonging to the core game can not be disabled in the UI. (default: on) - + If checked, files (i.e. esps, esms and bsas) belonging to the core game can not be disabled in the UI. (default: on) Uncheck this if you want to use Mod Organizer with total conversions (like Nehrim) but be aware that the game will crash if required files are not enabled. - + Force-enable game files - + Enable parsing of Archives. This is an Experimental Feature. Has negative effects on performance and known incorrectness. - + <html><head/><body><p>By default, MO will parse archive files (BSA, BA2) to calculate conflicts between the contents of the archive files and other loose files. This process has a noticeable cost in performance.</p><p>This feature should not be confused with the archive management feature offered by MO1. MO2 will only show conflicts with archives and will NOT load them into the game or program.</p><p>If you disable this feature, MO will only display conflicts between loose files.</p></body></html> - + Enable archives parsing (experimental) - - + + Disable this to prevent the GUI from being locked when running an executable. This may result in abnormal behavior. - + Lock GUI when running executable - + Steam - + Password - + Username - + Steam App ID - + The Steam AppID for your game - + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> <html><head><meta name="qrichtext" content="1" /><style type="text/css"> p, li { white-space: pre-wrap; } @@ -9061,69 +9078,69 @@ p, li { white-space: pre-wrap; } - + Network - + Disable automatic internet features - + Disable automatic internet features. This does not affect features that are explicitly invoked by the user (like checking mods for updates, endorsing, opening the web browser) - + Offline Mode - + Use a proxy for network connections. - + Use a proxy for network connections. This uses the system-wide settings which can be configured in Internet Explorer. Please note that MO will start up a few seconds slower on some systems when using a proxy. - + Use System HTTP Proxy - - + + + - - - + + Use "%1" as a placeholder for the URL. - + Custom browser - - + + Resets the window geometries for all windows. This can be useful if a window becomes too small or too large, if a column becomes too thin or too wide, and in similar situations. - + Reset Window Geometries - - + + For Skyrim, this can be used instead of Archive Invalidation. It should make AI redundant for all Profiles. For the other games this is not a sufficient replacement for AI! @@ -9131,12 +9148,12 @@ p, li { white-space: pre-wrap; } - + Back-date BSAs - + Add executables to the blacklist to prevent them from accessing the virtual file system. This is useful to prevent unintended programs from being hooked. Hooking unintended @@ -9145,64 +9162,64 @@ programs you are intentionally running. - + Add executables to the blacklist to prevent them from accessing the virtual file system. This is useful to prevent unintended programs from being hooked. Hooking unintended programs may affect the execution of these programs or the programs you are intentionally running. - + Executables Blacklist - - + + Files to skip or ignore from the virtual file system. - + Skip File Suffixes - - + + Directories to skip or ignore from the virtual file system. - + Skip Directories - + These are workarounds for problems with Mod Organizer. Please make sure you read the help text before changing anything here. - + Diagnostics - + Logs and Crashes - + Log Level - + Decides the amount of data printed to "ModOrganizer.log" - + Decides the amount of data printed to "ModOrganizer.log". "Debug" produces very useful information for finding problems. There is usually no noteworthy performance impact but the file may become rather large. If this is a problem you may prefer the "Info" level for regular use. On the "Error" level the log file usually remains empty. @@ -9210,17 +9227,17 @@ programs you are intentionally running. - + Crash Dumps - + Decides which type of crash dumps are collected when injected processes crash. - + Decides which type of crash dumps are collected when injected processes crash. "None" Disables the generation of crash dumps by MO. @@ -9231,17 +9248,17 @@ programs you are intentionally running. - + Max Dumps To Keep - + Maximum number of crash dumps to keep on disk. Use 0 for unlimited. - + Maximum number of crash dumps to keep on disk. Use 0 for unlimited. Set "Crash Dumps" above to None to disable crash dump collection. @@ -9249,22 +9266,22 @@ programs you are intentionally running. - + Integrated LOOT - + LOOT Log Level - + Click a link to open the location - + Logs and crash dumps are stored under your current instance in the <a href="LOGS_FULL_PATH">LOGS_DIR</a> and <a href="DUMPS_FULL_PATH">DUMPS_DIR</a> folders. @@ -9325,14 +9342,6 @@ programs you are intentionally running. - - T - - - Plugin - - - TransferSavesDialog @@ -9479,7 +9488,7 @@ On Windows XP: - + Connecting to Nexus... @@ -9494,7 +9503,7 @@ On Windows XP: - + Trying again... @@ -9840,7 +9849,8 @@ Please open the "Nexus" tab. - Use this interface to obtain an API key from NexusMods. This is used for all API connections - downloads, updates etc. MO2 uses the Windows Credential Manager to store this data securely. If the SSO page on Nexus is failing, use the manual entry and copy the API key from your profile. + Use this interface to authorize Mod Organizer with Nexus Mods. This login is used for all API connections - downloads, updates etc. MO2 uses the Windows Credential Manager to store these credentials securely. + Use this interface to authorize Mod Organizer with Nexus Mods. This login is used for all Nexus API connections such as downloads and update checks. MO2 uses the Windows Credential Manager to store the resulting OAuth tokens securely. diff --git a/src/src/organizercore.cpp b/src/src/organizercore.cpp index d42b1ac..c469033 100644 --- a/src/src/organizercore.cpp +++ b/src/src/organizercore.cpp @@ -369,6 +369,14 @@ void OrganizerCore::updateModInfoFromDisc() m_Settings.refreshThreadCount()); } +void OrganizerCore::showNotification(const QString& title, const QString& message, + QSystemTrayIcon::MessageIcon icon) +{ + if (m_UserInterface) { + m_UserInterface->showNotification(title, message, icon); + } +} + void OrganizerCore::setUserInterface(IUserInterface* ui) { storeSettings(); diff --git a/src/src/organizercore.h b/src/src/organizercore.h index 7ef7713..494e835 100644 --- a/src/src/organizercore.h +++ b/src/src/organizercore.h @@ -8,6 +8,7 @@ #include #include #include +#include #include #include @@ -372,6 +373,10 @@ public: static void setGlobalCoreDumpType(env::CoreDumpTypes type); static std::wstring getGlobalCoreDumpPath(); + void showNotification( + const QString& title, const QString& message, + QSystemTrayIcon::MessageIcon icon = QSystemTrayIcon::MessageIcon::Information); + public: MOBase::IModRepositoryBridge* createNexusBridge() const; QString profileName() const; diff --git a/src/src/settings.cpp b/src/src/settings.cpp index c665756..6cea57f 100644 --- a/src/src/settings.cpp +++ b/src/src/settings.cpp @@ -335,64 +335,32 @@ void Settings::setFirstStart(bool 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; + // USVFS-era setting; FUSE VFS on Linux doesn't hook executables per-process. + return ""; } -void Settings::setExecutablesBlacklist(const QString& s) +bool Settings::isExecutableBlacklisted(const QString&) const { - set(m_Settings, "Settings", "executable_blacklist", s); + return false; } +void Settings::setExecutablesBlacklist(const QString&) {} + QStringList Settings::skipFileSuffixes() const { - static const QStringList def = QStringList() << ".mohidden"; - - auto setting = get(m_Settings, "Settings", "skip_file_suffixes", def); - - return setting; + // Keep the historical default so existing modlists with .mohidden files + // keep ignoring them; previously this was a user-editable Workarounds entry. + return {".mohidden"}; } -void Settings::setSkipFileSuffixes(const QStringList& s) -{ - set(m_Settings, "Settings", "skip_file_suffixes", s); -} +void Settings::setSkipFileSuffixes(const QStringList&) {} QStringList Settings::skipDirectories() const { - static const QStringList def = QStringList() << ".git"; - - auto setting = get(m_Settings, "Settings", "skip_directories", def); - - return setting; + return {".git"}; } -void Settings::setSkipDirectories(const QStringList& s) -{ - set(m_Settings, "Settings", "skip_directories", s); -} +void Settings::setSkipDirectories(const QStringList&) {} void Settings::setMotdHash(uint hash) { @@ -671,12 +639,15 @@ void GameSettings::setPlugin(const MOBase::IPluginGame* gamePlugin) bool GameSettings::forceEnableCoreFiles() const { - return get(m_Settings, "Settings", "force_enable_core_files", true); + // Hardcoded on. Disabling primary-master autoload caused user issue + // where DLCs went unchecked after a tab refresh; there's no use-case + // outside Nehrim-style total conversions that justifies the footgun. + return true; } -void GameSettings::setForceEnableCoreFiles(bool b) +void GameSettings::setForceEnableCoreFiles(bool) { - set(m_Settings, "Settings", "force_enable_core_files", b); + // no-op; see forceEnableCoreFiles(). } std::optional GameSettings::directory() const @@ -1801,32 +1772,23 @@ NetworkSettings::NetworkSettings(QSettings& settings, bool globalInstance) void NetworkSettings::updateCustomBrowser() const { - if (useCustomBrowser()) { - MOBase::shell::SetUrlHandler(customBrowserCommand()); - } else { - MOBase::shell::SetUrlHandler(""); - } + // Removed alongside the Workarounds tab; system mimetypes drive URL handling. + MOBase::shell::SetUrlHandler(""); } bool NetworkSettings::offlineMode() const { - return get(m_Settings, "Settings", "offline_mode", false); + return false; } -void NetworkSettings::setOfflineMode(bool b) -{ - set(m_Settings, "Settings", "offline_mode", b); -} +void NetworkSettings::setOfflineMode(bool) {} bool NetworkSettings::useProxy() const { - return get(m_Settings, "Settings", "use_proxy", false); + return false; } -void NetworkSettings::setUseProxy(bool b) -{ - set(m_Settings, "Settings", "use_proxy", b); -} +void NetworkSettings::setUseProxy(bool) {} void NetworkSettings::setDownloadSpeed(const QString& name, int bytesPerSecond) { @@ -1935,25 +1897,17 @@ void NetworkSettings::updateFromOldMap() bool NetworkSettings::useCustomBrowser() const { - return get(m_Settings, "Settings", "use_custom_browser", false); + return false; } -void NetworkSettings::setUseCustomBrowser(bool b) -{ - set(m_Settings, "Settings", "use_custom_browser", b); - updateCustomBrowser(); -} +void NetworkSettings::setUseCustomBrowser(bool) {} QString NetworkSettings::customBrowserCommand() const { - return get(m_Settings, "Settings", "custom_browser", ""); + return ""; } -void NetworkSettings::setCustomBrowserCommand(const QString& s) -{ - set(m_Settings, "Settings", "custom_browser", s); - updateCustomBrowser(); -} +void NetworkSettings::setCustomBrowserCommand(const QString&) {} ServerList NetworkSettings::serversFromOldMap() const { @@ -2133,41 +2087,22 @@ SteamSettings::SteamSettings(Settings& parent, QSettings& settings) QString SteamSettings::appID() const { - return get(m_Settings, "Settings", "app_id", - m_Parent.game().plugin()->steamAPPId()); + // The user-override `app_id` ini key is no longer exposed; per-game plugins + // already carry the right value via steamAPPId(), and per-executable overrides + // live on individual Executable entries. + return 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); - } -} +void SteamSettings::setAppID(const QString&) {} bool SteamSettings::login(QString& username, QString& password) const { - username = get(m_Settings, "Settings", "steam_username", ""); - password = getWindowsCredential("steam_password"); - - return !username.isEmpty() && !password.isEmpty(); + username.clear(); + password.clear(); + return false; } -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)); - } -} +void SteamSettings::setLogin(QString, QString) {} InterfaceSettings::InterfaceSettings(QSettings& settings) : m_Settings(settings) {} @@ -2298,6 +2233,16 @@ void InterfaceSettings::setHideDownloadsAfterInstallation(bool b) set(m_Settings, "Settings", "autohide_downloads", b); } +bool InterfaceSettings::showDownloadNotifications() const +{ + return get(m_Settings, "Settings", "download_notifications", false); +} + +void InterfaceSettings::setShowDownloadNotifications(bool b) +{ + set(m_Settings, "Settings", "download_notifications", b); +} + bool InterfaceSettings::hideAPICounter() const { return get(m_Settings, "Settings", "hide_api_counter", false); diff --git a/src/src/settings.h b/src/src/settings.h index 6726b2f..92f71fa 100644 --- a/src/src/settings.h +++ b/src/src/settings.h @@ -647,6 +647,11 @@ public: bool hideDownloadsAfterInstallation() const; void setHideDownloadsAfterInstallation(bool b); + // whether to show notifications when downloads complete or fail + // + bool showDownloadNotifications() const; + void setShowDownloadNotifications(bool b); + // whether the API counter should be hidden // bool hideAPICounter() const; diff --git a/src/src/settingsdialog.cpp b/src/src/settingsdialog.cpp index e07ac71..6b0fca3 100644 --- a/src/src/settingsdialog.cpp +++ b/src/src/settingsdialog.cpp @@ -27,7 +27,6 @@ along with Mod Organizer. If not, see . #include "settingsdialogproton.h" #include "settingsdialogtheme.h" #include "settingsdialogupdates.h" -#include "settingsdialogworkarounds.h" #include "ui_settingsdialog.h" using namespace MOBase; @@ -54,8 +53,6 @@ SettingsDialog::SettingsDialog(PluginContainer* pluginContainer, Settings& setti new PluginsSettingsTab(settings, m_pluginContainer, *this))); m_tabs.push_back( std::unique_ptr(new ProtonSettingsTab(settings, *this))); - m_tabs.push_back( - std::unique_ptr(new WorkaroundsSettingsTab(settings, *this))); } PluginContainer* SettingsDialog::pluginContainer() diff --git a/src/src/settingsdialog.ui b/src/src/settingsdialog.ui index 04dcd3e..a7e2a90 100644 --- a/src/src/settingsdialog.ui +++ b/src/src/settingsdialog.ui @@ -166,6 +166,35 @@ + + + + Show notifications when downloads complete or fail. + + + Show notifications when downloads complete or fail. + + + Show notifications for completed or failed downloads + + + + + + + Enable parsing of Archives. This is an Experimental Feature. Has negative effects on performance and known incorrectness. + + + <html><head/><body><p>By default, MO will parse archive files (BSA, BA2) to calculate conflicts between the contents of the archive files and other loose files. This process has a noticeable cost in performance.</p><p>This feature should not be confused with the archive management feature offered by MO1. MO2 will only show conflicts with archives and will NOT load them into the game or program.</p><p>If you disable this feature, MO will only display conflicts between loose files.</p></body></html> + + + Enable archives parsing (experimental) + + + true + + + @@ -1897,440 +1926,6 @@ If you disable this feature, MO will only display official DLCs this way. Please - - - Workarounds - - - - - - #workaroundScrollArea { background-color: transparent; } -#workaroundScrollAreaWidgetContents { background-color: transparent; } - - - QFrame::NoFrame - - - QFrame::Plain - - - true - - - - - 0 - 0 - 778 - 475 - - - - false - - - - 0 - - - 0 - - - 0 - - - 0 - - - - - Options - - - - 9 - - - 9 - - - 9 - - - 9 - - - - - If checked, files (i.e. esps, esms and bsas) belonging to the core game can not be disabled in the UI. (default: on) - - - If checked, files (i.e. esps, esms and bsas) belonging to the core game can not be disabled in the UI. (default: on) -Uncheck this if you want to use Mod Organizer with total conversions (like Nehrim) but be aware that the game will crash if required files are not enabled. - - - Force-enable game files - - - true - - - - - - - Enable parsing of Archives. This is an Experimental Feature. Has negative effects on performance and known incorrectness. - - - <html><head/><body><p>By default, MO will parse archive files (BSA, BA2) to calculate conflicts between the contents of the archive files and other loose files. This process has a noticeable cost in performance.</p><p>This feature should not be confused with the archive management feature offered by MO1. MO2 will only show conflicts with archives and will NOT load them into the game or program.</p><p>If you disable this feature, MO will only display conflicts between loose files.</p></body></html> - - - Enable archives parsing (experimental) - - - true - - - - - - - - - - Steam - - - - - - - - - Password - - - - - - - QLineEdit::Password - - - - - - - Username - - - - - - - Steam App ID - - - - - - - The Steam AppID for your game - - - <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> -<html><head><meta name="qrichtext" content="1" /><style type="text/css"> -p, li { white-space: pre-wrap; } -</style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt; font-weight:400; font-style:normal;"> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">The Steam App ID is required to directly start some games. For Skyrim, if this is not set or wrong, the &quot;Mod Organizer&quot; load mechanism may not work properly.</span></p> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">The preset for this is the App ID of the &quot;regular&quot; version so in most cases, you should be set.</span></p> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">If you think you have a different version (GotY or something), follow these steps to get to the id:</span></p> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">1. Navigate to the game library in steam</span></p> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">2. right-click on the game you need the id for and choose </span><span style=" font-size:8pt; font-weight:600;">Create desktop shortcut</span></p> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">3. right-click on the newly created shortcut on your desktop and select </span><span style=" font-size:8pt; font-weight:600;">Properties</span></p> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">4. in the URL-field you should see something like this: </span><span style=" font-size:8pt; font-style:italic;">steam://rungameid/22380</span></p> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">22380 is the id you're looking for.</span></p></body></html> - - - - - - - - - - - 0 - 100 - - - - Network - - - - - - Disable automatic internet features - - - Disable automatic internet features. This does not affect features that are explicitly invoked by the user (like checking mods for updates, endorsing, opening the web browser) - - - Offline Mode - - - - - - - Use a proxy for network connections. - - - Use a proxy for network connections. This uses the system-wide settings which can be configured in Internet Explorer. Please note that MO will start up a few seconds slower on some systems when using a proxy. - - - Use System HTTP Proxy - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - - - Use "%1" as a placeholder for the URL. - - - Use "%1" as a placeholder for the URL. - - - Use "%1" as a placeholder for the URL. - - - Custom browser - - - - - - - Use "%1" as a placeholder for the URL. - - - Use "%1" as a placeholder for the URL. - - - Use "%1" as a placeholder for the URL. - - - - - - - ... - - - - - - - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - - - Resets the window geometries for all windows. This can be useful if a window becomes too small or too large, if a column becomes too thin or too wide, and in similar situations. - - - Resets the window geometries for all windows. This can be useful if a window becomes too small or too large, if a column becomes too thin or too wide, and in similar situations. - - - Reset Window Geometries - - - - - - - - For Skyrim, this can be used instead of Archive Invalidation. It should make AI redundant for all Profiles. - For the other games this is not a sufficient replacement for AI! - - - - - For Skyrim, this can be used instead of Archive Invalidation. It should make AI redundant for all Profiles. - For the other games this is not a sufficient replacement for AI! - - - - Back-date BSAs - - - - :/MO/gui/resources/emblem-readonly.png:/MO/gui/resources/emblem-readonly.png - - - - - - - Qt::Orientation::Horizontal - - - - 40 - 20 - - - - - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - - - Add executables to the blacklist to prevent them from -accessing the virtual file system. This is useful to prevent -unintended programs from being hooked. Hooking unintended -programs may affect the execution of these programs or the -programs you are intentionally running. - - - Add executables to the blacklist to prevent them from accessing the virtual file system. This is useful to prevent unintended programs from being hooked. Hooking unintended programs may affect the execution of these programs or the programs you are intentionally running. - - - Executables Blacklist - - - false - - - - - - - Files to skip or ignore from the virtual file system. - - - Files to skip or ignore from the virtual file system. - - - Skip File Suffixes - - - false - - - - - - - Directories to skip or ignore from the virtual file system. - - - Directories to skip or ignore from the virtual file system. - - - Skip Directories - - - false - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - - - - These are workarounds for problems with Mod Organizer. Please make sure you read the help text before changing anything here. - - - true - - - - - Diagnostics diff --git a/src/src/settingsdialoggeneral.cpp b/src/src/settingsdialoggeneral.cpp index 81af13c..c40f667 100644 --- a/src/src/settingsdialoggeneral.cpp +++ b/src/src/settingsdialoggeneral.cpp @@ -20,6 +20,9 @@ GeneralSettingsTab::GeneralSettingsTab(Settings& s, SettingsDialog& d) ui->showMetaBox->setChecked(settings().interface().metaDownloads()); ui->hideDownloadInstallBox->setChecked( settings().interface().hideDownloadsAfterInstallation()); + ui->showDownloadNotificationsBox->setChecked( + settings().interface().showDownloadNotifications()); + ui->enableArchiveParsingBox->setChecked(settings().archiveParsing()); // Update settings have moved to their own "Updates" tab. Hide the // legacy controls here so the two UIs don't contradict each other. @@ -65,6 +68,9 @@ void GeneralSettingsTab::update() settings().interface().setMetaDownloads(ui->showMetaBox->isChecked()); settings().interface().setHideDownloadsAfterInstallation( ui->hideDownloadInstallBox->isChecked()); + settings().interface().setShowDownloadNotifications( + ui->showDownloadNotificationsBox->isChecked()); + settings().setArchiveParsing(ui->enableArchiveParsingBox->isChecked()); // Update settings are persisted by UpdatesSettingsTab (own tab). diff --git a/src/src/settingsdialognexus.cpp b/src/src/settingsdialognexus.cpp index fac2a41..d4008be 100644 --- a/src/src/settingsdialognexus.cpp +++ b/src/src/settingsdialognexus.cpp @@ -393,15 +393,7 @@ NexusSettingsTab::NexusSettingsTab(Settings& s, SettingsDialog& d) : SettingsTab QObject::connect(ui->associateButton, &QPushButton::clicked, [&] { associate(); }); - QObject::connect(ui->useCustomBrowser, &QCheckBox::clicked, [&] { - updateCustomBrowser(); - }); - QObject::connect(ui->browseCustomBrowser, &QPushButton::clicked, [&] { - browseCustomBrowser(); - }); - updateNexusData(); - updateCustomBrowser(); } void NexusSettingsTab::update() @@ -495,23 +487,3 @@ void NexusSettingsTab::updateNexusData() } } -void NexusSettingsTab::updateCustomBrowser() -{ - ui->browserCommand->setEnabled(ui->useCustomBrowser->isChecked()); -} - -void NexusSettingsTab::browseCustomBrowser() -{ - const QString Filters = - QObject::tr("Executables (*.exe)") + ";;" + QObject::tr("All Files (*.*)"); - - QString file = QFileDialog::getOpenFileName( - parentWidget(), QObject::tr("Select the browser executable"), - ui->browserCommand->text(), Filters); - - if (file.isNull() || file == "") { - return; - } - - ui->browserCommand->setText(file + " \"%1\""); -} diff --git a/src/src/settingsdialognexus.h b/src/src/settingsdialognexus.h index 8b7b56c..e43a318 100644 --- a/src/src/settingsdialognexus.h +++ b/src/src/settingsdialognexus.h @@ -71,8 +71,6 @@ private: void associate(); void updateNexusData(); - void updateCustomBrowser(); - void browseCustomBrowser(); }; #endif // SETTINGSDIALOGNEXUS_H diff --git a/src/src/settingsdialogworkarounds.cpp b/src/src/settingsdialogworkarounds.cpp deleted file mode 100644 index 2b252b3..0000000 --- a/src/src/settingsdialogworkarounds.cpp +++ /dev/null @@ -1,238 +0,0 @@ -#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->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().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/settingsdialogworkarounds.h b/src/src/settingsdialogworkarounds.h deleted file mode 100644 index 03561b4..0000000 --- a/src/src/settingsdialogworkarounds.h +++ /dev/null @@ -1,49 +0,0 @@ -#ifndef SETTINGSDIALOGWORKAROUNDS_H -#define SETTINGSDIALOGWORKAROUNDS_H - -#include "settings.h" -#include "settingsdialog.h" - -class WorkaroundsSettingsTab : public SettingsTab -{ -public: - WorkaroundsSettingsTab(Settings& settings, SettingsDialog& dialog); - - // shows the blacklist dialog from the given settings, and changes the - // settings when the user accepts it - // - static bool changeBlacklistNow(QWidget* parent, Settings& settings); - - // shows the blacklist dialog from the given string and returns the new - // blacklist if the user accepted it - // - static std::optional changeBlacklistLater(QWidget* parent, - const QString& current); - - // shows the blacklist dialog from the given string and returns the new - // blacklist if the user accepted it - // - static std::optional changeSkipFileSuffixes(QWidget* parent, - const QStringList& current); - - // shows the blacklist dialog from the given string and returns the new - // blacklist if the user accepted it - // - static std::optional changeSkipDirectories(QWidget* parent, - const QStringList& current); - - void update() override; - -private: - QString m_ExecutableBlacklist; - QStringList m_SkipFileSuffixes; - QStringList m_SkipDirectories; - - static void on_bsaDateBtn_clicked(); - void on_execBlacklistBtn_clicked(); - void on_skipFileSuffixBtn_clicked(); - void on_skipDirectoriesBtn_clicked(); - void on_resetGeometryBtn_clicked(); -}; - -#endif // SETTINGSDIALOGWORKAROUNDS_H diff --git a/src/src/spawn.cpp b/src/src/spawn.cpp index 76d4e0e..c6f4691 100644 --- a/src/src/spawn.cpp +++ b/src/src/spawn.cpp @@ -24,7 +24,6 @@ along with Mod Organizer. If not, see . #include "fluorineconfig.h" #include "protonlauncher.h" #include "settings.h" -#include "settingsdialogworkarounds.h" #include "shared/appconfig.h" #include @@ -53,7 +52,6 @@ along with Mod Organizer. If not, see . class QMessageBox; using namespace MOBase; -using namespace MOShared; namespace spawn::dialogs { @@ -153,25 +151,16 @@ QMessageBox::StandardButton confirmBlacklisted(QWidget* parent, const auto details = "Executable: " + sp.binary.fileName() + "\nCurrent blacklist: " + settings.executablesBlacklist(); - auto r = MOBase::TaskDialog(parent, title) - .main(mainText) - .content(content) - .details(details) - .icon(QMessageBox::Question) - .remember("blacklistedExecutable", sp.binary.fileName()) - .button({QObject::tr("Continue"), - QObject::tr("Your mods might not work."), QMessageBox::Yes}) - .button({QObject::tr("Change the blacklist"), QMessageBox::Retry}) - .button({QObject::tr("Cancel"), QMessageBox::Cancel}) - .exec(); - - if (r == QMessageBox::Retry) { - if (!WorkaroundsSettingsTab::changeBlacklistNow(parent, settings)) { - r = QMessageBox::Cancel; - } - } - - return r; + return MOBase::TaskDialog(parent, title) + .main(mainText) + .content(content) + .details(details) + .icon(QMessageBox::Question) + .remember("blacklistedExecutable", sp.binary.fileName()) + .button({QObject::tr("Continue"), + QObject::tr("Your mods might not work."), QMessageBox::Yes}) + .button({QObject::tr("Cancel"), QMessageBox::Cancel}) + .exec(); } } // namespace spawn::dialogs diff --git a/src/src/systemtraymanager.cpp b/src/src/systemtraymanager.cpp index ad96c7d..498d29c 100644 --- a/src/src/systemtraymanager.cpp +++ b/src/src/systemtraymanager.cpp @@ -67,6 +67,21 @@ void SystemTrayManager::restoreFromSystemTray() } } +void SystemTrayManager::showNotification(const QString& title, const QString& message, + QSystemTrayIcon::MessageIcon icon) +{ + // The tray icon is normally only visible while minimized; show it just long + // enough for the notification to fire so the OS can route the message. + const bool wasVisible = m_SystemTrayIcon->isVisible(); + if (!wasVisible) { + m_SystemTrayIcon->show(); + } + m_SystemTrayIcon->showMessage(title, message, icon); + if (!wasVisible && !m_Parent->isHidden()) { + m_SystemTrayIcon->hide(); + } +} + void SystemTrayManager::on_systemTrayIcon_activated( QSystemTrayIcon::ActivationReason reason) { diff --git a/src/src/systemtraymanager.h b/src/src/systemtraymanager.h index f46cd7a..5530a2a 100644 --- a/src/src/systemtraymanager.h +++ b/src/src/systemtraymanager.h @@ -35,6 +35,10 @@ public: void minimizeToSystemTray(); void restoreFromSystemTray(); + void showNotification( + const QString& title, const QString& message, + QSystemTrayIcon::MessageIcon icon = QSystemTrayIcon::MessageIcon::Information); + private: QMainWindow* m_Parent; QDockWidget* m_LogDock; -- cgit v1.3.1