diff options
| author | Jeremy Rimpo <jeremy.rimpo@servermonkey.com> | 2026-05-12 13:50:45 -0500 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-05-12 13:50:45 -0500 |
| commit | 3efaf7c9463261fb2d182a7ae00d9045a2b273b8 (patch) | |
| tree | 8cc2c9f14d8cffe73187cf42ce8141c87bccef32 /src | |
| parent | 41ffb25c03956d1c6619c284e821611e70bcc307 (diff) | |
Migrating to OAuth Authentication (#2374)
Co-authored-by: aglowinthefield <146008217+aglowinthefield@users.noreply.github.com>
Co-authored-by: Jonathan Feenstra <26406078+JonathanFeenstra@users.noreply.github.com>
Diffstat (limited to 'src')
31 files changed, 1915 insertions, 1386 deletions
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 8b425272..698ad03d 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -12,7 +12,7 @@ find_package(mo2-esptk CONFIG REQUIRED) find_package(mo2-dds-header CONFIG REQUIRED) find_package(mo2-libbsarch CONFIG REQUIRED) -find_package(Qt6 REQUIRED COMPONENTS WebEngineWidgets WebSockets) +find_package(Qt6 REQUIRED COMPONENTS WebEngineWidgets WebSockets NetworkAuth) find_package(Boost CONFIG REQUIRED COMPONENTS program_options thread interprocess signals2 uuid accumulators) find_package(7zip CONFIG REQUIRED) find_package(lz4 CONFIG REQUIRED) @@ -41,7 +41,7 @@ target_link_libraries(organizer PRIVATE usvfs::usvfs mo2::uibase mo2::archive mo2::libbsarch mo2::bsatk mo2::esptk mo2::lootcli-header Boost::program_options Boost::signals2 Boost::uuid Boost::accumulators - Qt6::WebEngineWidgets Qt6::WebSockets Version Dbghelp) + Qt6::WebEngineWidgets Qt6::WebSockets Qt6::NetworkAuth Version Dbghelp) install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/dlls.manifest.qt6" DESTINATION ${_bin}/dlls @@ -130,6 +130,9 @@ mo2_add_filter(NAME src/core GROUPS githubpp installationmanager nexusinterface + nexusoauthlogin + nexusoauthtokens + nexusoauthconfig nxmaccessmanager organizercore game_features diff --git a/src/apiuseraccount.cpp b/src/apiuseraccount.cpp index 4b908df0..e9785b40 100644 --- a/src/apiuseraccount.cpp +++ b/src/apiuseraccount.cpp @@ -19,12 +19,17 @@ APIUserAccount::APIUserAccount() : m_type(APIUserAccountTypes::None) {} bool APIUserAccount::isValid() const { - return !m_key.isEmpty(); + return !m_accessToken.isEmpty() || !m_apiKey.isEmpty(); +} + +const QString& APIUserAccount::accessToken() const +{ + return m_accessToken; } const QString& APIUserAccount::apiKey() const { - return m_key; + return m_apiKey; } const QString& APIUserAccount::id() const @@ -47,9 +52,15 @@ const APILimits& APIUserAccount::limits() const return m_limits; } -APIUserAccount& APIUserAccount::apiKey(const QString& key) +APIUserAccount& APIUserAccount::accessToken(const QString& token) +{ + m_accessToken = token; + return *this; +} + +APIUserAccount& APIUserAccount::apiKey(const QString& apiKey) { - m_key = key; + m_apiKey = apiKey; return *this; } diff --git a/src/apiuseraccount.h b/src/apiuseraccount.h index 829654a5..628337f8 100644 --- a/src/apiuseraccount.h +++ b/src/apiuseraccount.h @@ -65,7 +65,12 @@ public: bool isValid() const; /** - * api key + * OAuth access token + */ + const QString& accessToken() const; + + /** + * OAuth access token */ const QString& apiKey() const; @@ -90,9 +95,14 @@ public: const APILimits& limits() const; /** - * sets the api key + * sets the OAuth access token + */ + APIUserAccount& accessToken(const QString& token); + + /** + * sets the OAuth access token */ - APIUserAccount& apiKey(const QString& key); + APIUserAccount& apiKey(const QString& apiKey); /** * sets the user id @@ -132,7 +142,7 @@ public: bool exhausted() const; private: - QString m_key, m_id, m_name; + QString m_accessToken, m_apiKey, m_id, m_name; APIUserAccountTypes m_type; APILimits m_limits; }; diff --git a/src/createinstancedialog.h b/src/createinstancedialog.h index 4495cc78..270f347f 100644 --- a/src/createinstancedialog.h +++ b/src/createinstancedialog.h @@ -27,7 +27,7 @@ class Settings; // // pages can be disabled if they return true in skip(), which happens globally // for some (IntroPage has a setting in the registry), depending on context -// (NexusPage is skipped if the API key already exists) or explicitly (when +// (NexusPage is skipped if the Nexus authorization already exists) or explicitly (when // only some info about the instance is missing on startup, such as a game // variant) // diff --git a/src/createinstancedialogpages.cpp b/src/createinstancedialogpages.cpp index 66f4c31c..ac4103ea 100644 --- a/src/createinstancedialogpages.cpp +++ b/src/createinstancedialogpages.cpp @@ -103,6 +103,7 @@ void Page::next() bool Page::action(CreateInstanceDialog::Actions a) { + Q_UNUSED(a); // no-op return false; } @@ -1203,7 +1204,7 @@ NexusPage::NexusPage(CreateInstanceDialog& dlg) : Page(dlg), m_skip(false) // just check it once, or connecting and then going back and forth would skip // the page, which would be unexpected - m_skip = GlobalSettings::hasNexusApiKey(); + m_skip = GlobalSettings::hasNexusOAuthTokens() || GlobalSettings::hasNexusApiKey(); } NexusPage::~NexusPage() = default; diff --git a/src/dlls.manifest.debug.qt6 b/src/dlls.manifest.debug.qt6 index e216a6fc..eb0cf284 100644 --- a/src/dlls.manifest.debug.qt6 +++ b/src/dlls.manifest.debug.qt6 @@ -12,6 +12,7 @@ <file name="Qt6Cored.dll" /> <file name="Qt6Guid.dll" /> <file name="Qt6Networkd.dll" /> + <file name="Qt6NetworkAuthd.dll" /> <file name="Qt6OpenGLd.dll" /> <file name="Qt6OpenGLWidgetsd.dll" /> <file name="Qt6Positioningd.dll" /> diff --git a/src/dlls.manifest.qt6 b/src/dlls.manifest.qt6 index 8f3ba135..4702d111 100644 --- a/src/dlls.manifest.qt6 +++ b/src/dlls.manifest.qt6 @@ -12,6 +12,7 @@ <file name="Qt6Core.dll" /> <file name="Qt6Gui.dll" /> <file name="Qt6Network.dll" /> + <file name="Qt6NetworkAuth.dll" /> <file name="Qt6OpenGL.dll" /> <file name="Qt6OpenGLWidgets.dll" /> <file name="Qt6Positioning.dll" /> diff --git a/src/instancemanager.cpp b/src/instancemanager.cpp index fcb7bf67..54da7f9a 100644 --- a/src/instancemanager.cpp +++ b/src/instancemanager.cpp @@ -53,8 +53,10 @@ QString Instance::displayName() const { if (isPortable()) return QObject::tr("Portable"); - else - return QDir(m_dir).dirName(); + else { + QDir instanceDir(m_dir.toUtf8()); + return instanceDir.dirName(); + } } QString Instance::gameName() const diff --git a/src/moapplication.cpp b/src/moapplication.cpp index 6e0d5003..dd0b897b 100644 --- a/src/moapplication.cpp +++ b/src/moapplication.cpp @@ -323,9 +323,10 @@ int MOApplication::run(MOMultiProcess& multiProcess) tt.start("MOApplication::doOneRun() finishing"); // start an api check - QString apiKey; - if (GlobalSettings::nexusApiKey(apiKey)) { - m_nexus->getAccessManager()->apiCheck(apiKey); + NexusOAuthTokens tokens; + if (GlobalSettings::nexusOAuthTokens(tokens) || + GlobalSettings::nexusApiKey(tokens.apiKey)) { + m_nexus->getAccessManager()->apiCheck(tokens); } // tutorials diff --git a/src/modinfo.cpp b/src/modinfo.cpp index abfd9de6..ea99eee3 100644 --- a/src/modinfo.cpp +++ b/src/modinfo.cpp @@ -314,8 +314,8 @@ bool ModInfo::checkAllForUpdate(PluginContainer* pluginContainer, QObject* recei } // Detect invalid source games + const auto& gamePlugins = pluginContainer->plugins<IPluginGame>(); for (auto itr = games.begin(); itr != games.end();) { - auto gamePlugins = pluginContainer->plugins<IPluginGame>(); IPluginGame* gamePlugin = qApp->property("managed_game").value<IPluginGame*>(); for (auto plugin : gamePlugins) { if (plugin != nullptr && diff --git a/src/modlistviewactions.cpp b/src/modlistviewactions.cpp index 9f289678..1f043ae7 100644 --- a/src/modlistviewactions.cpp +++ b/src/modlistviewactions.cpp @@ -230,12 +230,13 @@ void ModListViewActions::checkModsForUpdates() const QString()); NexusInterface::instance().requestTrackingInfo(m_receiver, QVariant(), QString()); } else { - QString apiKey; - if (GlobalSettings::nexusApiKey(apiKey)) { + NexusOAuthTokens tokens; + if (GlobalSettings::nexusOAuthTokens(tokens) || + GlobalSettings::nexusApiKey(tokens.apiKey)) { m_core.doAfterLogin([=]() { checkModsForUpdates(); }); - NexusInterface::instance().getAccessManager()->apiCheck(apiKey); + NexusInterface::instance().getAccessManager()->apiCheck(tokens); } else { log::warn("{}", tr("You are not currently authenticated with Nexus. Please do so " "under Settings -> Nexus.")); @@ -310,12 +311,13 @@ void ModListViewActions::checkModsForUpdates( if (NexusInterface::instance().getAccessManager()->validated()) { ModInfo::manualUpdateCheck(m_receiver, IDs); } else { - QString apiKey; - if (GlobalSettings::nexusApiKey(apiKey)) { + NexusOAuthTokens tokens; + if (GlobalSettings::nexusOAuthTokens(tokens) || + GlobalSettings::nexusApiKey(tokens.apiKey)) { m_core.doAfterLogin([=]() { checkModsForUpdates(IDs); }); - NexusInterface::instance().getAccessManager()->apiCheck(apiKey); + NexusInterface::instance().getAccessManager()->apiCheck(tokens); } else log::warn("{}", tr("You are not currently authenticated with Nexus. Please do so " "under Settings -> Nexus.")); diff --git a/src/nexusinterface.cpp b/src/nexusinterface.cpp index 1486a59c..0263907b 100644 --- a/src/nexusinterface.cpp +++ b/src/nexusinterface.cpp @@ -415,7 +415,7 @@ QString NexusInterface::getGameURL(QString gameName) const } } -QString NexusInterface::getOldModsURL(QString gameName) const +QString NexusInterface::getOldModsURL(const QString& gameName) const { IPluginGame* game = getGame(gameName); if (game != nullptr) { @@ -465,11 +465,10 @@ void NexusInterface::setPluginContainer(PluginContainer* pluginContainer) } int NexusInterface::requestDescription(QString gameName, int modID, QObject* receiver, - QVariant userData, const QString& subModule, - MOBase::IPluginGame const* game) + QVariant userData, const QString& subModule) { NXMRequestInfo requestInfo(modID, NXMRequestInfo::TYPE_DESCRIPTION, userData, - subModule, game); + subModule, getGameNexusName(gameName)); m_RequestQueue.enqueue(requestInfo); connect(this, SIGNAL(nxmDescriptionAvailable(QString, int, QVariant, QVariant, int)), @@ -487,8 +486,7 @@ int NexusInterface::requestDescription(QString gameName, int modID, QObject* rec } int NexusInterface::requestModInfo(QString gameName, int modID, QObject* receiver, - QVariant userData, const QString& subModule, - MOBase::IPluginGame const* game) + QVariant userData, const QString& subModule) { if (m_User.shouldThrottle()) { throttledWarning(m_User); @@ -496,7 +494,7 @@ int NexusInterface::requestModInfo(QString gameName, int modID, QObject* receive } NXMRequestInfo requestInfo(modID, NXMRequestInfo::TYPE_MODINFO, userData, subModule, - game); + getGameNexusName(gameName)); m_RequestQueue.enqueue(requestInfo); connect(this, SIGNAL(nxmModInfoAvailable(QString, int, QVariant, QVariant, int)), @@ -515,8 +513,7 @@ int NexusInterface::requestModInfo(QString gameName, int modID, QObject* receive int NexusInterface::requestUpdateInfo(QString gameName, NexusInterface::UpdatePeriod period, QObject* receiver, QVariant userData, - const QString& subModule, - const MOBase::IPluginGame* game) + const QString& subModule) { if (m_User.shouldThrottle()) { throttledWarning(m_User); @@ -524,7 +521,7 @@ int NexusInterface::requestUpdateInfo(QString gameName, } NXMRequestInfo requestInfo(period, NXMRequestInfo::TYPE_CHECKUPDATES, userData, - subModule, game); + subModule, getGameNexusName(gameName)); m_RequestQueue.enqueue(requestInfo); connect(this, SIGNAL(nxmUpdateInfoAvailable(QString, QVariant, QVariant, int)), @@ -556,7 +553,7 @@ int NexusInterface::requestUpdates(const int& modID, QObject* receiver, } NXMRequestInfo requestInfo(modID, NXMRequestInfo::TYPE_GETUPDATES, userData, - subModule, game); + subModule, game->gameNexusName()); m_RequestQueue.enqueue(requestInfo); connect(this, SIGNAL(nxmUpdatesAvailable(QString, int, QVariant, QVariant, int)), @@ -594,11 +591,10 @@ void NexusInterface::fakeFiles() } int NexusInterface::requestFiles(QString gameName, int modID, QObject* receiver, - QVariant userData, const QString& subModule, - MOBase::IPluginGame const* game) + QVariant userData, const QString& subModule) { NXMRequestInfo requestInfo(modID, NXMRequestInfo::TYPE_FILES, userData, subModule, - game); + getGameNexusName(gameName)); m_RequestQueue.enqueue(requestInfo); connect(this, SIGNAL(nxmFilesAvailable(QString, int, QVariant, QVariant, int)), receiver, SLOT(nxmFilesAvailable(QString, int, QVariant, QVariant, int)), @@ -617,14 +613,9 @@ int NexusInterface::requestFileInfo(QString gameName, int modID, int fileID, QObject* receiver, QVariant userData, const QString& subModule) { - IPluginGame* gamePlugin = getGame(gameName); - if (gamePlugin == nullptr) { - log::error("requestFileInfo can't find plugin for {}", gameName); - return -1; - } NXMRequestInfo requestInfo(modID, fileID, NXMRequestInfo::TYPE_FILEINFO, userData, - subModule, gamePlugin); + subModule, getGameNexusName(gameName)); m_RequestQueue.enqueue(requestInfo); connect( @@ -643,11 +634,10 @@ int NexusInterface::requestFileInfo(QString gameName, int modID, int fileID, int NexusInterface::requestDownloadURL(QString gameName, int modID, int fileID, QObject* receiver, QVariant userData, - const QString& subModule, - MOBase::IPluginGame const* game) + const QString& subModule) { NXMRequestInfo requestInfo(modID, fileID, NXMRequestInfo::TYPE_DOWNLOADURL, userData, - subModule, game); + subModule, getGameNexusName(gameName)); m_RequestQueue.enqueue(requestInfo); connect(this, @@ -687,8 +677,7 @@ int NexusInterface::requestEndorsementInfo(QObject* receiver, QVariant userData, int NexusInterface::requestToggleEndorsement(QString gameName, int modID, QString modVersion, bool endorse, QObject* receiver, QVariant userData, - const QString& subModule, - MOBase::IPluginGame const* game) + const QString& subModule) { if (m_User.shouldThrottle()) { throttledWarning(m_User); @@ -696,7 +685,7 @@ int NexusInterface::requestToggleEndorsement(QString gameName, int modID, } NXMRequestInfo requestInfo(modID, modVersion, NXMRequestInfo::TYPE_TOGGLEENDORSEMENT, - userData, subModule, game); + userData, subModule, getGameNexusName(gameName)); requestInfo.m_Endorse = endorse; m_RequestQueue.enqueue(requestInfo); @@ -733,8 +722,7 @@ int NexusInterface::requestTrackingInfo(QObject* receiver, QVariant userData, int NexusInterface::requestToggleTracking(QString gameName, int modID, bool track, QObject* receiver, QVariant userData, - const QString& subModule, - MOBase::IPluginGame const* game) + const QString& subModule) { if (m_User.shouldThrottle()) { throttledWarning(m_User); @@ -742,7 +730,7 @@ int NexusInterface::requestToggleTracking(QString gameName, int modID, bool trac } NXMRequestInfo requestInfo(modID, NXMRequestInfo::TYPE_TOGGLETRACKING, userData, - subModule, game); + subModule, getGameNexusName(gameName)); requestInfo.m_Track = track; m_RequestQueue.enqueue(requestInfo); @@ -760,15 +748,15 @@ int NexusInterface::requestToggleTracking(QString gameName, int modID, bool trac } int NexusInterface::requestGameInfo(QString gameName, QObject* receiver, - QVariant userData, const QString& subModule, - MOBase::IPluginGame const* game) + QVariant userData, const QString& subModule) { if (m_User.shouldThrottle()) { throttledWarning(m_User); return -1; } - NXMRequestInfo requestInfo(NXMRequestInfo::TYPE_GAMEINFO, userData, subModule, game); + NXMRequestInfo requestInfo(NXMRequestInfo::TYPE_GAMEINFO, userData, subModule, + getGameNexusName(gameName)); m_RequestQueue.enqueue(requestInfo); connect(this, SIGNAL(nxmGameInfoAvailable(QString, QVariant, QVariant, int)), @@ -786,11 +774,10 @@ int NexusInterface::requestGameInfo(QString gameName, QObject* receiver, int NexusInterface::requestInfoFromMd5(QString gameName, QByteArray& hash, QObject* receiver, QVariant userData, - const QString& subModule, - MOBase::IPluginGame const* game) + const QString& subModule) { NXMRequestInfo requestInfo(hash, NXMRequestInfo::TYPE_FILEINFO_MD5, userData, - subModule, game); + subModule, getGameNexusName(gameName)); requestInfo.m_Hash = hash; requestInfo.m_AllowedErrors[QNetworkReply::NetworkError::ContentNotFoundError].append( 404); @@ -810,10 +797,10 @@ int NexusInterface::requestInfoFromMd5(QString gameName, QByteArray& hash, return requestInfo.m_ID; } -IPluginGame* NexusInterface::getGame(QString gameName) const +IPluginGame* NexusInterface::getGame(const QString& gameName) const { - auto gamePlugins = m_PluginContainer->plugins<IPluginGame>(); - IPluginGame* gamePlugin = qApp->property("managed_game").value<IPluginGame*>(); + const auto& gamePlugins = m_PluginContainer->plugins<IPluginGame>(); + IPluginGame* gamePlugin = nullptr; for (auto plugin : gamePlugins) { if (plugin != nullptr && plugin->gameShortName().compare(gameName, Qt::CaseInsensitive) == 0) { @@ -824,6 +811,16 @@ IPluginGame* NexusInterface::getGame(QString gameName) const return gamePlugin; } +QString NexusInterface::getGameNexusName(const QString& gameName) const +{ + IPluginGame* game = getGame(gameName); + if (game != nullptr) { + return game->gameNexusName(); + } + + return gameName; +} + void NexusInterface::cleanup() { m_AccessManager = nullptr; @@ -855,6 +852,20 @@ void NexusInterface::nextRequest() } } + const auto currentTokens = m_AccessManager->tokens(); + if (!currentTokens || + (currentTokens->accessToken.isEmpty() && currentTokens->apiKey.isEmpty())) { + log::error("nexus: no OAuth token available, request aborted"); + return; + } + + if (!currentTokens->accessToken.isEmpty()) { + if (currentTokens->isExpired()) { + m_AccessManager->connectOrRefresh(*currentTokens); + return; + } + } + if (m_User.exhausted()) { m_RequestQueue.clear(); QTime time = QTime::currentTime(); @@ -985,33 +996,42 @@ void NexusInterface::nextRequest() } else { url = info.m_URL; } - QNetworkRequest request(url); - request.setAttribute(QNetworkRequest::CacheSaveControlAttribute, false); - request.setAttribute(QNetworkRequest::CacheLoadControlAttribute, - QNetworkRequest::AlwaysNetwork); - request.setRawHeader("APIKEY", m_User.apiKey().toUtf8()); - request.setHeader(QNetworkRequest::KnownHeaders::UserAgentHeader, - m_AccessManager->userAgent(info.m_SubModule)); - request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, - "application/json"); - request.setRawHeader("Protocol-Version", "1.0.0"); - request.setRawHeader("Application-Name", "MO2"); - request.setRawHeader("Application-Version", - QApplication::applicationVersion().toUtf8()); - if (postData.object().isEmpty()) { - if (!requestIsDelete) { - info.m_Reply = m_AccessManager->get(request); + QNetworkRequest request(url); + if (!currentTokens->accessToken.isEmpty()) { + if (postData.object().isEmpty()) { + if (!requestIsDelete) { + info.m_Reply = m_AccessManager->makeOAuthGetRequest(url); + } else { + m_AccessManager->addAPIHeaders(request); + info.m_Reply = m_AccessManager->makeOAuthDeleteRequest(request); + } + } else if (!requestIsDelete) { + info.m_Reply = m_AccessManager->makeOAuthPostRequest(url, postData.toJson()); } else { - info.m_Reply = m_AccessManager->deleteResource(request); + // Qt doesn't support DELETE with a payload as that's technically against the HTTP + // standard... + info.m_Reply = + m_AccessManager->makeOAuthCustomRequest(request, "DELETE", postData.toJson()); } - } else if (!requestIsDelete) { - info.m_Reply = m_AccessManager->post(request, postData.toJson()); } else { - // Qt doesn't support DELETE with a payload as that's technically against the HTTP - // standard... - info.m_Reply = - m_AccessManager->sendCustomRequest(request, "DELETE", postData.toJson()); + request.setRawHeader("APIKEY", currentTokens->apiKey.toUtf8()); + m_AccessManager->addAPIHeaders(request); + + if (postData.object().isEmpty()) { + if (!requestIsDelete) { + info.m_Reply = m_AccessManager->get(request); + } else { + info.m_Reply = m_AccessManager->deleteResource(request); + } + } else if (!requestIsDelete) { + info.m_Reply = m_AccessManager->post(request, postData.toJson()); + } else { + // Qt doesn't support DELETE with a payload as that's technically against the HTTP + // standard... + info.m_Reply = + m_AccessManager->sendCustomRequest(request, "DELETE", postData.toJson()); + } } connect(info.m_Reply, SIGNAL(finished()), this, SLOT(requestFinished())); @@ -1237,45 +1257,41 @@ QString get_management_url() NexusInterface::NXMRequestInfo::NXMRequestInfo( int modID, NexusInterface::NXMRequestInfo::Type type, QVariant userData, - const QString& subModule, MOBase::IPluginGame const* game) + const QString& subModule, const QString& gameNexusName) : m_ModID(modID), m_ModVersion("0"), m_FileID(0), m_Reply(nullptr), m_Type(type), m_UpdatePeriod(UpdatePeriod::NONE), m_UserData(userData), m_Timeout(nullptr), m_Reroute(false), m_ID(s_NextID.fetchAndAddAcquire(1)), - m_URL(get_management_url()), m_SubModule(subModule), - m_NexusGameID(game->nexusGameID()), m_GameName(game->gameNexusName()), + m_URL(get_management_url()), m_SubModule(subModule), m_GameName(gameNexusName), m_Endorse(false), m_Track(false), m_Hash(QByteArray()) {} NexusInterface::NXMRequestInfo::NXMRequestInfo( int modID, QString modVersion, NexusInterface::NXMRequestInfo::Type type, - QVariant userData, const QString& subModule, MOBase::IPluginGame const* game) + QVariant userData, const QString& subModule, const QString& gameNexusName) : m_ModID(modID), m_ModVersion(modVersion), m_FileID(0), m_Reply(nullptr), m_Type(type), m_UpdatePeriod(UpdatePeriod::NONE), m_UserData(userData), m_Timeout(nullptr), m_Reroute(false), m_ID(s_NextID.fetchAndAddAcquire(1)), - m_URL(get_management_url()), m_SubModule(subModule), - m_NexusGameID(game->nexusGameID()), m_GameName(game->gameNexusName()), + m_URL(get_management_url()), m_SubModule(subModule), m_GameName(gameNexusName), m_Endorse(false), m_Track(false), m_Hash(QByteArray()) {} NexusInterface::NXMRequestInfo::NXMRequestInfo(Type type, QVariant userData, const QString& subModule, - MOBase::IPluginGame const* game) + const QString& gameNexusName) : m_ModID(0), m_ModVersion("0"), m_FileID(0), m_Reply(nullptr), m_Type(type), m_UpdatePeriod(UpdatePeriod::NONE), m_UserData(userData), m_Timeout(nullptr), m_Reroute(false), m_ID(s_NextID.fetchAndAddAcquire(1)), - m_URL(get_management_url()), m_SubModule(subModule), - m_NexusGameID(game->nexusGameID()), m_GameName(game->gameNexusName()), + m_URL(get_management_url()), m_SubModule(subModule), m_GameName(gameNexusName), m_Endorse(false), m_Track(false), m_Hash(QByteArray()) {} NexusInterface::NXMRequestInfo::NXMRequestInfo( int modID, int fileID, NexusInterface::NXMRequestInfo::Type type, QVariant userData, - const QString& subModule, MOBase::IPluginGame const* game) + const QString& subModule, const QString& gameNexusName) : m_ModID(modID), m_ModVersion("0"), m_FileID(fileID), m_Reply(nullptr), m_Type(type), m_UpdatePeriod(UpdatePeriod::NONE), m_UserData(userData), m_Timeout(nullptr), m_Reroute(false), m_ID(s_NextID.fetchAndAddAcquire(1)), - m_URL(get_management_url()), m_SubModule(subModule), - m_NexusGameID(game->nexusGameID()), m_GameName(game->gameNexusName()), + m_URL(get_management_url()), m_SubModule(subModule), m_GameName(gameNexusName), m_Endorse(false), m_Track(false), m_Hash(QByteArray()) {} @@ -1284,28 +1300,26 @@ NexusInterface::NXMRequestInfo::NXMRequestInfo(Type type, QVariant userData, : m_ModID(0), m_ModVersion("0"), m_FileID(0), m_Reply(nullptr), m_Type(type), m_UpdatePeriod(UpdatePeriod::NONE), m_UserData(userData), m_Timeout(nullptr), m_Reroute(false), m_ID(s_NextID.fetchAndAddAcquire(1)), - m_URL(get_management_url()), m_SubModule(subModule), m_NexusGameID(0), - m_GameName(""), m_Endorse(false), m_Track(false), m_Hash(QByteArray()) + m_URL(get_management_url()), m_SubModule(subModule), m_GameName(""), + m_Endorse(false), m_Track(false), m_Hash(QByteArray()) {} NexusInterface::NXMRequestInfo::NXMRequestInfo( UpdatePeriod period, NexusInterface::NXMRequestInfo::Type type, QVariant userData, - const QString& subModule, MOBase::IPluginGame const* game) + const QString& subModule, const QString& gameNexusName) : m_ModID(0), m_ModVersion("0"), m_FileID(0), m_Reply(nullptr), m_Type(type), m_UpdatePeriod(period), m_UserData(userData), m_Timeout(nullptr), m_Reroute(false), m_ID(s_NextID.fetchAndAddAcquire(1)), - m_URL(get_management_url()), m_SubModule(subModule), - m_NexusGameID(game->nexusGameID()), m_GameName(game->gameNexusName()), + m_URL(get_management_url()), m_SubModule(subModule), m_GameName(gameNexusName), m_Endorse(false), m_Track(false), m_Hash(QByteArray()) {} NexusInterface::NXMRequestInfo::NXMRequestInfo( QByteArray& hash, NexusInterface::NXMRequestInfo::Type type, QVariant userData, - const QString& subModule, MOBase::IPluginGame const* game) + const QString& subModule, const QString& gameNexusName) : m_ModID(0), m_ModVersion("0"), m_FileID(0), m_Reply(nullptr), m_Type(type), m_UpdatePeriod(UpdatePeriod::NONE), m_UserData(userData), m_Timeout(nullptr), m_Reroute(false), m_ID(s_NextID.fetchAndAddAcquire(1)), - m_URL(get_management_url()), m_SubModule(subModule), - m_NexusGameID(game->nexusGameID()), m_GameName(game->gameNexusName()), + m_URL(get_management_url()), m_SubModule(subModule), m_GameName(gameNexusName), m_Endorse(false), m_Track(false), m_Hash(hash) {} diff --git a/src/nexusinterface.h b/src/nexusinterface.h index dd9650b1..03e70118 100644 --- a/src/nexusinterface.h +++ b/src/nexusinterface.h @@ -63,6 +63,7 @@ public: /** * @brief request description for a mod * + * @param gameName the game short name to support multiple game sources * @param modID id of the mod caller is interested in * @param userData user data to be returned with the result * @param url the url to request from @@ -80,6 +81,7 @@ public: /** * @brief request info about a single file of a mod * + * @param gameName the game short name to support multiple game sources * @param modID id of the mod caller is interested in * @param fileID id of the file the caller is interested in * @param userData user data to be returned with the result @@ -90,6 +92,7 @@ public: /** * @brief request the download url of a file * + * @param gameName the game short name to support multiple game sources * @param modID id of the mod caller is interested in * @param fileID id of the file the caller is interested in * @param userData user data to be returned with the result @@ -99,6 +102,8 @@ public: /** * @brief requestToggleEndorsement + * + * @param gameName the game short name to support multiple game sources * @param modID id of the mod caller is interested in * @param userData user data to be returned with the result */ @@ -107,6 +112,8 @@ public: /** * @brief requestToggleTracking + * + * @param gameName the game short name to support multiple game sources * @param modID id of the mod caller is interested in * @param userData user data to be returned with the result */ @@ -115,6 +122,8 @@ public: /** * @brief requestGameInfo + * + * @param gameName the game short name to support multiple game sources * @param userData user data to be returned with the result */ virtual void requestGameInfo(QString gameName, QVariant userData); @@ -246,26 +255,7 @@ public: * @return int an id to identify the request **/ int requestDescription(QString gameName, int modID, QObject* receiver, - QVariant userData, const QString& subModule) - { - return requestDescription(gameName, modID, receiver, userData, subModule, - getGame(gameName)); - } - - /** - * @brief request description for a mod - * - * @param gameName the game short name to support multiple game sources - * @param modID id of the mod caller is interested in - * @param receiver the object to receive the result asynchronously via a signal - *(nxmDescriptionAvailable) - * @param userData user data to be returned with the result - * @param game Game with which the mod is associated - * @return int an id to identify the request - **/ - int requestDescription(QString gameName, int modID, QObject* receiver, - QVariant userData, const QString& subModule, - MOBase::IPluginGame const* game); + QVariant userData, const QString& subModule); /** * @brief request description for a mod @@ -279,36 +269,10 @@ public: * @return int an id to identify the request **/ int requestModInfo(QString gameName, int modID, QObject* receiver, QVariant userData, - const QString& subModule) - { - return requestModInfo(gameName, modID, receiver, userData, subModule, - getGame(gameName)); - } - - /** - * @brief request mod info - * - * @param gameName the game short name to support multiple game sources - * @param modID id of the mod caller is interested in - * @param receiver the object to receive the result asynchronously via a signal - *(nxmModInfoAvailable) - * @param userData user data to be returned with the result - * @param game Game with which the mod is associated - * @return int an id to identify the request - **/ - int requestModInfo(QString gameName, int modID, QObject* receiver, QVariant userData, - const QString& subModule, MOBase::IPluginGame const* game); - - int requestUpdateInfo(QString gameName, NexusInterface::UpdatePeriod period, - QObject* receiver, QVariant userData, const QString& subModule) - { - return requestUpdateInfo(gameName, period, receiver, userData, subModule, - getGame(gameName)); - } + const QString& subModule); - int requestUpdateInfo(QString gameName, NexusInterface::UpdatePeriod period, - QObject* receiver, QVariant userData, const QString& subModule, - const MOBase::IPluginGame* game); + int requestUpdateInfo(QString gameNexusName, NexusInterface::UpdatePeriod period, + QObject* receiver, QVariant userData, const QString& subModule); /** * @brief request nexus descriptions for multiple mods at once @@ -334,30 +298,12 @@ public: * @return int an id to identify the request **/ int requestFiles(QString gameName, int modID, QObject* receiver, QVariant userData, - const QString& subModule) - { - return requestFiles(gameName, modID, receiver, userData, subModule, - getGame(gameName)); - } - - /** - * @brief request a list of the files belonging to a mod - * - * @param gameName the game short name to support multiple game sources - * @param modID id of the mod caller is interested in - * @param receiver the object to receive the result asynchronously via a signal - *(nxmFilesAvailable) - * @param userData user data to be returned with the result - * @param game the game with which the mods are associated - * @return int an id to identify the request - **/ - int requestFiles(QString gameName, int modID, QObject* receiver, QVariant userData, - const QString& subModule, MOBase::IPluginGame const* game); + const QString& subModule); /** * @brief request info about a single file of a mod * - * @param gameName name of the game short name to request the download from + * @param gameName game short name to request the download from * @param modID id of the mod caller is interested in (assumed to be for the current *game) * @param fileID id of the file the caller is interested in @@ -382,27 +328,7 @@ public: * @return int an id to identify the request **/ int requestDownloadURL(QString gameName, int modID, int fileID, QObject* receiver, - QVariant userData, const QString& subModule) - { - return requestDownloadURL(gameName, modID, fileID, receiver, userData, subModule, - getGame(gameName)); - } - - /** - * @brief request the download url of a file - * - * @param gameName the game short name to support multiple game sources - * @param modID id of the mod caller is interested in - * @param fileID id of the file the caller is interested in - * @param receiver the object to receive the result asynchronously via a signal - *(nxmFilesAvailable) - * @param userData user data to be returned with the result - * @param game the game with which the mods are associated - * @return int an id to identify the request - **/ - int requestDownloadURL(QString gameName, int modID, int fileID, QObject* receiver, - QVariant userData, const QString& subModule, - MOBase::IPluginGame const* game); + QVariant userData, const QString& subModule); int requestEndorsementInfo(QObject* receiver, QVariant userData, const QString& subModule); @@ -419,27 +345,7 @@ public: */ int requestToggleEndorsement(QString gameName, int modID, QString modVersion, bool endorse, QObject* receiver, QVariant userData, - const QString& subModule) - { - return requestToggleEndorsement(gameName, modID, modVersion, endorse, receiver, - userData, subModule, getGame(gameName)); - } - - /** - * @param gameName the game short name to support multiple game sources - * @brief toggle endorsement state of the mod - * @param modID id of the mod - * @param endorse true if the mod should be endorsed, false for un-endorse - * @param receiver the object to receive the result asynchronously via a signal - * (nxmFilesAvailable) - * @param userData user data to be returned with the result - * @param game the game with which the mods are associated - * @return int an id to identify the request - */ - int requestToggleEndorsement(QString gameName, int modID, QString modVersion, - bool endorse, QObject* receiver, QVariant userData, - const QString& subModule, - MOBase::IPluginGame const* game); + const QString& subModule); int requestTrackingInfo(QObject* receiver, QVariant userData, const QString& subModule); @@ -456,43 +362,7 @@ public: * @return int an id to identify the request */ int requestToggleTracking(QString gameName, int modID, bool track, QObject* receiver, - QVariant userData, const QString& subModule) - { - return requestToggleTracking(gameName, modID, track, receiver, userData, subModule, - getGame(gameName)); - } - - /** - * @param gameName the game short name to support multiple game sources - * @brief toggle tracking state of the mod - * @param modID id of the mod - * @param track true if the mod should be tracked, false for not tracked - * @param receiver the object to receive the result asynchronously via a signal - * (nxmFilesAvailable) - * @param userData user data to be returned with the result - * @param game the game with which the mods are associated - * @return int an id to identify the request - */ - int requestToggleTracking(QString gameName, int modID, bool track, QObject* receiver, - QVariant userData, const QString& subModule, - MOBase::IPluginGame const* game); - - /** - * @param gameName the game short name to support multiple game sources - * @brief toggle tracking state of the mod - * @param modID id of the mod - * @param track true if the mod should be tracked, false for not tracked - * @param receiver the object to receive the result asynchronously via a signal - * (nxmFilesAvailable) - * @param userData user data to be returned with the result - * @param game the game with which the mods are associated - * @return int an id to identify the request - */ - int requestGameInfo(QString gameName, QObject* receiver, QVariant userData, - const QString& subModule) - { - return requestGameInfo(gameName, receiver, userData, subModule, getGame(gameName)); - } + QVariant userData, const QString& subModule); /** * @param gameName the game short name to support multiple game sources @@ -506,24 +376,13 @@ public: * @return int an id to identify the request */ int requestGameInfo(QString gameName, QObject* receiver, QVariant userData, - const QString& subModule, MOBase::IPluginGame const* game); - - /** - * - */ - int requestInfoFromMd5(QString gameName, QByteArray& hash, QObject* receiver, - QVariant userData, const QString& subModule) - { - return requestInfoFromMd5(gameName, hash, receiver, userData, subModule, - getGame(gameName)); - } + const QString& subModule); /** * */ int requestInfoFromMd5(QString gameName, QByteArray& hash, QObject* receiver, - QVariant userData, const QString& subModule, - MOBase::IPluginGame const* game); + QVariant userData, const QString& subModule); /** * @param directory the directory to store cache files @@ -666,7 +525,6 @@ private: QString m_URL; QString m_SubModule; QString m_GameName; - int m_NexusGameID; bool m_Reroute; int m_ID; int m_Endorse; @@ -676,18 +534,18 @@ private: bool m_IgnoreGenericErrorHandler; NXMRequestInfo(int modID, Type type, QVariant userData, const QString& subModule, - MOBase::IPluginGame const* game); + const QString& gameNexusName); NXMRequestInfo(int modID, QString modVersion, Type type, QVariant userData, - const QString& subModule, MOBase::IPluginGame const* game); + const QString& subModule, const QString& gameNexusName); NXMRequestInfo(int modID, int fileID, Type type, QVariant userData, - const QString& subModule, MOBase::IPluginGame const* game); + const QString& subModule, const QString& gameNexusName); NXMRequestInfo(Type type, QVariant userData, const QString& subModule, - MOBase::IPluginGame const* game); + const QString& gameNexusName); NXMRequestInfo(Type type, QVariant userData, const QString& subModule); NXMRequestInfo(UpdatePeriod period, Type type, QVariant userData, - const QString& subModule, MOBase::IPluginGame const* game); + const QString& subModule, const QString& gameNexusName); NXMRequestInfo(QByteArray& hash, Type type, QVariant userData, - const QString& subModule, MOBase::IPluginGame const* game); + const QString& subModule, const QString& gameNexusName); private: static QAtomicInt s_NextID; @@ -698,8 +556,9 @@ private: private: void nextRequest(); void requestFinished(std::list<NXMRequestInfo>::iterator iter); - MOBase::IPluginGame* getGame(QString gameName) const; - QString getOldModsURL(QString gameName) const; + MOBase::IPluginGame* getGame(const QString& gameName) const; + QString getGameNexusName(const QString& gameName) const; + QString getOldModsURL(const QString& gameName) const; private: QNetworkDiskCache* m_DiskCache; diff --git a/src/nexusoauthconfig.cpp b/src/nexusoauthconfig.cpp new file mode 100644 index 00000000..370e0d6b --- /dev/null +++ b/src/nexusoauthconfig.cpp @@ -0,0 +1,62 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see <http://www.gnu.org/licenses/>. +*/ + +#include "nexusoauthconfig.h" +#include <QProcessEnvironment> + +namespace +{ +QString envOrDefault(const char* name, const QString& fallback) +{ + const auto value = qEnvironmentVariable(name); + if (!value.isEmpty()) { + return value; + } + + return fallback; +} +} // namespace + +namespace NexusOAuth +{ +QString clientId() +{ + return envOrDefault("MO2_NEXUS_CLIENT_ID", QStringLiteral("modorganizer2")); +} + +quint16 redirectPort() +{ + return 28635; +} + +QString redirectUri() +{ + return QStringLiteral("http://127.0.0.1:%1/callback").arg(redirectPort()); +} + +QString authorizeUrl() +{ + return QStringLiteral("https://users.nexusmods.com/oauth/authorize"); +} + +QString tokenUrl() +{ + return QStringLiteral("https://users.nexusmods.com/oauth/token"); +} +} // namespace NexusOAuth diff --git a/src/nexusoauthconfig.h b/src/nexusoauthconfig.h new file mode 100644 index 00000000..e0ccd631 --- /dev/null +++ b/src/nexusoauthconfig.h @@ -0,0 +1,34 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see <http://www.gnu.org/licenses/>. +*/ + +#ifndef NEXUSOAUTHCONFIG_H +#define NEXUSOAUTHCONFIG_H + +#include <QString> + +namespace NexusOAuth +{ +QString clientId(); +QString redirectUri(); +quint16 redirectPort(); +QString authorizeUrl(); +QString tokenUrl(); +} // namespace NexusOAuth + +#endif // NEXUSOAUTHCONFIG_H diff --git a/src/nexusoauthlogin.cpp b/src/nexusoauthlogin.cpp new file mode 100644 index 00000000..7d13642d --- /dev/null +++ b/src/nexusoauthlogin.cpp @@ -0,0 +1,78 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see <http://www.gnu.org/licenses/>. +*/ + +#include "nexusoauthlogin.h" +#include "nexusinterface.h" +#include "nexusoauthconfig.h" +#include "nxmaccessmanager.h" +#include "utility.h" +#include <QAbstractOAuth> +#include <QCryptographicHash> +#include <QHostAddress> +#include <QJsonObject> +#include <QJsonValue> +#include <QMultiMap> +#include <QRandomGenerator> +#include <QUrl> +#include <QVariant> +#include <QtNetworkAuth/QOAuth2AuthorizationCodeFlow> +#include <QtNetworkAuth/QOAuthHttpServerReplyHandler> + +using namespace MOBase; + +namespace +{ +QString callbackPath() +{ + return QUrl(NexusOAuth::redirectUri()).path(); +} +} // namespace + +NexusOAuthLogin::NexusOAuthLogin(QObject* parent) : QObject(parent), m_active(false) {} + +NexusOAuthLogin::~NexusOAuthLogin() = default; + +void NexusOAuthLogin::start() +{ + if (m_active) { + cancel(); + } + auto accessManager = NexusInterface::instance().getAccessManager(); + connect(accessManager, &NXMAccessManager::authorizationEnded, this, + &NexusOAuthLogin::authorizationEnded); + + NexusInterface::instance().getAccessManager()->connectOrRefresh(NexusOAuthTokens()); + m_active = true; +} + +void NexusOAuthLogin::authorizationEnded() +{ + m_active = false; +} + +void NexusOAuthLogin::cancel() +{ + NexusInterface::instance().getAccessManager()->cancelAuth(); + m_active = false; +} + +bool NexusOAuthLogin::isActive() const +{ + return m_active; +} diff --git a/src/nexusoauthlogin.h b/src/nexusoauthlogin.h new file mode 100644 index 00000000..66b6b392 --- /dev/null +++ b/src/nexusoauthlogin.h @@ -0,0 +1,59 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see <http://www.gnu.org/licenses/>. +*/ + +#ifndef NEXUSOAUTHLOGIN_H +#define NEXUSOAUTHLOGIN_H + +#include "nexusoauthtokens.h" +#include "nxmaccessmanager.h" +#include <QAbstractOAuth> +#include <QObject> +#include <functional> +#include <memory> + +class QOAuth2AuthorizationCodeFlow; +class QOAuthHttpServerReplyHandler; + +class NexusOAuthLogin : public QObject +{ + Q_OBJECT + +public: + explicit NexusOAuthLogin(QObject* parent = nullptr); + ~NexusOAuthLogin(); + + void start(); + void cancel(); + bool isActive() const; + + std::function<void(const NexusOAuthTokens&)> tokensReceived; + std::function<void(NXMAccessManager::OAuthState, QString)> stateChanged; + +private slots: + void authorizationEnded(); + +private: + std::unique_ptr<QOAuth2AuthorizationCodeFlow> m_flow; + std::unique_ptr<QOAuthHttpServerReplyHandler> m_replyHandler; + bool m_active; + + QByteArray m_codeVerifier; +}; + +#endif // NEXUSOAUTHLOGIN_H diff --git a/src/nexusoauthtokens.h b/src/nexusoauthtokens.h new file mode 100644 index 00000000..78f3f22c --- /dev/null +++ b/src/nexusoauthtokens.h @@ -0,0 +1,98 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see <http://www.gnu.org/licenses/>. +*/ + +#ifndef NEXUSOAUTHTOKENS_H +#define NEXUSOAUTHTOKENS_H + +#include <QDateTime> +#include <QJsonObject> +#include <chrono> +#include <optional> + +struct NexusOAuthTokens +{ + QString accessToken; + QString refreshToken; + QString scope; + QString tokenType; + QDateTime expiresAt; + QString apiKey; + + bool isValid() const { return !accessToken.isEmpty() && expiresAt.isValid(); } + + bool isExpired(std::chrono::seconds skew = std::chrono::seconds(300)) const + { + if (!expiresAt.isValid()) { + return true; + } + + const auto now = QDateTime::currentDateTimeUtc(); + return now.addSecs(skew.count()) >= expiresAt; + } + + QJsonObject toJson() const + { + QJsonObject json; + json.insert(QStringLiteral("access_token"), accessToken); + json.insert(QStringLiteral("refresh_token"), refreshToken); + json.insert(QStringLiteral("scope"), scope); + json.insert(QStringLiteral("token_type"), tokenType); + json.insert(QStringLiteral("expires_at"), expiresAt.toString(Qt::ISODateWithMs)); + return json; + } + + static std::optional<NexusOAuthTokens> fromJson(const QJsonObject& json) + { + NexusOAuthTokens tokens; + tokens.accessToken = json.value(QStringLiteral("access_token")).toString(); + tokens.refreshToken = json.value(QStringLiteral("refresh_token")).toString(); + tokens.scope = json.value(QStringLiteral("scope")).toString(); + tokens.tokenType = json.value(QStringLiteral("token_type")).toString(); + tokens.expiresAt = QDateTime::fromString( + json.value(QStringLiteral("expires_at")).toString(), Qt::ISODateWithMs); + if (!tokens.expiresAt.isValid()) { + tokens.expiresAt = QDateTime::fromString( + json.value(QStringLiteral("expires_at")).toString(), Qt::ISODate); + } + + if (!tokens.isValid()) { + return std::nullopt; + } + + if (tokens.expiresAt.isValid() && tokens.expiresAt.timeSpec() != Qt::UTC) { + tokens.expiresAt = tokens.expiresAt.toUTC(); + } + + return tokens; + } +}; + +inline NexusOAuthTokens makeTokensFromResponse(const QVariantMap& data) +{ + NexusOAuthTokens tokens; + tokens.accessToken = data["access_token"].toString(); + tokens.refreshToken = data["refresh_token"].toString(); + tokens.scope = data["scope"].toString(); + tokens.tokenType = data["token_type"].toString(); + tokens.expiresAt = data["expiration_at"].toDateTime(); + + return tokens; +} + +#endif // NEXUSOAUTHTOKENS_H diff --git a/src/nxmaccessmanager.cpp b/src/nxmaccessmanager.cpp index 3efdcda0..8d1e73cc 100644 --- a/src/nxmaccessmanager.cpp +++ b/src/nxmaccessmanager.cpp @@ -20,6 +20,8 @@ along with Mod Organizer. If not, see <http://www.gnu.org/licenses/>. #include "nxmaccessmanager.h" #include "iplugingame.h" #include "nexusinterface.h" +#include "nexusoauthconfig.h" +#include "nexusoauthlogin.h" #include "nxmurl.h" #include "persistentcookiejar.h" #include "report.h" @@ -28,6 +30,7 @@ along with Mod Organizer. If not, see <http://www.gnu.org/licenses/>. #include "utility.h" #include <QCoreApplication> #include <QDir> +#include <QEventLoop> #include <QJsonArray> #include <QJsonDocument> #include <QMessageBox> @@ -42,13 +45,11 @@ along with Mod Organizer. If not, see <http://www.gnu.org/licenses/>. using namespace MOBase; using namespace std::chrono_literals; -const QString NexusBaseUrl("https://api.nexusmods.com/v1"); -const QString NexusSSO("wss://sso.nexusmods.com"); -const QString - NexusSSOPage("https://www.nexusmods.com/sso?id=%1&application=modorganizer2"); +const QString NexusUserUrl("https://users.nexusmods.com/oauth/"); +const QString NexusV1BaseUrl("https://api.nexusmods.com/v1/"); ValidationProgressDialog::ValidationProgressDialog(Settings* s, NexusKeyValidator& v) - : m_settings(s), m_validator(v), m_updateTimer(nullptr), m_first(true) + : m_Settings(s), m_Validator(v), m_UpdateTimer(nullptr), m_First(true) { ui.reset(new Ui::ValidationProgressDialog); ui->setupUi(this); @@ -77,24 +78,24 @@ void ValidationProgressDialog::setParentWidget(QWidget* w) void ValidationProgressDialog::start() { - if (!m_updateTimer) { - m_updateTimer = new QTimer(this); - connect(m_updateTimer, &QTimer::timeout, [&] { + if (!m_UpdateTimer) { + m_UpdateTimer = new QTimer(this); + connect(m_UpdateTimer, &QTimer::timeout, [&] { onTimer(); }); - m_updateTimer->setInterval(100ms); + m_UpdateTimer->setInterval(100ms); } updateProgress(); - m_updateTimer->start(); + m_UpdateTimer->start(); show(); } void ValidationProgressDialog::stop() { - if (m_updateTimer) { - m_updateTimer->stop(); + if (m_UpdateTimer) { + m_UpdateTimer->stop(); } hide(); @@ -102,13 +103,15 @@ void ValidationProgressDialog::stop() void ValidationProgressDialog::showEvent(QShowEvent* e) { - if (m_first) { - if (m_settings) { - m_settings->geometry().centerOnMainWindowMonitor(this); + if (m_First) { + if (m_Settings) { + m_Settings->geometry().centerOnMainWindowMonitor(this); } - m_first = false; + m_First = false; } + + QDialog::showEvent(e); } void ValidationProgressDialog::closeEvent(QCloseEvent* e) @@ -124,7 +127,7 @@ void ValidationProgressDialog::onHide() void ValidationProgressDialog::onCancel() { - m_validator.cancel(); + m_Validator.cancel(); } void ValidationProgressDialog::onTimer() @@ -134,7 +137,7 @@ void ValidationProgressDialog::onTimer() void ValidationProgressDialog::updateProgress() { - const auto* current = m_validator.currentAttempt(); + const auto* current = m_Validator.currentAttempt(); if (current) { ui->progress->setRange(0, current->timeout().count()); @@ -144,7 +147,7 @@ void ValidationProgressDialog::updateProgress() ui->progress->setRange(0, 0); } - if (const auto* a = m_validator.lastAttempt()) { + if (const auto* a = m_Validator.lastAttempt()) { ui->label->setText(a->message() + ". " + tr("Trying again...")); } else if (current) { ui->label->setText(tr("Connecting to Nexus...")); @@ -153,260 +156,62 @@ void ValidationProgressDialog::updateProgress() } } -NexusSSOLogin::NexusSSOLogin() : m_keyReceived(false), m_active(false) +ValidationAttempt::ValidationAttempt(std::chrono::seconds timeout) + : m_Reply(nullptr), m_Result(None) { - m_timeout.setInterval(10s); - m_timeout.setSingleShot(true); - - QObject::connect(&m_socket, &QWebSocket::connected, [&] { - onConnected(); - }); - - QObject::connect(&m_socket, - qOverload<QAbstractSocket::SocketError>(&QWebSocket::error), - [&](auto&& e) { - onError(e); - }); - - QObject::connect(&m_socket, &QWebSocket::sslErrors, [&](auto&& errors) { - onSslErrors(errors); - }); + m_Timeout.setSingleShot(true); + m_Timeout.setInterval(timeout); - QObject::connect(&m_socket, &QWebSocket::textMessageReceived, [&](auto&& s) { - onMessage(s); - }); - - QObject::connect(&m_socket, &QWebSocket::disconnected, [&] { - onDisconnected(); - }); - - QObject::connect(&m_timeout, &QTimer::timeout, [&] { + QObject::connect(&m_Timeout, &QTimer::timeout, [&] { onTimeout(); }); } -QString NexusSSOLogin::stateToString(States s, const QString& e) -{ - switch (s) { - case ConnectingToSSO: - return QObject::tr("Connecting to Nexus..."); - - case WaitingForToken: - return QObject::tr("Waiting for Nexus..."); - - case WaitingForBrowser: - return QObject::tr("Opened Nexus in browser.") + "\n" + - QObject::tr("Switch to your browser and accept the request."); - - case Finished: - return QObject::tr("Finished."); - - case Timeout: - return QObject::tr("No answer from Nexus.") + "\n" + - QObject::tr("A firewall might be blocking Mod Organizer."); - - case ClosedByRemote: - return QObject::tr("Nexus closed the connection.") + "\n" + - QObject::tr("A firewall might be blocking Mod Organizer."); - - case Cancelled: - return QObject::tr("Cancelled."); - - case Error: // fall-through - default: { - if (e.isEmpty()) { - return QString("%1").arg(s); - } else { - return e; - } - } - } -} - -void NexusSSOLogin::start() -{ - m_active = true; - setState(ConnectingToSSO); - m_timeout.start(); - m_socket.open(NexusSSO); -} - -void NexusSSOLogin::cancel() +void ValidationAttempt::start(NXMAccessManager& m, const NexusOAuthTokens& tokens) { - if (m_active) { - abort(); - setState(Cancelled); - } -} - -void NexusSSOLogin::close() -{ - if (m_active) { - m_active = false; - m_timeout.stop(); - m_socket.close(); - } -} - -void NexusSSOLogin::abort() -{ - m_active = false; - m_timeout.stop(); - m_socket.abort(); -} - -bool NexusSSOLogin::isActive() const -{ - return m_active; -} - -void NexusSSOLogin::setState(States s, const QString& error) -{ - if (stateChanged) { - stateChanged(s, error); - } -} - -void NexusSSOLogin::onConnected() -{ - setState(WaitingForToken); - - m_keyReceived = false; - - boost::uuids::random_generator generator; - boost::uuids::uuid sessionId = generator(); - m_guid = boost::uuids::to_string(sessionId).c_str(); - - QJsonObject data; - data.insert(QString("id"), QJsonValue(m_guid)); - data.insert(QString("protocol"), 2); - - const QString message = QJsonDocument(data).toJson(); - m_socket.sendTextMessage(message); -} - -void NexusSSOLogin::onMessage(const QString& s) -{ - const QJsonDocument doc = QJsonDocument::fromJson(s.toUtf8()); - const QVariantMap root = doc.object().toVariantMap(); - - if (!root["success"].toBool()) { - close(); - - setState(Error, QString("There was a problem with SSO initialization: %1") - .arg(root["error"].toString())); + m_Tokens = tokens; + if (!sendRequest(m, tokens)) { return; } - const QVariantMap data = root["data"].toMap(); - - if (data.contains("connection_token")) { - // first answer - - // open browser - const QUrl url = NexusSSOPage.arg(m_guid); - shell::Open(url); - - m_timeout.stop(); - setState(WaitingForBrowser); - } else { - // second answer - const auto key = data["api_key"].toString(); - close(); + m_Elapsed.start(); + m_Timeout.start(); - if (keyChanged) { - keyChanged(key); - } - - setState(Finished); - } -} - -void NexusSSOLogin::onDisconnected() -{ - if (m_active) { - if (!m_keyReceived) { - close(); - setState(ClosedByRemote); - } else { - m_active = false; - } - } + log::debug("nexus: attempt started with timeout of {} seconds", timeout().count()); } -void NexusSSOLogin::onError(QAbstractSocket::SocketError e) +bool ValidationAttempt::sendRequest(NXMAccessManager& m, const NexusOAuthTokens& tokens) { - if (m_active) { - close(); - setState(Error, m_socket.errorString()); - } -} -void NexusSSOLogin::onSslErrors(const QList<QSslError>& errors) -{ - if (m_active) { - for (const auto& e : errors) { - setState(Error, e.errorString()); - } + if (tokens.accessToken.isEmpty() && tokens.apiKey.isEmpty()) { + setFailure(HardError, QObject::tr("No access token or API key")); + return false; } -} -void NexusSSOLogin::onTimeout() -{ - abort(); - setState(Timeout); -} - -ValidationAttempt::ValidationAttempt(std::chrono::seconds timeout) - : m_reply(nullptr), m_result(None) -{ - m_timeout.setSingleShot(true); - m_timeout.setInterval(timeout); - - QObject::connect(&m_timeout, &QTimer::timeout, [&] { - onTimeout(); - }); -} - -void ValidationAttempt::start(NXMAccessManager& m, const QString& key) -{ - if (!sendRequest(m, key)) { - return; + QNetworkRequest request; + QString requestUrl; + if (!tokens.accessToken.isEmpty()) { + requestUrl = NexusUserUrl + "userinfo"; + m_Reply = + NexusInterface::instance().getAccessManager()->makeOAuthGetRequest(requestUrl); + } else { + requestUrl = NexusV1BaseUrl + "users/validate"; + request.setUrl(requestUrl); + request.setRawHeader("APIKEY", tokens.apiKey.toUtf8()); + m_Reply = m.get(request); } - m_elapsed.start(); - m_timeout.start(); - - log::debug("nexus: attempt started with timeout of {} seconds", timeout().count()); -} - -bool ValidationAttempt::sendRequest(NXMAccessManager& m, const QString& key) -{ - const QString requestUrl(NexusBaseUrl + "/users/validate"); - QNetworkRequest request(requestUrl); - - request.setRawHeader("APIKEY", key.toUtf8()); - request.setHeader(QNetworkRequest::KnownHeaders::UserAgentHeader, - m.userAgent().toUtf8()); - request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, - "application/json"); - request.setRawHeader("Protocol-Version", "1.0.0"); - request.setRawHeader("Application-Name", "MO2"); - request.setRawHeader("Application-Version", m.MOVersion().toUtf8()); - - m_reply = m.get(request); - - if (!m_reply) { + if (!m_Reply) { setFailure(SoftError, QObject::tr("Failed to request %1").arg(requestUrl)); return false; } - QObject::connect(m_reply, &QNetworkReply::finished, [&] { + QObject::connect(m_Reply, &QNetworkReply::finished, [&] { onFinished(); }); - QObject::connect(m_reply, &QNetworkReply::sslErrors, [&](auto&& errors) { + QObject::connect(m_Reply, &QNetworkReply::sslErrors, [&](auto&& errors) { onSslErrors(errors); }); @@ -415,15 +220,15 @@ bool ValidationAttempt::sendRequest(NXMAccessManager& m, const QString& key) void ValidationAttempt::cancel() { - if (!m_reply || m_result != None) { + if (!m_Reply || m_Result != None) { // not running return; } setFailure(Cancelled, QObject::tr("Cancelled")); - if (m_reply) { - m_reply->abort(); + if (m_Reply) { + m_Reply->abort(); } cleanup(); @@ -431,39 +236,39 @@ void ValidationAttempt::cancel() bool ValidationAttempt::done() const { - return (m_result != None); + return (m_Result != None); } ValidationAttempt::Result ValidationAttempt::result() const { - return m_result; + return m_Result; } const QString& ValidationAttempt::message() const { - return m_message; + return m_Message; } std::chrono::seconds ValidationAttempt::timeout() const { return std::chrono::duration_cast<std::chrono::seconds>( - m_timeout.intervalAsDuration()); + m_Timeout.intervalAsDuration()); } QElapsedTimer ValidationAttempt::elapsed() const { - return m_elapsed; + return m_Elapsed; } void ValidationAttempt::onFinished() { - if (m_result == Cancelled) { + if (m_Result == Cancelled) { return; } log::debug("nexus: request has finished"); - if (!m_reply) { + if (!m_Reply) { // shouldn't happen log::error("nexus: reply is null"); setFailure(HardError, QObject::tr("Internal error")); @@ -471,25 +276,25 @@ void ValidationAttempt::onFinished() } const auto code = - m_reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + m_Reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); if (code == 0) { // request wasn't even sent log::error("nexus: code is 0"); - setFailure(SoftError, m_reply->errorString()); + setFailure(SoftError, m_Reply->errorString()); return; } - const auto doc = QJsonDocument::fromJson(m_reply->readAll()); - const auto headers = m_reply->rawHeaderPairs(); - const auto httpError = m_reply->errorString(); + const auto doc = QJsonDocument::fromJson(m_Reply->readAll()); + const auto headers = m_Reply->rawHeaderPairs(); + const auto httpError = m_Reply->errorString(); const QJsonObject data = doc.object(); if (code != 200) { // http request failed - QString s = m_reply->errorString(); + QString s = m_Reply->errorString(); const auto nexusMessage = data.value("message").toString(); if (!nexusMessage.isEmpty()) { @@ -515,30 +320,60 @@ void ValidationAttempt::onFinished() return; } - if (!data.contains("user_id")) { - setFailure(HardError, QObject::tr("Bad response")); - return; - } + if (!m_Tokens.accessToken.isEmpty()) { + if (!data.contains("sub")) { + setFailure(HardError, QObject::tr("Bad response")); + return; + } - const int id = data.value("user_id").toInt(); - const QString key = data.value("key").toString(); - const QString name = data.value("name").toString(); - const bool premium = data.value("is_premium").toBool(); + 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"}; + bool premium = false; + for (auto role : roles) { + QString roleVal = role.toString(); + if (validRoles.contains(roleVal)) { + premium = true; + break; + } + } - if (key.isEmpty()) { - setFailure(HardError, QObject::tr("API key is empty")); - return; - } + if (m_Tokens.accessToken.isEmpty()) { + setFailure(HardError, QObject::tr("Access token is empty")); + return; + } + + const auto user = + APIUserAccount() + .accessToken(m_Tokens.accessToken) + .id(QString("%1").arg(id)) + .name(name) + .type(premium ? APIUserAccountTypes::Premium : APIUserAccountTypes::Regular) + .limits(NexusInterface::defaultAPILimits()); + + setSuccess(user); + } else if (!m_Tokens.apiKey.isEmpty()) { + if (!data.contains("user_id")) { + setFailure(HardError, QObject::tr("Bad response")); + return; + } - const auto user = - APIUserAccount() - .apiKey(key) - .id(QString("%1").arg(id)) - .name(name) - .type(premium ? APIUserAccountTypes::Premium : APIUserAccountTypes::Regular) - .limits(NexusInterface::parseLimits(headers)); + const int id = data.value("user_id").toInt(); + const QString key = data.value("key").toString(); + const QString name = data.value("name").toString(); + const bool premium = data.value("is_premium").toBool(); - setSuccess(user); + const auto user = + APIUserAccount() + .apiKey(m_Tokens.apiKey) + .id(QString("%1").arg(id)) + .name(name) + .type(premium ? APIUserAccountTypes::Premium : APIUserAccountTypes::Regular) + .limits(NexusInterface::parseLimits(headers)); + + setSuccess(user); + } } void ValidationAttempt::onSslErrors(const QList<QSslError>& errors) @@ -566,8 +401,8 @@ void ValidationAttempt::setFailure(Result r, const QString& error) cleanup(); - m_result = r; - m_message = error; + m_Result = r; + m_Message = error; if (failure) { failure(); @@ -579,8 +414,8 @@ void ValidationAttempt::setSuccess(const APIUserAccount& user) log::debug("nexus connection successful"); cleanup(); - m_result = Success; - m_message = ""; + m_Result = Success; + m_Message = ""; if (success) { success(user); @@ -589,17 +424,17 @@ void ValidationAttempt::setSuccess(const APIUserAccount& user) void ValidationAttempt::cleanup() { - m_timeout.stop(); + m_Timeout.stop(); - if (m_reply) { - m_reply->disconnect(); - m_reply->deleteLater(); - m_reply = nullptr; + if (m_Reply) { + m_Reply->disconnect(); + m_Reply->deleteLater(); + m_Reply = nullptr; } } NexusKeyValidator::NexusKeyValidator(Settings* s, NXMAccessManager& am) - : m_settings(s), m_manager(am) + : m_Settings(s), m_Manager(am) {} NexusKeyValidator::~NexusKeyValidator() @@ -609,21 +444,21 @@ NexusKeyValidator::~NexusKeyValidator() std::vector<std::chrono::seconds> NexusKeyValidator::getTimeouts() const { - if (m_settings) { - return m_settings->nexus().validationTimeouts(); + if (m_Settings) { + return m_Settings->nexus().validationTimeouts(); } else { return {10s, 15s, 20s}; } } -void NexusKeyValidator::start(const QString& key, Behaviour b) +void NexusKeyValidator::start(const NexusOAuthTokens& tokens, Behaviour b) { if (isActive()) { log::debug("nexus: trying to start while ongoing; ignoring"); return; } - m_key = key; + m_Tokens = tokens; const auto timeouts = getTimeouts(); @@ -645,10 +480,10 @@ void NexusKeyValidator::start(const QString& key, Behaviour b) void NexusKeyValidator::createAttempts( const std::vector<std::chrono::seconds>& timeouts) { - m_attempts.clear(); + m_Attempts.clear(); for (auto&& t : timeouts) { - m_attempts.push_back(std::make_unique<ValidationAttempt>(t)); + m_Attempts.push_back(std::make_unique<ValidationAttempt>(t)); } } @@ -656,14 +491,14 @@ void NexusKeyValidator::cancel() { log::debug("nexus: connection cancelled"); - for (auto&& a : m_attempts) { + for (auto&& a : m_Attempts) { a->cancel(); } } bool NexusKeyValidator::isActive() const { - for (auto&& a : m_attempts) { + for (auto&& a : m_Attempts) { if (!a->done()) { return true; } @@ -676,7 +511,7 @@ const ValidationAttempt* NexusKeyValidator::lastAttempt() const { const ValidationAttempt* last = nullptr; - for (auto&& a : m_attempts) { + for (auto&& a : m_Attempts) { if (a->done()) { last = a.get(); } else { @@ -689,7 +524,7 @@ const ValidationAttempt* NexusKeyValidator::lastAttempt() const const ValidationAttempt* NexusKeyValidator::currentAttempt() const { - for (auto&& a : m_attempts) { + for (auto&& a : m_Attempts) { if (!a->done()) { return a.get(); } @@ -700,7 +535,12 @@ const ValidationAttempt* NexusKeyValidator::currentAttempt() const bool NexusKeyValidator::nextTry() { - for (auto&& a : m_attempts) { + if (!m_Tokens) { + log::error("nexus: validator invoked without tokens"); + return false; + } + + for (auto&& a : m_Attempts) { if (!a->done()) { a->success = [&](auto&& user) { onAttemptSuccess(*a, user); @@ -709,7 +549,7 @@ bool NexusKeyValidator::nextTry() onAttemptFailure(*a); }; - a->start(m_manager, m_key); + a->start(m_Manager, *m_Tokens); return true; } } @@ -759,6 +599,7 @@ void NexusKeyValidator::onAttemptFailure(const ValidationAttempt& a) void NexusKeyValidator::setFinished(ValidationAttempt::Result r, const QString& message, std::optional<APIUserAccount> user) { + m_Attempts.clear(); if (finished) { finished(r, message, user); } @@ -767,13 +608,68 @@ void NexusKeyValidator::setFinished(ValidationAttempt::Result r, const QString& NXMAccessManager::NXMAccessManager(QObject* parent, Settings* s, const QString& moVersion) : QNetworkAccessManager(parent), m_Settings(s), m_MOVersion(moVersion), - m_validator(s, *this), m_validationState(NotChecked) + m_Validator(s, *this), m_ValidationState(NotChecked) { - m_validator.finished = [&](auto&& r, auto&& m, auto&& u) { + NexusOAuthTokens tokens; + GlobalSettings::nexusOAuthTokens(tokens); + GlobalSettings::nexusApiKey(tokens.apiKey); + m_Tokens = tokens; + m_NexusOAuth.reset(new QOAuth2AuthorizationCodeFlow); + m_NexusOAuthReplyHandler.reset(new QOAuthHttpServerReplyHandler( + QHostAddress::LocalHost, NexusOAuth::redirectPort(), this)); + m_NexusOAuth->setReplyHandler(m_NexusOAuthReplyHandler.get()); + + connect(m_NexusOAuth.get(), &QOAuth2AuthorizationCodeFlow::requestFailed, this, + [&](QAbstractOAuth::Error error) { + handleOAuthError(QObject::tr("Authorization failed (%1)").arg(int(error))); + }); + + connect(m_NexusOAuth.get(), &QOAuth2AuthorizationCodeFlow::granted, this, [&]() { + notifyTokens(); + }); + + connect(m_NexusOAuth.get(), &QOAuth2AuthorizationCodeFlow::authorizeWithBrowser, this, + [&](const QUrl& url) { + shell::Open(url); + setOAuthState(OAuthState::WaitingForBrowser); + }); + + connect(m_NexusOAuth.get(), &QOAuth2AuthorizationCodeFlow::accessTokenAboutToExpire, + this, [&] { + if (!m_NexusOAuthReplyHandler->isListening() && + !m_NexusOAuthReplyHandler->listen(QHostAddress::LocalHost, + NexusOAuth::redirectPort())) { + handleOAuthError(QObject::tr("Failed to bind to localhost on port %1.") + .arg(NexusOAuth::redirectPort())); + return; + } + }); + + connect(m_NexusOAuth.get(), &QOAuth2AuthorizationCodeFlow::statusChanged, this, + [&](QAbstractOAuth::Status status) { + switch (status) { + case QAbstractOAuth::Status::RefreshingToken: + setOAuthState(OAuthState::Refreshing); + break; + case QAbstractOAuth::Status::TemporaryCredentialsReceived: + setOAuthState(OAuthState::Authorizing); + break; + case QAbstractOAuth::Status::Granted: + setOAuthState(OAuthState::Finished); + break; + default: + break; + } + }); + + connect(this, &NXMAccessManager::tokensReceived, this, + &NXMAccessManager::saveRefreshedTokens); + + m_Validator.finished = [&](auto&& r, auto&& m, auto&& u) { onValidatorFinished(r, m, u); }; - m_validator.attemptFinished = [&](auto&& a) { + m_Validator.attemptFinished = [&](auto&& a) { onValidatorAttemptFinished(a); }; @@ -791,7 +687,7 @@ void NXMAccessManager::setTopLevelWidget(QWidget* w) } } else { m_ProgressDialog.reset(); - m_validator.cancel(); + m_Validator.cancel(); } } @@ -818,7 +714,7 @@ NXMAccessManager::createRequest(QNetworkAccessManager::Operation operation, void NXMAccessManager::showCookies() const { - QUrl url(NexusBaseUrl + "/"); + QUrl url(NexusV1BaseUrl + "/"); for (const QNetworkCookie& cookie : cookieJar()->cookiesForUrl(url)) { log::debug("{} - {} (expires: {})", cookie.name().constData(), cookie.value().constData(), cookie.expirationDate().toString()); @@ -835,10 +731,115 @@ void NXMAccessManager::clearCookies() } } -void NXMAccessManager::startValidationCheck(const QString& key) +void NXMAccessManager::setTokens(const NexusOAuthTokens& tokens) +{ + m_Tokens = tokens; +} + +std::optional<NexusOAuthTokens> NXMAccessManager::tokens() const +{ + return m_Tokens; +} + +void NXMAccessManager::handleOAuthError(const QString& message) +{ + m_NexusOAuthReplyHandler->close(); + emit updateOAuthState(OAuthState::Error, message); + emit authorizationEnded(); +} + +void NXMAccessManager::notifyTokens() +{ + if (!m_NexusOAuth) { + handleOAuthError(QObject::tr("Internal error: OAuth flow is missing.")); + return; + } + + QVariantMap payload; + + auto scopeTokens = m_NexusOAuth->grantedScopeTokens(); + QStringList scopes; + for (auto token : scopeTokens) { + scopes.append(QString::fromUtf8(token.constData())); + } + payload["access_token"] = m_NexusOAuth->token(); + payload["refresh_token"] = m_NexusOAuth->refreshToken(); + payload["scope"] = scopes.join(" "); + payload["expiration_at"] = m_NexusOAuth->expirationAt(); + + const auto extras = m_NexusOAuth->extraTokens(); + payload.insert(extras); + + auto tokens = makeTokensFromResponse(payload); + if (!tokens.isValid()) { + handleOAuthError(QObject::tr("Invalid OAuth token payload.")); + return; + } + + tokens.scope = scopes.join(" "); + + emit tokensReceived(tokens); + + startValidationCheck(tokens); + emit authorizationEnded(); +} + +void NXMAccessManager::saveRefreshedTokens(const NexusOAuthTokens tokens) +{ + NexusOAuthTokens finalTokens; + if (GlobalSettings::hasNexusOAuthTokens() || GlobalSettings::hasNexusApiKey()) { + NexusOAuthTokens oldTokens; + GlobalSettings::nexusOAuthTokens(oldTokens); + GlobalSettings::nexusApiKey(oldTokens.apiKey); + NexusOAuthTokens newTokens(tokens); + if (tokens.apiKey.isEmpty()) { + newTokens.apiKey = oldTokens.apiKey; + } + finalTokens = newTokens; + } else { + finalTokens = tokens; + } + const bool ret = GlobalSettings::setNexusOAuthTokens(finalTokens); + const bool ret2 = GlobalSettings::setNexusApiKey(finalTokens.apiKey); + if (ret && ret2) { + setTokens(finalTokens); + } +} + +void NXMAccessManager::setOAuthState(OAuthState state, const QString& message) +{ + emit updateOAuthState(state, message); +} + +QString NXMAccessManager::stateToString(OAuthState state, const QString& details) +{ + switch (state) { + case OAuthState::Initializing: + return QObject::tr("Connecting to Nexus..."); + + case OAuthState::WaitingForBrowser: + return QObject::tr("Opened Nexus in browser.") + "\n" + + QObject::tr("Switch to your browser and accept the request."); + + case OAuthState::Authorizing: + return QObject::tr("Waiting for Nexus..."); + + case OAuthState::Finished: + return QObject::tr("Finished."); + + case OAuthState::Cancelled: + return QObject::tr("Cancelled."); + + case OAuthState::Error: + default: + return details.isEmpty() ? QObject::tr("An unknown error has occurred.") : details; + } +} + +void NXMAccessManager::startValidationCheck(const NexusOAuthTokens& tokens) { - m_validationState = NotChecked; - m_validator.start(key, NexusKeyValidator::Retry); + m_ValidationState = NotChecked; + m_Validator.start(tokens, NexusKeyValidator::Retry); if (m_ProgressDialog) { // don't show the progress dialog on startup for the first attempt; the @@ -854,14 +855,14 @@ void NXMAccessManager::onValidatorFinished(ValidationAttempt::Result r, stopProgress(); if (user) { - m_validationState = Valid; + m_ValidationState = Valid; emit credentialsReceived(*user); emit validateSuccessful(true); } else { if (r == ValidationAttempt::Cancelled) { - m_validationState = NotChecked; + m_ValidationState = NotChecked; } else { - m_validationState = Invalid; + m_ValidationState = Invalid; emit validateFailed(message); } } @@ -890,11 +891,11 @@ void NXMAccessManager::onValidatorAttemptFinished(const ValidationAttempt& a) bool NXMAccessManager::validated() const { - if (m_validationState == Valid) { + if (m_ValidationState == Valid) { return true; } - if (m_validator.isActive()) { + if (m_Validator.isActive()) { const_cast<NXMAccessManager*>(this)->startProgress(); } @@ -903,40 +904,179 @@ bool NXMAccessManager::validated() const void NXMAccessManager::refuseValidation() { - m_validationState = Invalid; + m_ValidationState = Invalid; } bool NXMAccessManager::validateAttempted() const { - return (m_validationState != NotChecked); + return (m_ValidationState != NotChecked); } bool NXMAccessManager::validateWaiting() const { - return m_validator.isActive(); + return m_Validator.isActive(); } -void NXMAccessManager::apiCheck(const QString& apiKey, bool force) +void NXMAccessManager::connectOrRefresh(const NexusOAuthTokens tokens) { - if (m_validator.isActive()) { + 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); + m_NexusOAuth->setPkceMethod(QOAuth2AuthorizationCodeFlow::PkceMethod::S256); + QSet<QByteArray> scope = {"openid", "profile", "email"}; + 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( + QString("<style>\n" + " body {\n" + " text-align: center;\n" + " background-color: #2b2b2b;\n" + " color: white;\n" + " font-family: sans-serif;\n" + " font-size: 18px;\n" + " }\n" + "</style>\n" + "<img src=\"%1\" alt=\"Mod Organizer\">\n") + .arg(imageSrc) + + QObject::tr("<p><strong>Authorization complete.<br>You may close this " + "window.</strong></p>\n")); + if (!m_NexusOAuthReplyHandler->listen(QHostAddress::LocalHost, + NexusOAuth::redirectPort())) { + handleOAuthError(QObject::tr("Failed to bind to localhost on port %1.") + .arg(NexusOAuth::redirectPort())); + return; + } + if (!tokens.accessToken.isEmpty()) { + m_NexusOAuth->setToken(tokens.accessToken); + m_NexusOAuth->setRefreshToken(tokens.refreshToken); + scope.clear(); + for (const QString scopeItem : tokens.scope.split(" ")) { + scope.insert(scopeItem.toUtf8()); + } + m_NexusOAuth->setRequestedScopeTokens(scope); + + setOAuthState(OAuthState::Refreshing); + m_NexusOAuth->refreshTokens(); + } else { + setOAuthState(OAuthState::Initializing); + m_NexusOAuth->grant(); + } +} + +void NXMAccessManager::cancelAuth() +{ + if (m_NexusOAuthReplyHandler) { + m_NexusOAuthReplyHandler->close(); + } + + m_NexusOAuth.reset(); + m_NexusOAuthReplyHandler.reset(); + setOAuthState(OAuthState::Cancelled); +} + +void NXMAccessManager::addAPIHeaders(QNetworkRequest& request) +{ + request.setAttribute(QNetworkRequest::CacheSaveControlAttribute, false); + request.setAttribute(QNetworkRequest::CacheLoadControlAttribute, + QNetworkRequest::AlwaysNetwork); + request.setHeader(QNetworkRequest::KnownHeaders::UserAgentHeader, + userAgent().toUtf8()); + request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, + "application/json"); + request.setRawHeader("Protocol-Version", "1.0.0"); + request.setRawHeader("Application-Name", "MO2"); + request.setRawHeader("Application-Version", MOVersion().toUtf8()); +} + +QNetworkReply* NXMAccessManager::makeOAuthGetRequest(const QUrl url) +{ + if (!m_NexusOAuth->token().isEmpty()) { + QNetworkRequest request(url); + m_NexusOAuth->prepareRequest(&request, "GET"); + addAPIHeaders(request); + return m_NexusOAuth->networkAccessManager()->get(request); + } + return nullptr; +} + +QNetworkReply* NXMAccessManager::makeOAuthPostRequest(const QUrl url, + const QByteArray payload = {}) +{ + if (!m_NexusOAuth->token().isEmpty()) { + QNetworkRequest request(url); + m_NexusOAuth->prepareRequest(&request, "POST", payload); + addAPIHeaders(request); + return m_NexusOAuth->networkAccessManager()->post(request, payload); + } + return nullptr; +} + +QNetworkReply* NXMAccessManager::makeOAuthDeleteRequest(QNetworkRequest request) +{ + if (!m_NexusOAuth->token().isEmpty()) { + m_NexusOAuth->prepareRequest(&request, "DELETE"); + addAPIHeaders(request); + return m_NexusOAuth->networkAccessManager()->deleteResource(request); + } + return nullptr; +} + +QNetworkReply* NXMAccessManager::makeOAuthCustomRequest(QNetworkRequest request, + const QByteArray& verb, + const QByteArray& data) +{ + if (!m_NexusOAuth->token().isEmpty()) { + m_NexusOAuth->prepareRequest(&request, verb, data); + addAPIHeaders(request); + return m_NexusOAuth->networkAccessManager()->sendCustomRequest(request, verb, data); + } + return nullptr; +} + +void NXMAccessManager::apiCheck(const NexusOAuthTokens& tokens, bool force) +{ + if (m_Validator.isActive()) { + return; + } + + setTokens(tokens); if (m_Settings && m_Settings->network().offlineMode()) { - m_validationState = NotChecked; + m_ValidationState = NotChecked; return; } if (force) { - m_validationState = NotChecked; + m_ValidationState = NotChecked; } - if (m_validationState == Valid) { + if (m_ValidationState == Valid) { emit validateSuccessful(false); return; } - startValidationCheck(apiKey); + if (m_NexusOAuth->token().isEmpty() && !tokens.accessToken.isEmpty()) { + connectOrRefresh(tokens); + } else if (!tokens.apiKey.isEmpty()) { + startValidationCheck(tokens); + } } const QString& NXMAccessManager::MOVersion() const @@ -964,16 +1104,30 @@ QString NXMAccessManager::userAgent(const QString& subModule) const .arg(m_MOVersion, comments.join("; "), qVersion()); } -void NXMAccessManager::clearApiKey() +void NXMAccessManager::clearCredentials() { - m_validator.cancel(); + m_Validator.cancel(); + // TODO: Verify revocation process + // if (m_Tokens && !m_Tokens->accessToken.isEmpty()) { + // QNetworkRequest request(NexusOAuth::tokenUrl()); + // QUrlQuery params; + // params.addQueryItem("token", m_Tokens->refreshToken); + // params.addQueryItem("token_type_hint", "refresh_token"); + // m_NexusOAuth->prepareRequest(&request, "POST", + // params.toString(QUrl::FullyEncoded).toUtf8()); + // m_NexusOAuth->networkAccessManager()->post( + // request, params.toString(QUrl::FullyEncoded).toUtf8()); + //} + m_Tokens.reset(); + m_NexusOAuth->setToken(""); + m_NexusOAuthReplyHandler->close(); emit credentialsReceived(APIUserAccount()); } void NXMAccessManager::startProgress() { if (!m_ProgressDialog) { - m_ProgressDialog.reset(new ValidationProgressDialog(m_Settings, m_validator)); + m_ProgressDialog.reset(new ValidationProgressDialog(m_Settings, m_Validator)); } m_ProgressDialog->start(); diff --git a/src/nxmaccessmanager.h b/src/nxmaccessmanager.h index 79371a45..ca96deb8 100644 --- a/src/nxmaccessmanager.h +++ b/src/nxmaccessmanager.h @@ -21,6 +21,7 @@ along with Mod Organizer. If not, see <http://www.gnu.org/licenses/>. #define NXMACCESSMANAGER_H #include "apiuseraccount.h" +#include "nexusoauthtokens.h" #include "ui_validationprogressdialog.h" #include <QDialogButtonBox> #include <QElapsedTimer> @@ -28,7 +29,9 @@ along with Mod Organizer. If not, see <http://www.gnu.org/licenses/>. #include <QNetworkReply> #include <QProgressDialog> #include <QTimer> -#include <QWebSocket> +#include <QtNetworkAuth/QOAuth2AuthorizationCodeFlow> +#include <QtNetworkAuth/QOAuthHttpServerReplyHandler> +#include <optional> #include <set> namespace MOBase @@ -38,53 +41,6 @@ class IPluginGame; class NXMAccessManager; class Settings; -class NexusSSOLogin -{ -public: - enum States - { - ConnectingToSSO, - WaitingForToken, - WaitingForBrowser, - Finished, - Timeout, - ClosedByRemote, - Cancelled, - Error - }; - - std::function<void(QString)> keyChanged; - std::function<void(States, QString)> stateChanged; - - static QString stateToString(States s, const QString& e); - - NexusSSOLogin(); - - void start(); - void cancel(); - - bool isActive() const; - -private: - QWebSocket m_socket; - QString m_guid; - bool m_keyReceived; - bool m_active; - QTimer m_timeout; - - void setState(States s, const QString& error = {}); - - void close(); - void abort(); - - void onConnected(); - void onMessage(const QString& s); - void onDisconnected(); - void onError(QAbstractSocket::SocketError e); - void onSslErrors(const QList<QSslError>& errors); - void onTimeout(); -}; - class ValidationAttempt { public: @@ -104,7 +60,7 @@ public: ValidationAttempt(const ValidationAttempt&) = delete; ValidationAttempt& operator=(const ValidationAttempt&) = delete; - void start(NXMAccessManager& m, const QString& key); + void start(NXMAccessManager& m, const NexusOAuthTokens& tokens); void cancel(); bool done() const; @@ -114,13 +70,14 @@ public: QElapsedTimer elapsed() const; private: - QNetworkReply* m_reply; - Result m_result; - QString m_message; - QTimer m_timeout; - QElapsedTimer m_elapsed; + QNetworkReply* m_Reply; + Result m_Result; + QString m_Message; + QTimer m_Timeout; + QElapsedTimer m_Elapsed; + NexusOAuthTokens m_Tokens; - bool sendRequest(NXMAccessManager& m, const QString& key); + bool sendRequest(NXMAccessManager& m, const NexusOAuthTokens& tokens); void onFinished(); void onSslErrors(const QList<QSslError>& errors); @@ -150,7 +107,7 @@ public: NexusKeyValidator(Settings* s, NXMAccessManager& am); ~NexusKeyValidator(); - void start(const QString& key, Behaviour b); + void start(const NexusOAuthTokens& tokens, Behaviour b); void cancel(); bool isActive() const; @@ -158,10 +115,10 @@ public: const ValidationAttempt* currentAttempt() const; private: - Settings* m_settings; - NXMAccessManager& m_manager; - QString m_key; - std::vector<std::unique_ptr<ValidationAttempt>> m_attempts; + Settings* m_Settings; + NXMAccessManager& m_Manager; + std::optional<NexusOAuthTokens> m_Tokens; + std::vector<std::unique_ptr<ValidationAttempt>> m_Attempts; void createAttempts(const std::vector<std::chrono::seconds>& timeouts); std::vector<std::chrono::seconds> getTimeouts() const; @@ -192,10 +149,10 @@ protected: private: std::unique_ptr<Ui::ValidationProgressDialog> ui; - Settings* m_settings; - NexusKeyValidator& m_validator; - QTimer* m_updateTimer; - bool m_first; + Settings* m_Settings; + NexusKeyValidator& m_Validator; + QTimer* m_UpdateTimer; + bool m_First; void onHide(); void onCancel(); @@ -210,6 +167,17 @@ class NXMAccessManager : public QNetworkAccessManager { Q_OBJECT public: + enum class OAuthState + { + Initializing, + WaitingForBrowser, + Authorizing, + Refreshing, + Finished, + Cancelled, + Error + }; + NXMAccessManager(QObject* parent, Settings* s, const QString& moVersion); void setTopLevelWidget(QWidget* w); @@ -219,7 +187,22 @@ public: bool validateAttempted() const; bool validateWaiting() const; - void apiCheck(const QString& apiKey, bool force = false); + void connectOrRefresh(const NexusOAuthTokens tokens); + void cancelAuth(); + + QNetworkReply* makeOAuthGetRequest(const QUrl url); + QNetworkReply* makeOAuthPostRequest(const QUrl url, const QByteArray payload); + QNetworkReply* makeOAuthDeleteRequest(QNetworkRequest request); + QNetworkReply* makeOAuthCustomRequest(QNetworkRequest request, const QByteArray& verb, + const QByteArray& data); + + static QString stateToString(OAuthState state, const QString& details = {}); + + void addAPIHeaders(QNetworkRequest& request); + + void apiCheck(const NexusOAuthTokens& tokens, bool force = false); + void setTokens(const NexusOAuthTokens& tokens); + std::optional<NexusOAuthTokens> tokens() const; void showCookies() const; @@ -228,7 +211,7 @@ public: QString userAgent(const QString& subModule = QString()) const; const QString& MOVersion() const; - void clearApiKey(); + void clearCredentials(); void refuseValidation(); @@ -250,6 +233,9 @@ signals: void validateSuccessful(bool necessary); void validateFailed(const QString& message); void credentialsReceived(const APIUserAccount& user); + void authorizationEnded(); + void tokensReceived(const NexusOAuthTokens tokens); + void updateOAuthState(OAuthState state, QString message); protected: virtual QNetworkReply* createRequest(QNetworkAccessManager::Operation operation, @@ -268,10 +254,18 @@ private: Settings* m_Settings; mutable std::unique_ptr<ValidationProgressDialog> m_ProgressDialog; QString m_MOVersion; - NexusKeyValidator m_validator; - States m_validationState; + NexusKeyValidator m_Validator; + States m_ValidationState; + std::optional<NexusOAuthTokens> m_Tokens; + std::unique_ptr<QOAuth2AuthorizationCodeFlow> m_NexusOAuth; + std::unique_ptr<QOAuthHttpServerReplyHandler> m_NexusOAuthReplyHandler; + + void handleOAuthError(const QString& message); - void startValidationCheck(const QString& key); + void setOAuthState(OAuthState state, const QString& message = {}); + void notifyTokens(); + + void startValidationCheck(const NexusOAuthTokens& tokens); void onValidatorFinished(ValidationAttempt::Result r, const QString& message, std::optional<APIUserAccount>); @@ -280,6 +274,9 @@ private: void startProgress(); void stopProgress(); + +private slots: + void saveRefreshedTokens(const NexusOAuthTokens tokens); }; #endif // NXMACCESSMANAGER_H diff --git a/src/organizer_en.ts b/src/organizer_en.ts index b4d85290..abcf2fd2 100644 --- a/src/organizer_en.ts +++ b/src/organizer_en.ts @@ -710,42 +710,37 @@ p, li { white-space: pre-wrap; } <translation type="unfinished"></translation> </message> <message> - <location filename="createinstancedialog.ui" line="1103"/> - <source>Enter API Key Manually</source> - <translation type="unfinished"></translation> - </message> - <message> - <location filename="createinstancedialog.ui" line="1154"/> + <location filename="createinstancedialog.ui" line="1147"/> <source><h3>Confirmation</h3></source> <translation type="unfinished"></translation> </message> <message> - <location filename="createinstancedialog.ui" line="1161"/> + <location filename="createinstancedialog.ui" line="1154"/> <source>The instance is about to be created. Review the information below and press 'Finish'.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="createinstancedialog.ui" line="1202"/> + <location filename="createinstancedialog.ui" line="1195"/> <source>Instance creation log</source> <translation type="unfinished"></translation> </message> <message> - <location filename="createinstancedialog.ui" line="1237"/> + <location filename="createinstancedialog.ui" line="1230"/> <source>Launch the new instance</source> <translation type="unfinished"></translation> </message> <message> - <location filename="createinstancedialog.ui" line="1285"/> + <location filename="createinstancedialog.ui" line="1278"/> <source>< Back</source> <translation type="unfinished"></translation> </message> <message> - <location filename="createinstancedialog.ui" line="1292"/> + <location filename="createinstancedialog.ui" line="1285"/> <source>Next ></source> <translation type="unfinished"></translation> </message> <message> - <location filename="createinstancedialog.ui" line="1302"/> + <location filename="createinstancedialog.ui" line="1295"/> <source>Cancel</source> <translation type="unfinished"></translation> </message> @@ -1008,145 +1003,150 @@ p, li { white-space: pre-wrap; } </message> <message> <location filename="downloadlistview.cpp" line="242"/> + <source>Visit the uploader's profile</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="downloadlistview.cpp" line="246"/> <source>Open File</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadlistview.cpp" line="245"/> + <location filename="downloadlistview.cpp" line="249"/> <source>Open Meta File</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadlistview.cpp" line="248"/> - <location filename="downloadlistview.cpp" line="272"/> - <location filename="downloadlistview.cpp" line="284"/> + <location filename="downloadlistview.cpp" line="252"/> + <location filename="downloadlistview.cpp" line="276"/> + <location filename="downloadlistview.cpp" line="288"/> <source>Reveal in Explorer</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadlistview.cpp" line="254"/> - <location filename="downloadlistview.cpp" line="278"/> + <location filename="downloadlistview.cpp" line="258"/> + <location filename="downloadlistview.cpp" line="282"/> <source>Delete...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadlistview.cpp" line="258"/> + <location filename="downloadlistview.cpp" line="262"/> <source>Un-Hide</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadlistview.cpp" line="262"/> + <location filename="downloadlistview.cpp" line="266"/> <source>Hide</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadlistview.cpp" line="266"/> - <location filename="downloadlistview.cpp" line="380"/> + <location filename="downloadlistview.cpp" line="270"/> + <location filename="downloadlistview.cpp" line="384"/> <source>Cancel</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadlistview.cpp" line="269"/> + <location filename="downloadlistview.cpp" line="273"/> <source>Pause</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadlistview.cpp" line="281"/> + <location filename="downloadlistview.cpp" line="285"/> <source>Resume</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadlistview.cpp" line="296"/> + <location filename="downloadlistview.cpp" line="300"/> <source>Delete Installed Downloads...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadlistview.cpp" line="299"/> + <location filename="downloadlistview.cpp" line="303"/> <source>Delete Uninstalled Downloads...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadlistview.cpp" line="302"/> + <location filename="downloadlistview.cpp" line="306"/> <source>Delete All Downloads...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadlistview.cpp" line="308"/> + <location filename="downloadlistview.cpp" line="312"/> <source>Hide Installed...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadlistview.cpp" line="311"/> + <location filename="downloadlistview.cpp" line="315"/> <source>Hide Uninstalled...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadlistview.cpp" line="314"/> + <location filename="downloadlistview.cpp" line="318"/> <source>Hide All...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadlistview.cpp" line="318"/> + <location filename="downloadlistview.cpp" line="322"/> <source>Un-Hide All...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadlistview.cpp" line="375"/> + <location filename="downloadlistview.cpp" line="379"/> <source>Delete download</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadlistview.cpp" line="379"/> + <location filename="downloadlistview.cpp" line="383"/> <source>Move to the Recycle Bin</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadlistview.cpp" line="444"/> - <location filename="downloadlistview.cpp" line="455"/> - <location filename="downloadlistview.cpp" line="466"/> + <location filename="downloadlistview.cpp" line="453"/> + <location filename="downloadlistview.cpp" line="464"/> + <location filename="downloadlistview.cpp" line="475"/> <source>Delete Files?</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadlistview.cpp" line="445"/> + <location filename="downloadlistview.cpp" line="454"/> <source>This will remove all finished downloads from this list and from disk. Are you absolutely sure you want to proceed?</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadlistview.cpp" line="456"/> + <location filename="downloadlistview.cpp" line="465"/> <source>This will remove all installed downloads from this list and from disk. Are you absolutely sure you want to proceed?</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadlistview.cpp" line="467"/> + <location filename="downloadlistview.cpp" line="476"/> <source>This will remove all uninstalled downloads from this list and from disk. Are you absolutely sure you want to proceed?</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadlistview.cpp" line="476"/> - <location filename="downloadlistview.cpp" line="486"/> - <location filename="downloadlistview.cpp" line="496"/> + <location filename="downloadlistview.cpp" line="485"/> + <location filename="downloadlistview.cpp" line="495"/> + <location filename="downloadlistview.cpp" line="505"/> <source>Hide Files?</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadlistview.cpp" line="477"/> + <location filename="downloadlistview.cpp" line="486"/> <source>This will remove all finished downloads from this list (but NOT from disk).</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadlistview.cpp" line="487"/> + <location filename="downloadlistview.cpp" line="496"/> <source>This will remove all installed downloads from this list (but NOT from disk).</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadlistview.cpp" line="497"/> + <location filename="downloadlistview.cpp" line="506"/> <source>This will remove all uninstalled downloads from this list (but NOT from disk).</source> <translation type="unfinished"></translation> </message> @@ -1154,22 +1154,22 @@ Are you absolutely sure you want to proceed?</source> <context> <name>DownloadManager</name> <message> - <location filename="downloadmanager.cpp" line="198"/> + <location filename="downloadmanager.cpp" line="201"/> <source>failed to rename "%1" to "%2"</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="440"/> + <location filename="downloadmanager.cpp" line="443"/> <source>Memory allocation error (in refreshing directory).</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="455"/> + <location filename="downloadmanager.cpp" line="458"/> <source>Query Metadata</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="456"/> + <location filename="downloadmanager.cpp" line="459"/> <source>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.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="606"/> + <location filename="downloadmanager.cpp" line="609"/> <source>failed to download %1: could not open output file: %2</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="635"/> + <location filename="downloadmanager.cpp" line="638"/> <source>Download again?</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="636"/> + <location filename="downloadmanager.cpp" line="639"/> <source>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.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="703"/> + <location filename="downloadmanager.cpp" line="706"/> <source>Wrong Game</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="704"/> + <location filename="downloadmanager.cpp" line="707"/> <source>The download link is for a mod for "%1" but this instance of MO has been set up for "%2".</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="718"/> + <location filename="downloadmanager.cpp" line="721"/> <source>There is already a download queued for this file. Mod %1 @@ -1210,12 +1210,12 @@ File %2</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="725"/> + <location filename="downloadmanager.cpp" line="728"/> <source>Already Queued</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="738"/> + <location filename="downloadmanager.cpp" line="741"/> <source>There is already a download started for this file. Mod %1: %2 @@ -1223,287 +1223,297 @@ File %3: %4</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="774"/> + <location filename="downloadmanager.cpp" line="777"/> <source>Already Started</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="806"/> - <location filename="downloadmanager.cpp" line="936"/> + <location filename="downloadmanager.cpp" line="809"/> + <location filename="downloadmanager.cpp" line="939"/> <source>remove: invalid download index %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="824"/> + <location filename="downloadmanager.cpp" line="827"/> <source>failed to delete %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="830"/> + <location filename="downloadmanager.cpp" line="833"/> <source>failed to delete meta file for %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="889"/> + <location filename="downloadmanager.cpp" line="892"/> <source>restore: invalid download index: %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="955"/> + <location filename="downloadmanager.cpp" line="958"/> <source>cancel: invalid download index %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="967"/> + <location filename="downloadmanager.cpp" line="970"/> <source>pause: invalid download index %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="989"/> + <location filename="downloadmanager.cpp" line="992"/> <source>resume: invalid download index %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="1000"/> + <location filename="downloadmanager.cpp" line="1003"/> <source>resume (int): invalid download index %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="1025"/> + <location filename="downloadmanager.cpp" line="1028"/> <source>No known download urls. Sorry, this download can't be resumed.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="1068"/> - <location filename="downloadmanager.cpp" line="1133"/> + <location filename="downloadmanager.cpp" line="1071"/> + <location filename="downloadmanager.cpp" line="1136"/> <source>query: invalid download index %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="1090"/> + <location filename="downloadmanager.cpp" line="1093"/> <source>Please enter the Nexus mod ID</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="1091"/> + <location filename="downloadmanager.cpp" line="1094"/> <source>Mod ID:</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="1109"/> + <location filename="downloadmanager.cpp" line="1112"/> <source>Please select the source game code for %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="1167"/> + <location filename="downloadmanager.cpp" line="1170"/> <source>Hashing download file '%1'</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="1168"/> + <location filename="downloadmanager.cpp" line="1171"/> <source>Cancel</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="1196"/> + <location filename="downloadmanager.cpp" line="1199"/> <source>VisitNexus: invalid download index %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="1216"/> + <location filename="downloadmanager.cpp" line="1219"/> <source>Nexus ID for this Mod is unknown</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="1223"/> + <location filename="downloadmanager.cpp" line="1226"/> + <source>VisitUploaderProfile: invalid download index %1</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="downloadmanager.cpp" line="1240"/> + <source>Uploader for this Mod is unknown</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="downloadmanager.cpp" line="1247"/> <source>OpenFile: invalid download index %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="1267"/> + <location filename="downloadmanager.cpp" line="1291"/> <source>OpenFileInDownloadsFolder: invalid download index %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="1300"/> + <location filename="downloadmanager.cpp" line="1324"/> <source>get pending: invalid download index %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="1309"/> + <location filename="downloadmanager.cpp" line="1333"/> <source>get path: invalid download index %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="1319"/> + <location filename="downloadmanager.cpp" line="1343"/> <source>Main</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="1321"/> + <location filename="downloadmanager.cpp" line="1345"/> <source>Update</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="1323"/> + <location filename="downloadmanager.cpp" line="1347"/> <source>Optional</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="1325"/> + <location filename="downloadmanager.cpp" line="1349"/> <source>Old</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="1327"/> + <location filename="downloadmanager.cpp" line="1351"/> <source>Miscellaneous</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="1329"/> + <location filename="downloadmanager.cpp" line="1353"/> <source>Deleted</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="1331"/> + <location filename="downloadmanager.cpp" line="1355"/> <source>Archived</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="1333"/> + <location filename="downloadmanager.cpp" line="1357"/> <source>Unknown</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="1340"/> + <location filename="downloadmanager.cpp" line="1364"/> <source>display name: invalid download index %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="1361"/> + <location filename="downloadmanager.cpp" line="1385"/> <source>file name: invalid download index %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="1370"/> + <location filename="downloadmanager.cpp" line="1394"/> <source>file time: invalid download index %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="1389"/> + <location filename="downloadmanager.cpp" line="1413"/> <source>file size: invalid download index %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="1398"/> + <location filename="downloadmanager.cpp" line="1422"/> <source>progress: invalid download index %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="1407"/> + <location filename="downloadmanager.cpp" line="1431"/> <source>state: invalid download index %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="1416"/> + <location filename="downloadmanager.cpp" line="1440"/> <source>infocomplete: invalid download index %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="1430"/> - <location filename="downloadmanager.cpp" line="1438"/> - <location filename="downloadmanager.cpp" line="1451"/> + <location filename="downloadmanager.cpp" line="1454"/> + <location filename="downloadmanager.cpp" line="1462"/> + <location filename="downloadmanager.cpp" line="1475"/> <source>mod id: invalid download index %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="1459"/> + <location filename="downloadmanager.cpp" line="1483"/> <source>ishidden: invalid download index %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="1467"/> + <location filename="downloadmanager.cpp" line="1491"/> <source>file info: invalid download index %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="1476"/> + <location filename="downloadmanager.cpp" line="1500"/> <source>mark installed: invalid download index %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="1517"/> + <location filename="downloadmanager.cpp" line="1541"/> <source>mark uninstalled: invalid download index %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="1682"/> + <location filename="downloadmanager.cpp" line="1706"/> <source>%1% - %2 - ~%3</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="1693"/> + <location filename="downloadmanager.cpp" line="1717"/> <source>Memory allocation error (in processing progress event).</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="1702"/> + <location filename="downloadmanager.cpp" line="1726"/> <source>Memory allocation error (in processing downloaded data).</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="1823"/> + <location filename="downloadmanager.cpp" line="1856"/> <source>Information updated</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="1825"/> - <location filename="downloadmanager.cpp" line="1848"/> + <location filename="downloadmanager.cpp" line="1858"/> + <location filename="downloadmanager.cpp" line="1881"/> <source>No matching file found on Nexus! Maybe this file is no longer available or it was renamed?</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="1828"/> + <location filename="downloadmanager.cpp" line="1861"/> <source>No file on Nexus matches the selected file by name. Please manually choose the correct one.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="2024"/> + <location filename="downloadmanager.cpp" line="2057"/> <source>No download server available. Please try again later.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="2199"/> + <location filename="downloadmanager.cpp" line="2235"/> <source>Failed to request file info from nexus: %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="2231"/> + <location filename="downloadmanager.cpp" line="2267"/> <source>Warning: Content type is: %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="2238"/> + <location filename="downloadmanager.cpp" line="2274"/> <source>Download header content length: %1 downloaded file size: %2</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="2242"/> + <location filename="downloadmanager.cpp" line="2278"/> <source>Download failed: %1 (%2)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="2267"/> + <location filename="downloadmanager.cpp" line="2303"/> <source>We were unable to download the file due to errors after four retries. There may be an issue with the Nexus servers.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="2358"/> + <location filename="downloadmanager.cpp" line="2394"/> <source>failed to re-open %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadmanager.cpp" line="2406"/> + <location filename="downloadmanager.cpp" line="2442"/> <source>Unable to write download to drive (return %1). Check the drive's available storage. @@ -1514,12 +1524,12 @@ Canceling download "%2"...</source> <context> <name>DownloadsTab</name> <message> - <location filename="downloadstab.cpp" line="90"/> + <location filename="downloadstab.cpp" line="92"/> <source>Query Metadata</source> <translation type="unfinished"></translation> </message> <message> - <location filename="downloadstab.cpp" line="91"/> + <location filename="downloadstab.cpp" line="93"/> <source>Cannot query metadata while offline mode is enabled. Do you want to disable offline mode?</source> <translation type="unfinished"></translation> </message> @@ -3896,120 +3906,120 @@ You will have to visit the mod page on the %1 Nexus site to change your mind.</s <translation type="unfinished"></translation> </message> <message> - <location filename="mainwindow.cpp" line="3400"/> + <location filename="mainwindow.cpp" line="3406"/> <source>Thank you!</source> <translation type="unfinished"></translation> </message> <message> - <location filename="mainwindow.cpp" line="3401"/> + <location filename="mainwindow.cpp" line="3407"/> <source>Thank you for your endorsement!</source> <translation type="unfinished"></translation> </message> <message> - <location filename="mainwindow.cpp" line="3515"/> + <location filename="mainwindow.cpp" line="3521"/> <source>Mod ID %1 no longer seems to be available on Nexus.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="mainwindow.cpp" line="3533"/> + <location filename="mainwindow.cpp" line="3539"/> <source>Error %1: Request to Nexus failed: %2</source> <translation type="unfinished"></translation> </message> <message> - <location filename="mainwindow.cpp" line="3550"/> - <location filename="mainwindow.cpp" line="3623"/> + <location filename="mainwindow.cpp" line="3556"/> + <location filename="mainwindow.cpp" line="3629"/> <source>failed to read %1: %2</source> <translation type="unfinished"></translation> </message> <message> - <location filename="mainwindow.cpp" line="3563"/> + <location filename="mainwindow.cpp" line="3569"/> <source>Error</source> <translation type="unfinished"></translation> </message> <message> - <location filename="mainwindow.cpp" line="3564"/> + <location filename="mainwindow.cpp" line="3570"/> <source>failed to extract %1 (errorcode %2)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="mainwindow.cpp" line="3598"/> + <location filename="mainwindow.cpp" line="3604"/> <source>Extract BSA</source> <translation type="unfinished"></translation> </message> <message> - <location filename="mainwindow.cpp" line="3637"/> + <location filename="mainwindow.cpp" line="3643"/> <source>This archive contains invalid hashes. Some files may be broken.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="mainwindow.cpp" line="3647"/> + <location filename="mainwindow.cpp" line="3653"/> <source>Extract...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="mainwindow.cpp" line="3716"/> + <location filename="mainwindow.cpp" line="3722"/> <source>Remove '%1' from the toolbar</source> <translation type="unfinished"></translation> </message> <message> - <location filename="mainwindow.cpp" line="3772"/> + <location filename="mainwindow.cpp" line="3778"/> <source>Backup of load order created</source> <translation type="unfinished"></translation> </message> <message> - <location filename="mainwindow.cpp" line="3783"/> + <location filename="mainwindow.cpp" line="3789"/> <source>Choose backup to restore</source> <translation type="unfinished"></translation> </message> <message> - <location filename="mainwindow.cpp" line="3800"/> + <location filename="mainwindow.cpp" line="3806"/> <source>This file might be left over following a crash or power loss event. Check its contents before restoring.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="mainwindow.cpp" line="3809"/> + <location filename="mainwindow.cpp" line="3815"/> <source>No Backups</source> <translation type="unfinished"></translation> </message> <message> - <location filename="mainwindow.cpp" line="3810"/> + <location filename="mainwindow.cpp" line="3816"/> <source>There are no backups to restore</source> <translation type="unfinished"></translation> </message> <message> - <location filename="mainwindow.cpp" line="3834"/> - <location filename="mainwindow.cpp" line="3858"/> + <location filename="mainwindow.cpp" line="3840"/> + <location filename="mainwindow.cpp" line="3864"/> <source>Restore failed</source> <translation type="unfinished"></translation> </message> <message> - <location filename="mainwindow.cpp" line="3835"/> - <location filename="mainwindow.cpp" line="3859"/> + <location filename="mainwindow.cpp" line="3841"/> + <location filename="mainwindow.cpp" line="3865"/> <source>Failed to restore the backup. Errorcode: %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="mainwindow.cpp" line="3847"/> + <location filename="mainwindow.cpp" line="3853"/> <source>Backup of mod list created</source> <translation type="unfinished"></translation> </message> <message> - <location filename="mainwindow.cpp" line="3927"/> + <location filename="mainwindow.cpp" line="3933"/> <source>A file with the same name has already been downloaded. What would you like to do?</source> <translation type="unfinished"></translation> </message> <message> - <location filename="mainwindow.cpp" line="3929"/> + <location filename="mainwindow.cpp" line="3935"/> <source>Overwrite</source> <translation type="unfinished"></translation> </message> <message> - <location filename="mainwindow.cpp" line="3930"/> + <location filename="mainwindow.cpp" line="3936"/> <source>Rename new file</source> <translation type="unfinished"></translation> </message> <message> - <location filename="mainwindow.cpp" line="3931"/> + <location filename="mainwindow.cpp" line="3937"/> <source>Ignore file</source> <translation type="unfinished"></translation> </message> @@ -4478,12 +4488,12 @@ p, li { white-space: pre-wrap; } <context> <name>ModInfoRegular</name> <message> - <location filename="modinforegular.cpp" line="736"/> + <location filename="modinforegular.cpp" line="742"/> <source>%1 contains no esp/esm/esl and no asset (textures, meshes, interface, ...) directory</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modinforegular.cpp" line="742"/> + <location filename="modinforegular.cpp" line="748"/> <source>Categories: <br></source> <translation type="unfinished"></translation> </message> @@ -4584,178 +4594,198 @@ p, li { white-space: pre-wrap; } <translation type="unfinished"></translation> </message> <message> - <location filename="modlist.cpp" line="263"/> + <location filename="modlist.cpp" line="267"/> <source>invalid</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlist.cpp" line="409"/> + <location filename="modlist.cpp" line="413"/> <source>installed version: "%1", newest version: "%2"</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlist.cpp" line="414"/> + <location filename="modlist.cpp" line="418"/> <source>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".</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlist.cpp" line="422"/> + <location filename="modlist.cpp" line="426"/> <source>This file has been marked as "Old". There is most likely an updated version of this file available.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlist.cpp" line="430"/> + <location filename="modlist.cpp" line="434"/> <source>This file has been marked as "Deleted"! You may want to check for an update or remove the nexus ID from this mod!</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlist.cpp" line="440"/> + <location filename="modlist.cpp" line="444"/> <source>%1 minute(s) and %2 second(s)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlist.cpp" line="442"/> + <location filename="modlist.cpp" line="446"/> <source>This mod will be available to check in %2.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlist.cpp" line="449"/> + <location filename="modlist.cpp" line="453"/> <source>Categories: <br></source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlist.cpp" line="482"/> + <location filename="modlist.cpp" line="486"/> <source>Invalid name</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlist.cpp" line="488"/> + <location filename="modlist.cpp" line="492"/> <source>Name is already in use by another mod</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlist.cpp" line="1225"/> + <location filename="modlist.cpp" line="1229"/> <source>Confirm</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlist.cpp" line="1226"/> + <location filename="modlist.cpp" line="1230"/> <source>Are you sure you want to remove "%1"?</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlist.cpp" line="1307"/> + <location filename="modlist.cpp" line="1311"/> <source>Conflicts</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlist.cpp" line="1309"/> + <location filename="modlist.cpp" line="1313"/> <source>Flags</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlist.cpp" line="1311"/> + <location filename="modlist.cpp" line="1315"/> <source>Content</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlist.cpp" line="1313"/> + <location filename="modlist.cpp" line="1317"/> <source>Mod Name</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlist.cpp" line="1315"/> + <location filename="modlist.cpp" line="1319"/> <source>Version</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlist.cpp" line="1317"/> + <location filename="modlist.cpp" line="1321"/> <source>Priority</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlist.cpp" line="1319"/> + <location filename="modlist.cpp" line="1323"/> <source>Category</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlist.cpp" line="1321"/> + <location filename="modlist.cpp" line="1325"/> + <source>Author</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="modlist.cpp" line="1327"/> + <source>Uploader</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="modlist.cpp" line="1329"/> <source>Source Game</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlist.cpp" line="1323"/> + <location filename="modlist.cpp" line="1331"/> <source>Nexus ID</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlist.cpp" line="1325"/> + <location filename="modlist.cpp" line="1333"/> <source>Installation</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlist.cpp" line="1327"/> + <location filename="modlist.cpp" line="1335"/> <source>Notes</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlist.cpp" line="1329"/> - <location filename="modlist.cpp" line="1373"/> + <location filename="modlist.cpp" line="1337"/> + <location filename="modlist.cpp" line="1385"/> <source>unknown</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlist.cpp" line="1337"/> + <location filename="modlist.cpp" line="1345"/> <source>Name of your mods</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlist.cpp" line="1339"/> + <location filename="modlist.cpp" line="1347"/> <source>Version of the mod (if available)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlist.cpp" line="1341"/> + <location filename="modlist.cpp" line="1349"/> <source>Installation priority of your mod. The higher, the more "important" it is and thus overwrites files from mods with lower priority.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlist.cpp" line="1345"/> + <location filename="modlist.cpp" line="1353"/> <source>Primary category of the mod.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlist.cpp" line="1347"/> + <location filename="modlist.cpp" line="1355"/> + <source>Author(s) of the mod.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="modlist.cpp" line="1357"/> + <source>Uploader of the mod. This is not necessarily the same as the author.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="modlist.cpp" line="1359"/> <source>The source game which was the origin of this mod.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlist.cpp" line="1349"/> + <location filename="modlist.cpp" line="1361"/> <source>Id of the mod as used on Nexus.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlist.cpp" line="1351"/> + <location filename="modlist.cpp" line="1363"/> <source>Indicators of file conflicts between mods.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlist.cpp" line="1353"/> + <location filename="modlist.cpp" line="1365"/> <source>Emblems to highlight things that might require attention.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlist.cpp" line="1360"/> + <location filename="modlist.cpp" line="1372"/> <source>Depicts the content of the mod:</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlist.cpp" line="1369"/> + <location filename="modlist.cpp" line="1381"/> <source>Time this mod was installed</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlist.cpp" line="1371"/> + <location filename="modlist.cpp" line="1383"/> <source>User notes about the mod</source> <translation type="unfinished"></translation> </message> @@ -4852,8 +4882,8 @@ p, li { white-space: pre-wrap; } </message> <message> <location filename="modlistcontextmenu.cpp" line="376"/> - <location filename="modlistcontextmenu.cpp" line="453"/> - <location filename="modlistcontextmenu.cpp" line="622"/> + <location filename="modlistcontextmenu.cpp" line="459"/> + <location filename="modlistcontextmenu.cpp" line="634"/> <source>Open in Explorer</source> <translation type="unfinished"></translation> </message> @@ -4869,13 +4899,13 @@ p, li { white-space: pre-wrap; } </message> <message> <location filename="modlistcontextmenu.cpp" line="398"/> - <location filename="modlistcontextmenu.cpp" line="525"/> + <location filename="modlistcontextmenu.cpp" line="531"/> <source>Select Color...</source> <translation type="unfinished"></translation> </message> <message> <location filename="modlistcontextmenu.cpp" line="403"/> - <location filename="modlistcontextmenu.cpp" line="529"/> + <location filename="modlistcontextmenu.cpp" line="535"/> <source>Reset Color</source> <translation type="unfinished"></translation> </message> @@ -4891,121 +4921,127 @@ p, li { white-space: pre-wrap; } </message> <message> <location filename="modlistcontextmenu.cpp" line="429"/> - <location filename="modlistcontextmenu.cpp" line="595"/> + <location filename="modlistcontextmenu.cpp" line="601"/> <source>Ignore missing data</source> <translation type="unfinished"></translation> </message> <message> <location filename="modlistcontextmenu.cpp" line="435"/> - <location filename="modlistcontextmenu.cpp" line="602"/> + <location filename="modlistcontextmenu.cpp" line="608"/> <source>Mark as converted/working</source> <translation type="unfinished"></translation> </message> <message> <location filename="modlistcontextmenu.cpp" line="441"/> - <location filename="modlistcontextmenu.cpp" line="610"/> + <location filename="modlistcontextmenu.cpp" line="616"/> <source>Visit on Nexus</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistcontextmenu.cpp" line="448"/> - <location filename="modlistcontextmenu.cpp" line="617"/> + <location filename="modlistcontextmenu.cpp" line="447"/> + <location filename="modlistcontextmenu.cpp" line="622"/> + <source>Visit the uploader's profile</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="modlistcontextmenu.cpp" line="454"/> + <location filename="modlistcontextmenu.cpp" line="629"/> <source>Visit on %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistcontextmenu.cpp" line="466"/> + <location filename="modlistcontextmenu.cpp" line="472"/> <source>Change versioning scheme</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistcontextmenu.cpp" line="472"/> + <location filename="modlistcontextmenu.cpp" line="478"/> <source>Force-check updates</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistcontextmenu.cpp" line="476"/> + <location filename="modlistcontextmenu.cpp" line="482"/> <source>Un-ignore update</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistcontextmenu.cpp" line="481"/> + <location filename="modlistcontextmenu.cpp" line="487"/> <source>Ignore update</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistcontextmenu.cpp" line="488"/> + <location filename="modlistcontextmenu.cpp" line="494"/> <source>Enable selected</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistcontextmenu.cpp" line="491"/> + <location filename="modlistcontextmenu.cpp" line="497"/> <source>Disable selected</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistcontextmenu.cpp" line="502"/> + <location filename="modlistcontextmenu.cpp" line="508"/> <source>Rename Mod...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistcontextmenu.cpp" line="505"/> + <location filename="modlistcontextmenu.cpp" line="511"/> <source>Reinstall Mod</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistcontextmenu.cpp" line="508"/> + <location filename="modlistcontextmenu.cpp" line="514"/> <source>Remove Mod...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistcontextmenu.cpp" line="511"/> + <location filename="modlistcontextmenu.cpp" line="517"/> <source>Create Backup</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistcontextmenu.cpp" line="517"/> + <location filename="modlistcontextmenu.cpp" line="523"/> <source>Restore hidden files</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistcontextmenu.cpp" line="539"/> + <location filename="modlistcontextmenu.cpp" line="545"/> <source>Un-Endorse</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistcontextmenu.cpp" line="544"/> - <location filename="modlistcontextmenu.cpp" line="552"/> + <location filename="modlistcontextmenu.cpp" line="550"/> + <location filename="modlistcontextmenu.cpp" line="558"/> <source>Endorse</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistcontextmenu.cpp" line="547"/> + <location filename="modlistcontextmenu.cpp" line="553"/> <source>Won't endorse</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistcontextmenu.cpp" line="557"/> + <location filename="modlistcontextmenu.cpp" line="563"/> <source>Endorsement state unknown</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistcontextmenu.cpp" line="567"/> + <location filename="modlistcontextmenu.cpp" line="573"/> <source>Remap Category (From Nexus)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistcontextmenu.cpp" line="575"/> + <location filename="modlistcontextmenu.cpp" line="581"/> <source>Start tracking</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistcontextmenu.cpp" line="580"/> + <location filename="modlistcontextmenu.cpp" line="586"/> <source>Stop tracking</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistcontextmenu.cpp" line="585"/> + <location filename="modlistcontextmenu.cpp" line="591"/> <source>Tracked state unknown</source> <translation type="unfinished"></translation> </message> @@ -5124,7 +5160,7 @@ p, li { white-space: pre-wrap; } <context> <name>ModListSortProxy</name> <message> - <location filename="modlistsortproxy.cpp" line="629"/> + <location filename="modlistsortproxy.cpp" line="661"/> <source>Drag&Drop is only supported when sorting by priority</source> <translation type="unfinished"></translation> </message> @@ -5153,17 +5189,17 @@ Please enter the name:</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistview.cpp" line="974"/> + <location filename="modlistview.cpp" line="975"/> <source>Exception: </source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistview.cpp" line="976"/> + <location filename="modlistview.cpp" line="977"/> <source>Unknown exception</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistview.cpp" line="1342"/> + <location filename="modlistview.cpp" line="1343"/> <source><Multiple></source> <translation type="unfinished"></translation> </message> @@ -5182,7 +5218,7 @@ Please enter the name:</source> </message> <message> <location filename="modlistviewactions.cpp" line="120"/> - <location filename="modlistviewactions.cpp" line="1360"/> + <location filename="modlistviewactions.cpp" line="1400"/> <source>Create Mod...</source> <translation type="unfinished"></translation> </message> @@ -5194,7 +5230,7 @@ Please enter a name:</source> </message> <message> <location filename="modlistviewactions.cpp" line="131"/> - <location filename="modlistviewactions.cpp" line="1371"/> + <location filename="modlistviewactions.cpp" line="1411"/> <source>A mod with this name already exists</source> <translation type="unfinished"></translation> </message> @@ -5226,8 +5262,8 @@ Please enter a name:</source> </message> <message> <location filename="modlistviewactions.cpp" line="217"/> - <location filename="modlistviewactions.cpp" line="795"/> - <location filename="modlistviewactions.cpp" line="1044"/> + <location filename="modlistviewactions.cpp" line="816"/> + <location filename="modlistviewactions.cpp" line="1084"/> <source>Confirm</source> <translation type="unfinished"></translation> </message> @@ -5239,8 +5275,8 @@ Please enter a name:</source> </message> <message> <location filename="modlistviewactions.cpp" line="267"/> - <location filename="modlistviewactions.cpp" line="1078"/> - <location filename="modlistviewactions.cpp" line="1430"/> + <location filename="modlistviewactions.cpp" line="1118"/> + <location filename="modlistviewactions.cpp" line="1470"/> <source>Are you sure?</source> <translation type="unfinished"></translation> </message> @@ -5317,178 +5353,197 @@ You can also use online editors and converters instead.</source> </message> <message> <location filename="modlistviewactions.cpp" line="381"/> - <source>Nexus_ID</source> + <source>Mod_Author</source> <translation type="unfinished"></translation> </message> <message> <location filename="modlistviewactions.cpp" line="382"/> - <source>Mod_Nexus_URL</source> + <source>Mod_Uploader</source> <translation type="unfinished"></translation> </message> <message> <location filename="modlistviewactions.cpp" line="383"/> - <source>Mod_Version</source> + <source>Nexus_ID</source> <translation type="unfinished"></translation> </message> <message> <location filename="modlistviewactions.cpp" line="384"/> - <source>Install_Date</source> + <source>Mod_Nexus_URL</source> <translation type="unfinished"></translation> </message> <message> <location filename="modlistviewactions.cpp" line="385"/> + <source>Mod_Uploader_URL</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="modlistviewactions.cpp" line="386"/> + <source>Mod_Version</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="modlistviewactions.cpp" line="387"/> + <source>Install_Date</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="modlistviewactions.cpp" line="388"/> <source>Download_File_Name</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistviewactions.cpp" line="512"/> + <location filename="modlistviewactions.cpp" line="533"/> <source>export failed: %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistviewactions.cpp" line="566"/> + <location filename="modlistviewactions.cpp" line="587"/> <source>Failed to display overwrite dialog: %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistviewactions.cpp" line="629"/> + <location filename="modlistviewactions.cpp" line="650"/> <source>Set Priority</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistviewactions.cpp" line="630"/> + <location filename="modlistviewactions.cpp" line="651"/> <source>Set the priority of the selected mods</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistviewactions.cpp" line="760"/> + <location filename="modlistviewactions.cpp" line="781"/> <source>failed to rename mod: %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistviewactions.cpp" line="796"/> + <location filename="modlistviewactions.cpp" line="817"/> <source>Remove the following mods?<br><ul>%1</ul></source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistviewactions.cpp" line="813"/> + <location filename="modlistviewactions.cpp" line="834"/> <source>failed to remove mod: %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistviewactions.cpp" line="841"/> + <location filename="modlistviewactions.cpp" line="862"/> <source>Continue?</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistviewactions.cpp" line="842"/> + <location filename="modlistviewactions.cpp" line="863"/> <source>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.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistviewactions.cpp" line="868"/> + <location filename="modlistviewactions.cpp" line="889"/> <source>Sorry</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistviewactions.cpp" line="869"/> + <location filename="modlistviewactions.cpp" line="890"/> <source>I don't know a versioning scheme where %1 is newer than %2.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistviewactions.cpp" line="890"/> - <source>Opening Nexus Links</source> + <location filename="modlistviewactions.cpp" line="911"/> + <source>Nexus Links</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistviewactions.cpp" line="891"/> - <source>You are trying to open %1 links to Nexus Mods. Are you sure you want to do this?</source> + <location filename="modlistviewactions.cpp" line="931"/> + <location filename="modlistviewactions.cpp" line="949"/> + <source>Web Pages</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistviewactions.cpp" line="914"/> - <location filename="modlistviewactions.cpp" line="936"/> - <source>Opening Web Pages</source> + <location filename="modlistviewactions.cpp" line="978"/> + <source>Uploader Profiles</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistviewactions.cpp" line="915"/> - <location filename="modlistviewactions.cpp" line="937"/> - <source>You are trying to open %1 Web Pages. Are you sure you want to do this?</source> + <location filename="modlistviewactions.cpp" line="998"/> + <source>Opening %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistviewactions.cpp" line="987"/> - <location filename="modlistviewactions.cpp" line="992"/> - <location filename="modlistviewactions.cpp" line="1003"/> + <location filename="modlistviewactions.cpp" line="999"/> + <source>You are trying to open %1 %2. Are you sure you want to do this?</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="modlistviewactions.cpp" line="1027"/> + <location filename="modlistviewactions.cpp" line="1032"/> + <location filename="modlistviewactions.cpp" line="1043"/> <source>Failed</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistviewactions.cpp" line="988"/> + <location filename="modlistviewactions.cpp" line="1028"/> <source>Installation file no longer exists</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistviewactions.cpp" line="993"/> + <location filename="modlistviewactions.cpp" line="1033"/> <source>Mods installed with old versions of MO can't be reinstalled in this way.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistviewactions.cpp" line="1003"/> + <location filename="modlistviewactions.cpp" line="1043"/> <source>Failed to create backup.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistviewactions.cpp" line="1045"/> + <location filename="modlistviewactions.cpp" line="1085"/> <source>Restore all hidden files in the following mods?<br><ul>%1</ul></source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistviewactions.cpp" line="1079"/> + <location filename="modlistviewactions.cpp" line="1119"/> <source>About to restore all hidden files in: </source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistviewactions.cpp" line="1112"/> + <location filename="modlistviewactions.cpp" line="1152"/> <source>Endorsing multiple mods will take a while. Please wait...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistviewactions.cpp" line="1279"/> + <location filename="modlistviewactions.cpp" line="1319"/> <source>Overwrite?</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistviewactions.cpp" line="1280"/> + <location filename="modlistviewactions.cpp" line="1320"/> <source>This will replace the existing mod "%1". Continue?</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistviewactions.cpp" line="1284"/> + <location filename="modlistviewactions.cpp" line="1324"/> <source>failed to remove mod "%1"</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistviewactions.cpp" line="1290"/> + <location filename="modlistviewactions.cpp" line="1330"/> <source>failed to rename "%1" to "%2"</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistviewactions.cpp" line="1342"/> + <location filename="modlistviewactions.cpp" line="1382"/> <source>Move successful.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistviewactions.cpp" line="1361"/> + <location filename="modlistviewactions.cpp" line="1401"/> <source>This will move all files from overwrite into a new, regular mod. Please enter a name:</source> <translation type="unfinished"></translation> </message> <message> - <location filename="modlistviewactions.cpp" line="1431"/> + <location filename="modlistviewactions.cpp" line="1471"/> <source>About to recursively delete: </source> <translation type="unfinished"></translation> @@ -5510,123 +5565,81 @@ Please enter a name:</source> <context> <name>NexusConnectionUI</name> <message> - <location filename="settingsdialognexus.cpp" line="104"/> + <location filename="settingsdialognexus.cpp" line="49"/> <source>Connected.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialognexus.cpp" line="106"/> + <location filename="settingsdialognexus.cpp" line="51"/> <source>Not connected.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialognexus.cpp" line="162"/> + <location filename="settingsdialognexus.cpp" line="86"/> <source>Disconnected.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialognexus.cpp" line="176"/> - <source>Checking API key...</source> + <location filename="settingsdialognexus.cpp" line="100"/> + <source>Authorizing with Nexus...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialognexus.cpp" line="185"/> - <source>Received API key.</source> + <location filename="settingsdialognexus.cpp" line="107"/> + <source>Received authorization from Nexus.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialognexus.cpp" line="210"/> + <location filename="settingsdialognexus.cpp" line="131"/> <source>Received user account information</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialognexus.cpp" line="213"/> + <location filename="settingsdialognexus.cpp" line="135"/> + <location filename="settingsdialognexus.cpp" line="140"/> <source>Linked with Nexus successfully.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialognexus.cpp" line="215"/> - <source>Failed to set API key</source> + <location filename="settingsdialognexus.cpp" line="137"/> + <source>Failed to store OAuth tokens.</source> <translation type="unfinished"></translation> </message> </context> <context> <name>NexusInterface</name> <message> - <location filename="nexusinterface.cpp" line="357"/> + <location filename="nexusinterface.cpp" line="360"/> <source>Please pick the mod ID for "%1"</source> <translation type="unfinished"></translation> </message> <message> - <location filename="nexusinterface.cpp" line="848"/> + <location filename="nexusinterface.cpp" line="854"/> <source>You must authorize MO2 in Settings -> Nexus to use the Nexus API.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="nexusinterface.cpp" line="857"/> + <location filename="nexusinterface.cpp" line="863"/> <source>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.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="nexusinterface.cpp" line="938"/> + <location filename="nexusinterface.cpp" line="944"/> <source>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.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="nexusinterface.cpp" line="1079"/> + <location filename="nexusinterface.cpp" line="1099"/> <source>empty response</source> <translation type="unfinished"></translation> </message> <message> - <location filename="nexusinterface.cpp" line="1159"/> + <location filename="nexusinterface.cpp" line="1179"/> <source>invalid response</source> <translation type="unfinished"></translation> </message> </context> <context> - <name>NexusManualKeyDialog</name> - <message> - <location filename="nexusmanualkey.ui" line="14"/> - <source>Manual Nexus API Key</source> - <translation type="unfinished"></translation> - </message> - <message> - <location filename="nexusmanualkey.ui" line="50"/> - <source>1. Get your personal API key</source> - <translation type="unfinished"></translation> - </message> - <message> - <location filename="nexusmanualkey.ui" line="57"/> - <source>Open Browser</source> - <translation type="unfinished"></translation> - </message> - <message> - <location filename="nexusmanualkey.ui" line="97"/> - <source>2. Enter your personal API key</source> - <translation type="unfinished"></translation> - </message> - <message> - <location filename="nexusmanualkey.ui" line="117"/> - <source>Paste</source> - <translation type="unfinished"></translation> - </message> - <message> - <location filename="nexusmanualkey.ui" line="124"/> - <source>Clear</source> - <translation type="unfinished"></translation> - </message> - <message> - <location filename="nexusmanualkey.ui" line="146"/> - <source>Enter API key here</source> - <translation type="unfinished"></translation> - </message> - <message> - <location filename="nexusmanualkey.ui" line="171"/> - <source><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></source> - <translation type="unfinished"></translation> - </message> -</context> -<context> <name>NexusTab</name> <message> <location filename="modinfodialognexus.cpp" line="156"/> @@ -5996,48 +6009,93 @@ Continue?</source> <context> <name>PluginContainer</name> <message> - <location filename="plugincontainer.cpp" line="1106"/> + <location filename="plugincontainer.cpp" line="89"/> + <source>Plugin</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="plugincontainer.cpp" line="94"/> + <source>Diagnose</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="plugincontainer.cpp" line="99"/> + <source>Game</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="plugincontainer.cpp" line="104"/> + <source>Installer</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="plugincontainer.cpp" line="109"/> + <source>Mod Page</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="plugincontainer.cpp" line="114"/> + <source>Preview</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="plugincontainer.cpp" line="119"/> + <source>Tool</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="plugincontainer.cpp" line="124"/> + <source>Proxy</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="plugincontainer.cpp" line="129"/> + <source>File Mapper</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="plugincontainer.cpp" line="1104"/> <source>Plugin error</source> <translation type="unfinished"></translation> </message> <message> - <location filename="plugincontainer.cpp" line="1107"/> + <location filename="plugincontainer.cpp" line="1105"/> <source>Mod Organizer failed to load the plugin '%1' last time it was started.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="plugincontainer.cpp" line="1110"/> + <location filename="plugincontainer.cpp" line="1108"/> <source>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.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="plugincontainer.cpp" line="1115"/> + <location filename="plugincontainer.cpp" line="1113"/> <source>Skip this plugin</source> <translation type="unfinished"></translation> </message> <message> - <location filename="plugincontainer.cpp" line="1116"/> + <location filename="plugincontainer.cpp" line="1114"/> <source>Blacklist this plugin</source> <translation type="unfinished"></translation> </message> <message> - <location filename="plugincontainer.cpp" line="1117"/> + <location filename="plugincontainer.cpp" line="1115"/> <source>Load this plugin</source> <translation type="unfinished"></translation> </message> <message> - <location filename="plugincontainer.cpp" line="1215"/> + <location filename="plugincontainer.cpp" line="1213"/> <source>Some plugins could not be loaded</source> <translation type="unfinished"></translation> </message> <message> - <location filename="plugincontainer.cpp" line="1218"/> - <location filename="plugincontainer.cpp" line="1238"/> + <location filename="plugincontainer.cpp" line="1216"/> + <location filename="plugincontainer.cpp" line="1236"/> <source>Description missing</source> <translation type="unfinished"></translation> </message> <message> - <location filename="plugincontainer.cpp" line="1228"/> + <location filename="plugincontainer.cpp" line="1226"/> <source>The following plugins could not be loaded. The reason may be missing dependencies (i.e. python) or an outdated version:</source> <translation type="unfinished"></translation> </message> @@ -6398,49 +6456,6 @@ Continue?</source> </message> </context> <context> - <name>PluginTypeName</name> - <message> - <location filename="plugincontainer.cpp" line="94"/> - <source>Diagnose</source> - <translation type="unfinished"></translation> - </message> - <message> - <location filename="plugincontainer.cpp" line="99"/> - <source>Game</source> - <translation type="unfinished"></translation> - </message> - <message> - <location filename="plugincontainer.cpp" line="104"/> - <source>Installer</source> - <translation type="unfinished"></translation> - </message> - <message> - <location filename="plugincontainer.cpp" line="109"/> - <source>Mod Page</source> - <translation type="unfinished"></translation> - </message> - <message> - <location filename="plugincontainer.cpp" line="114"/> - <source>Preview</source> - <translation type="unfinished"></translation> - </message> - <message> - <location filename="plugincontainer.cpp" line="119"/> - <source>Tool</source> - <translation type="unfinished"></translation> - </message> - <message> - <location filename="plugincontainer.cpp" line="124"/> - <source>Proxy</source> - <translation type="unfinished"></translation> - </message> - <message> - <location filename="plugincontainer.cpp" line="129"/> - <source>File Mapper</source> - <translation type="unfinished"></translation> - </message> -</context> -<context> <name>PreviewDialog</name> <message> <location filename="previewdialog.ui" line="14"/> @@ -6998,7 +7013,7 @@ p, li { white-space: pre-wrap; } </message> <message> <location filename="createinstancedialogpages.cpp" line="37"/> - <location filename="createinstancedialogpages.cpp" line="1236"/> + <location filename="createinstancedialogpages.cpp" line="1237"/> <source>Instance type: %1</source> <translation type="unfinished"></translation> </message> @@ -7008,193 +7023,192 @@ p, li { white-space: pre-wrap; } <translation type="unfinished"></translation> </message> <message> - <location filename="createinstancedialogpages.cpp" line="300"/> + <location filename="createinstancedialogpages.cpp" line="301"/> <source>Find game installation for %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="createinstancedialogpages.cpp" line="348"/> + <location filename="createinstancedialogpages.cpp" line="349"/> <source>Find game installation</source> <translation type="unfinished"></translation> </message> <message> - <location filename="createinstancedialogpages.cpp" line="397"/> - <location filename="createinstancedialogpages.cpp" line="686"/> + <location filename="createinstancedialogpages.cpp" line="398"/> <location filename="createinstancedialogpages.cpp" line="687"/> + <location filename="createinstancedialogpages.cpp" line="688"/> <source>Unrecognized game</source> <translation type="unfinished"></translation> </message> <message> - <location filename="createinstancedialogpages.cpp" line="399"/> + <location filename="createinstancedialogpages.cpp" line="400"/> <source>The folder %1 does not seem to contain a game Mod Organizer can manage.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="createinstancedialogpages.cpp" line="402"/> + <location filename="createinstancedialogpages.cpp" line="403"/> <source>See details for the list of supported games.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="createinstancedialogpages.cpp" line="479"/> + <location filename="createinstancedialogpages.cpp" line="480"/> <source>No installation found</source> <translation type="unfinished"></translation> </message> <message> - <location filename="createinstancedialogpages.cpp" line="550"/> + <location filename="createinstancedialogpages.cpp" line="551"/> <source>Browse...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="createinstancedialogpages.cpp" line="551"/> + <location filename="createinstancedialogpages.cpp" line="552"/> <source>The folder must contain a valid game installation</source> <translation type="unfinished"></translation> </message> <message> - <location filename="createinstancedialogpages.cpp" line="663"/> <location filename="createinstancedialogpages.cpp" line="664"/> + <location filename="createinstancedialogpages.cpp" line="665"/> <source>Microsoft Store game</source> <translation type="unfinished"></translation> </message> <message> - <location filename="createinstancedialogpages.cpp" line="666"/> + <location filename="createinstancedialogpages.cpp" line="667"/> <source>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.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="createinstancedialogpages.cpp" line="673"/> - <location filename="createinstancedialogpages.cpp" line="695"/> - <location filename="createinstancedialogpages.cpp" line="721"/> + <location filename="createinstancedialogpages.cpp" line="674"/> + <location filename="createinstancedialogpages.cpp" line="696"/> + <location filename="createinstancedialogpages.cpp" line="722"/> <source>Use this folder for %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="createinstancedialogpages.cpp" line="674"/> + <location filename="createinstancedialogpages.cpp" line="675"/> <source>Use this folder</source> <translation type="unfinished"></translation> </message> <message> - <location filename="createinstancedialogpages.cpp" line="675"/> - <location filename="createinstancedialogpages.cpp" line="696"/> - <location filename="createinstancedialogpages.cpp" line="723"/> + <location filename="createinstancedialogpages.cpp" line="676"/> + <location filename="createinstancedialogpages.cpp" line="697"/> + <location filename="createinstancedialogpages.cpp" line="724"/> <source>I know what I'm doing</source> <translation type="unfinished"></translation> </message> <message> - <location filename="createinstancedialogpages.cpp" line="676"/> - <location filename="createinstancedialogpages.cpp" line="697"/> - <location filename="createinstancedialogpages.cpp" line="724"/> - <location filename="settingsdialognexus.cpp" line="270"/> - <location filename="settingsdialognexus.cpp" line="277"/> + <location filename="createinstancedialogpages.cpp" line="677"/> + <location filename="createinstancedialogpages.cpp" line="698"/> + <location filename="createinstancedialogpages.cpp" line="725"/> + <location filename="settingsdialognexus.cpp" line="199"/> <location filename="settingsdialogworkarounds.cpp" line="231"/> - <location filename="spawn.cpp" line="179"/> - <location filename="spawn.cpp" line="203"/> - <location filename="spawn.cpp" line="278"/> - <location filename="spawn.cpp" line="301"/> - <location filename="spawn.cpp" line="328"/> - <location filename="spawn.cpp" line="352"/> - <location filename="spawn.cpp" line="383"/> + <location filename="spawn.cpp" line="181"/> + <location filename="spawn.cpp" line="205"/> + <location filename="spawn.cpp" line="280"/> + <location filename="spawn.cpp" line="303"/> + <location filename="spawn.cpp" line="330"/> + <location filename="spawn.cpp" line="354"/> + <location filename="spawn.cpp" line="385"/> <location filename="uilocker.cpp" line="349"/> <source>Cancel</source> <translation type="unfinished"></translation> </message> <message> - <location filename="createinstancedialogpages.cpp" line="689"/> + <location filename="createinstancedialogpages.cpp" line="690"/> <source>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.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="createinstancedialogpages.cpp" line="708"/> <location filename="createinstancedialogpages.cpp" line="709"/> + <location filename="createinstancedialogpages.cpp" line="710"/> <source>Incorrect game</source> <translation type="unfinished"></translation> </message> <message> - <location filename="createinstancedialogpages.cpp" line="711"/> + <location filename="createinstancedialogpages.cpp" line="712"/> <source>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>.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="createinstancedialogpages.cpp" line="719"/> + <location filename="createinstancedialogpages.cpp" line="720"/> <source>Manage %1 instead</source> <translation type="unfinished"></translation> </message> <message> - <location filename="createinstancedialogpages.cpp" line="1238"/> + <location filename="createinstancedialogpages.cpp" line="1239"/> <source>Instance location: %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="createinstancedialogpages.cpp" line="1241"/> + <location filename="createinstancedialogpages.cpp" line="1242"/> <source>Instance name: %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="createinstancedialogpages.cpp" line="1244"/> + <location filename="createinstancedialogpages.cpp" line="1245"/> <source>Profile settings:</source> <translation type="unfinished"></translation> </message> <message> - <location filename="createinstancedialogpages.cpp" line="1246"/> + <location filename="createinstancedialogpages.cpp" line="1247"/> <source> Local INIs: %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="createinstancedialogpages.cpp" line="1247"/> - <location filename="createinstancedialogpages.cpp" line="1250"/> - <location filename="createinstancedialogpages.cpp" line="1252"/> + <location filename="createinstancedialogpages.cpp" line="1248"/> + <location filename="createinstancedialogpages.cpp" line="1251"/> + <location filename="createinstancedialogpages.cpp" line="1253"/> <source>yes</source> <translation type="unfinished"></translation> </message> <message> - <location filename="createinstancedialogpages.cpp" line="1247"/> - <location filename="createinstancedialogpages.cpp" line="1250"/> - <location filename="createinstancedialogpages.cpp" line="1253"/> + <location filename="createinstancedialogpages.cpp" line="1248"/> + <location filename="createinstancedialogpages.cpp" line="1251"/> + <location filename="createinstancedialogpages.cpp" line="1254"/> <source>no</source> <translation type="unfinished"></translation> </message> <message> - <location filename="createinstancedialogpages.cpp" line="1249"/> + <location filename="createinstancedialogpages.cpp" line="1250"/> <source> Local Saves: %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="createinstancedialogpages.cpp" line="1251"/> + <location filename="createinstancedialogpages.cpp" line="1252"/> <source> Automatic Archive Invalidation: %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="createinstancedialogpages.cpp" line="1258"/> - <location filename="createinstancedialogpages.cpp" line="1262"/> + <location filename="createinstancedialogpages.cpp" line="1259"/> + <location filename="createinstancedialogpages.cpp" line="1263"/> <source>Base directory: %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="createinstancedialogpages.cpp" line="1263"/> + <location filename="createinstancedialogpages.cpp" line="1264"/> <source>Downloads</source> <translation type="unfinished"></translation> </message> <message> - <location filename="createinstancedialogpages.cpp" line="1264"/> + <location filename="createinstancedialogpages.cpp" line="1265"/> <source>Mods</source> <translation type="unfinished"></translation> </message> <message> - <location filename="createinstancedialogpages.cpp" line="1265"/> + <location filename="createinstancedialogpages.cpp" line="1266"/> <source>Profiles</source> <translation type="unfinished"></translation> </message> <message> - <location filename="createinstancedialogpages.cpp" line="1266"/> + <location filename="createinstancedialogpages.cpp" line="1267"/> <source>Overwrite</source> <translation type="unfinished"></translation> </message> <message> - <location filename="createinstancedialogpages.cpp" line="1275"/> + <location filename="createinstancedialogpages.cpp" line="1276"/> <source>Game: %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="createinstancedialogpages.cpp" line="1276"/> + <location filename="createinstancedialogpages.cpp" line="1277"/> <source>Game location: %1</source> <translation type="unfinished"></translation> </message> @@ -7438,12 +7452,12 @@ Destination:<byte value="xd"/> <translation type="unfinished"></translation> </message> <message> - <location filename="main.cpp" line="63"/> + <location filename="main.cpp" line="62"/> <source>Mod Organizer</source> <translation type="unfinished"></translation> </message> <message> - <location filename="main.cpp" line="64"/> + <location filename="main.cpp" line="63"/> <source>An instance of Mod Organizer is already running</source> <translation type="unfinished"></translation> </message> @@ -7515,94 +7529,114 @@ Destination:<byte value="xd"/> <translation type="unfinished"></translation> </message> <message> - <location filename="nxmaccessmanager.cpp" line="192"/> + <location filename="nexusoauthlogin.cpp" line="53"/> <source>Connecting to Nexus...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="nxmaccessmanager.cpp" line="195"/> + <location filename="nexusoauthlogin.cpp" line="60"/> <source>Waiting for Nexus...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="nxmaccessmanager.cpp" line="198"/> + <location filename="nexusoauthlogin.cpp" line="56"/> <source>Opened Nexus in browser.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="nxmaccessmanager.cpp" line="199"/> + <location filename="nexusoauthlogin.cpp" line="57"/> <source>Switch to your browser and accept the request.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="nxmaccessmanager.cpp" line="202"/> + <location filename="nexusoauthlogin.cpp" line="63"/> <source>Finished.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="nxmaccessmanager.cpp" line="205"/> - <source>No answer from Nexus.</source> + <location filename="nexusoauthlogin.cpp" line="70"/> + <source>An unknown error has occurred.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="nexusoauthlogin.cpp" line="82"/> + <source>No OAuth client id configured.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="nexusoauthlogin.cpp" line="120"/> + <source><html><body><h2>Mod Organizer</h2><p>Authorization complete. You may close this window.</p></body></html></source> <translation type="unfinished"></translation> </message> <message> - <location filename="nxmaccessmanager.cpp" line="206"/> - <location filename="nxmaccessmanager.cpp" line="210"/> - <source>A firewall might be blocking Mod Organizer.</source> + <location filename="nexusoauthlogin.cpp" line="125"/> + <source>Failed to bind to localhost on port %1.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="nxmaccessmanager.cpp" line="209"/> - <source>Nexus closed the connection.</source> + <location filename="nexusoauthlogin.cpp" line="157"/> + <source>Authorization failed (%1)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="nxmaccessmanager.cpp" line="213"/> + <location filename="nexusoauthlogin.cpp" line="198"/> + <source>Internal error: OAuth flow is missing.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="nexusoauthlogin.cpp" line="212"/> + <source>Invalid OAuth token payload.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="nexusoauthlogin.cpp" line="66"/> <source>Cancelled.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="nxmaccessmanager.cpp" line="401"/> + <location filename="nxmaccessmanager.cpp" line="205"/> <source>Failed to request %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="nxmaccessmanager.cpp" line="423"/> - <location filename="nxmaccessmanager.cpp" line="753"/> + <location filename="nxmaccessmanager.cpp" line="227"/> + <location filename="nxmaccessmanager.cpp" line="561"/> <source>Cancelled</source> <translation type="unfinished"></translation> </message> <message> - <location filename="nxmaccessmanager.cpp" line="469"/> + <location filename="nxmaccessmanager.cpp" line="273"/> <source>Internal error</source> <translation type="unfinished"></translation> </message> <message> - <location filename="nxmaccessmanager.cpp" line="504"/> + <location filename="nxmaccessmanager.cpp" line="308"/> <source>HTTP code %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="nxmaccessmanager.cpp" line="514"/> + <location filename="nxmaccessmanager.cpp" line="318"/> <source>Invalid JSON</source> <translation type="unfinished"></translation> </message> <message> - <location filename="nxmaccessmanager.cpp" line="519"/> + <location filename="nxmaccessmanager.cpp" line="323"/> <source>Bad response</source> <translation type="unfinished"></translation> </message> <message> - <location filename="nxmaccessmanager.cpp" line="529"/> - <source>API key is empty</source> + <location filename="nxmaccessmanager.cpp" line="188"/> + <location filename="nxmaccessmanager.cpp" line="332"/> + <source>Access token is empty</source> <translation type="unfinished"></translation> </message> <message> - <location filename="nxmaccessmanager.cpp" line="552"/> + <location filename="nxmaccessmanager.cpp" line="355"/> <source>SSL error</source> <translation type="unfinished"></translation> </message> <message> - <location filename="nxmaccessmanager.cpp" line="557"/> + <location filename="nxmaccessmanager.cpp" line="360"/> <source>Timed out</source> <translation type="unfinished"></translation> </message> @@ -7675,19 +7709,19 @@ This program is known to cause issues with Mod Organizer, such as freezing or bl <translation type="unfinished"></translation> </message> <message> - <location filename="settings.cpp" line="1435"/> - <location filename="settings.cpp" line="1459"/> - <location filename="settings.cpp" line="1507"/> + <location filename="settings.cpp" line="1437"/> + <location filename="settings.cpp" line="1461"/> + <location filename="settings.cpp" line="1509"/> <source>attempt to store setting for unknown plugin "%1"</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settings.cpp" line="2005"/> + <location filename="settings.cpp" line="2007"/> <source>Failed</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settings.cpp" line="2006"/> + <location filename="settings.cpp" line="2008"/> <source>Failed to start the helper application: %1</source> <translation type="unfinished"></translation> </message> @@ -7734,40 +7768,33 @@ This program is known to cause issues with Mod Organizer, such as freezing or bl <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialognexus.cpp" line="272"/> - <location filename="settingsdialognexus.cpp" line="282"/> - <location filename="settingsdialognexus.cpp" line="287"/> - <source>Enter API Key Manually</source> - <translation type="unfinished"></translation> - </message> - <message> - <location filename="settingsdialognexus.cpp" line="275"/> - <location filename="settingsdialognexus.cpp" line="280"/> - <location filename="settingsdialognexus.cpp" line="285"/> + <location filename="settingsdialognexus.cpp" line="202"/> + <location filename="settingsdialognexus.cpp" line="205"/> + <location filename="settingsdialognexus.cpp" line="208"/> <source>Connect to Nexus</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialognexus.cpp" line="442"/> - <location filename="settingsdialognexus.cpp" line="443"/> - <location filename="settingsdialognexus.cpp" line="444"/> - <location filename="settingsdialognexus.cpp" line="445"/> - <location filename="settingsdialognexus.cpp" line="446"/> + <location filename="settingsdialognexus.cpp" line="363"/> + <location filename="settingsdialognexus.cpp" line="364"/> + <location filename="settingsdialognexus.cpp" line="365"/> + <location filename="settingsdialognexus.cpp" line="366"/> + <location filename="settingsdialognexus.cpp" line="367"/> <source>N/A</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialognexus.cpp" line="458"/> + <location filename="settingsdialognexus.cpp" line="379"/> <source>Executables (*.exe)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialognexus.cpp" line="458"/> + <location filename="settingsdialognexus.cpp" line="379"/> <source>All Files (*.*)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialognexus.cpp" line="461"/> + <location filename="settingsdialognexus.cpp" line="382"/> <source>Select the browser executable</source> <translation type="unfinished"></translation> </message> @@ -7924,196 +7951,196 @@ Example: <translation type="unfinished"></translation> </message> <message> - <location filename="spawn.cpp" line="137"/> + <location filename="spawn.cpp" line="139"/> <source>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.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="spawn.cpp" line="144"/> + <location filename="spawn.cpp" line="146"/> <source>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.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="spawn.cpp" line="149"/> + <location filename="spawn.cpp" line="151"/> <source>The file '%1' does not exist.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="spawn.cpp" line="153"/> + <location filename="spawn.cpp" line="155"/> <source>The working directory '%1' does not exist.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="spawn.cpp" line="170"/> - <location filename="spawn.cpp" line="171"/> - <location filename="spawn.cpp" line="196"/> - <location filename="spawn.cpp" line="197"/> + <location filename="spawn.cpp" line="172"/> + <location filename="spawn.cpp" line="173"/> + <location filename="spawn.cpp" line="198"/> + <location filename="spawn.cpp" line="199"/> <source>Cannot start Steam</source> <translation type="unfinished"></translation> </message> <message> - <location filename="spawn.cpp" line="173"/> + <location filename="spawn.cpp" line="175"/> <source>The path to the Steam executable cannot be found. You might try reinstalling Steam.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="spawn.cpp" line="177"/> - <location filename="spawn.cpp" line="201"/> - <location filename="spawn.cpp" line="299"/> + <location filename="spawn.cpp" line="179"/> + <location filename="spawn.cpp" line="203"/> + <location filename="spawn.cpp" line="301"/> <source>Continue without starting Steam</source> <translation type="unfinished"></translation> </message> <message> - <location filename="spawn.cpp" line="178"/> - <location filename="spawn.cpp" line="202"/> + <location filename="spawn.cpp" line="180"/> + <location filename="spawn.cpp" line="204"/> <source>The program may fail to launch.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="spawn.cpp" line="212"/> + <location filename="spawn.cpp" line="214"/> <source>Cannot launch program</source> <translation type="unfinished"></translation> </message> <message> - <location filename="spawn.cpp" line="214"/> - <location filename="spawn.cpp" line="238"/> - <location filename="spawn.cpp" line="256"/> + <location filename="spawn.cpp" line="216"/> + <location filename="spawn.cpp" line="240"/> + <location filename="spawn.cpp" line="258"/> <source>Cannot start %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="spawn.cpp" line="236"/> + <location filename="spawn.cpp" line="238"/> <source>Cannot launch helper</source> <translation type="unfinished"></translation> </message> <message> - <location filename="spawn.cpp" line="254"/> - <location filename="spawn.cpp" line="309"/> + <location filename="spawn.cpp" line="256"/> + <location filename="spawn.cpp" line="311"/> <source>Elevation required</source> <translation type="unfinished"></translation> </message> <message> - <location filename="spawn.cpp" line="258"/> + <location filename="spawn.cpp" line="260"/> <source>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".<byte value="xd"/> <byte value="xd"/> You can restart Mod Organizer as administrator and try launching the program again.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="spawn.cpp" line="274"/> - <location filename="spawn.cpp" line="323"/> + <location filename="spawn.cpp" line="276"/> + <location filename="spawn.cpp" line="325"/> <source>Restart Mod Organizer as administrator</source> <translation type="unfinished"></translation> </message> <message> - <location filename="spawn.cpp" line="275"/> - <location filename="spawn.cpp" line="324"/> + <location filename="spawn.cpp" line="277"/> + <location filename="spawn.cpp" line="326"/> <source>You must allow "helper.exe" to make changes to the system.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="spawn.cpp" line="287"/> + <location filename="spawn.cpp" line="289"/> <source>Launch Steam</source> <translation type="unfinished"></translation> </message> <message> - <location filename="spawn.cpp" line="288"/> + <location filename="spawn.cpp" line="290"/> <source>This program requires Steam</source> <translation type="unfinished"></translation> </message> <message> - <location filename="spawn.cpp" line="289"/> + <location filename="spawn.cpp" line="291"/> <source>Mod Organizer has detected that this program likely requires Steam to be running to function properly.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="spawn.cpp" line="298"/> + <location filename="spawn.cpp" line="300"/> <source>Start Steam</source> <translation type="unfinished"></translation> </message> <message> - <location filename="spawn.cpp" line="300"/> - <location filename="spawn.cpp" line="326"/> + <location filename="spawn.cpp" line="302"/> + <location filename="spawn.cpp" line="328"/> <source>The program might fail to run.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="spawn.cpp" line="310"/> + <location filename="spawn.cpp" line="312"/> <source>Steam is running as administrator</source> <translation type="unfinished"></translation> </message> <message> - <location filename="spawn.cpp" line="311"/> + <location filename="spawn.cpp" line="313"/> <source>Running Steam as administrator is typically unnecessary and can cause problems when Mod Organizer itself is not running as administrator.<byte value="xd"/> <byte value="xd"/> You can restart Mod Organizer as administrator and try launching the program again.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="spawn.cpp" line="326"/> - <location filename="spawn.cpp" line="350"/> - <location filename="spawn.cpp" line="380"/> + <location filename="spawn.cpp" line="328"/> + <location filename="spawn.cpp" line="352"/> + <location filename="spawn.cpp" line="382"/> <source>Continue</source> <translation type="unfinished"></translation> </message> <message> - <location filename="spawn.cpp" line="336"/> + <location filename="spawn.cpp" line="338"/> <source>Event Log not running</source> <translation type="unfinished"></translation> </message> <message> - <location filename="spawn.cpp" line="337"/> + <location filename="spawn.cpp" line="339"/> <source>The Event Log service is not running</source> <translation type="unfinished"></translation> </message> <message> - <location filename="spawn.cpp" line="338"/> + <location filename="spawn.cpp" line="340"/> <source>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.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="spawn.cpp" line="350"/> - <location filename="spawn.cpp" line="381"/> + <location filename="spawn.cpp" line="352"/> + <location filename="spawn.cpp" line="383"/> <source>Your mods might not work.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="spawn.cpp" line="361"/> + <location filename="spawn.cpp" line="363"/> <source>Blacklisted program</source> <translation type="unfinished"></translation> </message> <message> - <location filename="spawn.cpp" line="363"/> + <location filename="spawn.cpp" line="365"/> <source>The program %1 is blacklisted</source> <translation type="unfinished"></translation> </message> <message> - <location filename="spawn.cpp" line="364"/> + <location filename="spawn.cpp" line="366"/> <source>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.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="spawn.cpp" line="382"/> + <location filename="spawn.cpp" line="384"/> <source>Change the blacklist</source> <translation type="unfinished"></translation> </message> <message> - <location filename="spawn.cpp" line="603"/> + <location filename="spawn.cpp" line="605"/> <source>Waiting</source> <translation type="unfinished"></translation> </message> <message> - <location filename="spawn.cpp" line="604"/> + <location filename="spawn.cpp" line="606"/> <source>Please press OK once you're logged into steam.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="spawn.cpp" line="892"/> + <location filename="spawn.cpp" line="894"/> <source>Select binary</source> <translation type="unfinished"></translation> </message> <message> - <location filename="spawn.cpp" line="893"/> + <location filename="spawn.cpp" line="895"/> <source>Binary</source> <translation type="unfinished"></translation> </message> @@ -8730,7 +8757,7 @@ If you disable this feature, MO will only display official DLCs this way. Please <location filename="settingsdialog.ui" line="847"/> <location filename="settingsdialog.ui" line="944"/> <location filename="settingsdialog.ui" line="1002"/> - <location filename="settingsdialog.ui" line="1981"/> + <location filename="settingsdialog.ui" line="1971"/> <source>...</source> <translation type="unfinished"></translation> </message> @@ -8847,205 +8874,195 @@ If you disable this feature, MO will only display official DLCs this way. Please </message> <message> <location filename="settingsdialog.ui" line="1207"/> - <source>Manually enter the API key and try to login</source> + <source>Clear the stored Nexus authorization and force reauthorization.</source> <translation type="unfinished"></translation> </message> <message> <location filename="settingsdialog.ui" line="1210"/> - <source>Enter API Key Manually</source> - <translation type="unfinished"></translation> - </message> - <message> - <location filename="settingsdialog.ui" line="1217"/> - <source>Clear the stored Nexus API key and force reauthorization.</source> - <translation type="unfinished"></translation> - </message> - <message> - <location filename="settingsdialog.ui" line="1220"/> <source>Disconnect from Nexus</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="1293"/> - <location filename="settingsdialog.ui" line="1768"/> + <location filename="settingsdialog.ui" line="1283"/> + <location filename="settingsdialog.ui" line="1758"/> <source>Options</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="1326"/> + <location filename="settingsdialog.ui" line="1316"/> <source>Endorsement Integration</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="1336"/> + <location filename="settingsdialog.ui" line="1326"/> <source>Tracked Integration</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="1346"/> + <location filename="settingsdialog.ui" line="1336"/> <source>Use Nexus category mappings</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="1356"/> - <location filename="settingsdialog.ui" line="1359"/> + <location filename="settingsdialog.ui" line="1346"/> + <location filename="settingsdialog.ui" line="1349"/> <source><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></source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="1362"/> + <location filename="settingsdialog.ui" line="1352"/> <source>Hide API Request Counter</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="1387"/> + <location filename="settingsdialog.ui" line="1377"/> <source>Associate with "Download with manager" links</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="1394"/> + <location filename="settingsdialog.ui" line="1384"/> <source>Remove cache and cookies.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="1397"/> + <location filename="settingsdialog.ui" line="1387"/> <source>Clear Cache</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="1410"/> + <location filename="settingsdialog.ui" line="1400"/> <source>Servers</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="1433"/> + <location filename="settingsdialog.ui" line="1423"/> <source>Known Servers (updated on download)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="1454"/> + <location filename="settingsdialog.ui" line="1444"/> <source>Preferred Servers (Drag & Drop)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="1481"/> + <location filename="settingsdialog.ui" line="1471"/> <source>Plugins</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="1582"/> + <location filename="settingsdialog.ui" line="1572"/> <source>Author:</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="1602"/> + <location filename="settingsdialog.ui" line="1592"/> <source>Version:</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="1622"/> + <location filename="settingsdialog.ui" line="1612"/> <source>Description:</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="1645"/> + <location filename="settingsdialog.ui" line="1635"/> <source>Enabled</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="1674"/> + <location filename="settingsdialog.ui" line="1664"/> <source>Key</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="1679"/> + <location filename="settingsdialog.ui" line="1669"/> <source>Value</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="1687"/> + <location filename="settingsdialog.ui" line="1677"/> <source>No plugin found.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="1708"/> + <location filename="settingsdialog.ui" line="1698"/> <source>Blacklisted Plugins (use <del> to remove):</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="1722"/> + <location filename="settingsdialog.ui" line="1712"/> <source>Workarounds</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="1786"/> + <location filename="settingsdialog.ui" line="1776"/> <source>If checked, files (i.e. esps, esms and bsas) belonging to the core game can not be disabled in the UI. (default: on)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="1789"/> + <location filename="settingsdialog.ui" line="1779"/> <source>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.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="1793"/> + <location filename="settingsdialog.ui" line="1783"/> <source>Force-enable game files</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="1803"/> + <location filename="settingsdialog.ui" line="1793"/> <source>Enable parsing of Archives. This is an Experimental Feature. Has negative effects on performance and known incorrectness.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="1806"/> + <location filename="settingsdialog.ui" line="1796"/> <source><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></source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="1809"/> + <location filename="settingsdialog.ui" line="1799"/> <source>Enable archives parsing (experimental)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="1819"/> - <location filename="settingsdialog.ui" line="1822"/> + <location filename="settingsdialog.ui" line="1809"/> + <location filename="settingsdialog.ui" line="1812"/> <source>Disable this to prevent the GUI from being locked when running an executable. This may result in abnormal behavior.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="1825"/> + <location filename="settingsdialog.ui" line="1815"/> <source>Lock GUI when running executable</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="1838"/> + <location filename="settingsdialog.ui" line="1828"/> <source>Steam</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="1847"/> + <location filename="settingsdialog.ui" line="1837"/> <source>Password</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="1861"/> + <location filename="settingsdialog.ui" line="1851"/> <source>Username</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="1868"/> + <location filename="settingsdialog.ui" line="1858"/> <source>Steam App ID</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="1875"/> + <location filename="settingsdialog.ui" line="1865"/> <source>The Steam AppID for your game</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="1878"/> + <location filename="settingsdialog.ui" line="1868"/> <source><!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; } <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="1905"/> + <location filename="settingsdialog.ui" line="1895"/> <source>Network</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="1911"/> + <location filename="settingsdialog.ui" line="1901"/> <source>Disable automatic internet features</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="1914"/> + <location filename="settingsdialog.ui" line="1904"/> <source>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)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="1917"/> + <location filename="settingsdialog.ui" line="1907"/> <source>Offline Mode</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="1924"/> + <location filename="settingsdialog.ui" line="1914"/> <source>Use a proxy for network connections.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="1927"/> + <location filename="settingsdialog.ui" line="1917"/> <source>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.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="1930"/> + <location filename="settingsdialog.ui" line="1920"/> <source>Use System HTTP Proxy</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="1952"/> - <location filename="settingsdialog.ui" line="1955"/> + <location filename="settingsdialog.ui" line="1942"/> + <location filename="settingsdialog.ui" line="1945"/> + <location filename="settingsdialog.ui" line="1948"/> <location filename="settingsdialog.ui" line="1958"/> - <location filename="settingsdialog.ui" line="1968"/> - <location filename="settingsdialog.ui" line="1971"/> - <location filename="settingsdialog.ui" line="1974"/> + <location filename="settingsdialog.ui" line="1961"/> + <location filename="settingsdialog.ui" line="1964"/> <source>Use "%1" as a placeholder for the URL.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="1961"/> + <location filename="settingsdialog.ui" line="1951"/> <source>Custom browser</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="2009"/> - <location filename="settingsdialog.ui" line="2012"/> + <location filename="settingsdialog.ui" line="1999"/> + <location filename="settingsdialog.ui" line="2002"/> <source>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.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="2015"/> + <location filename="settingsdialog.ui" line="2005"/> <source>Reset Window Geometries</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="2022"/> - <location filename="settingsdialog.ui" line="2028"/> + <location filename="settingsdialog.ui" line="2012"/> + <location filename="settingsdialog.ui" line="2018"/> <source> 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; } <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="2034"/> + <location filename="settingsdialog.ui" line="2024"/> <source>Back-date BSAs</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="2076"/> + <location filename="settingsdialog.ui" line="2066"/> <source>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.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="2083"/> + <location filename="settingsdialog.ui" line="2073"/> <source>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.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="2086"/> + <location filename="settingsdialog.ui" line="2076"/> <source>Executables Blacklist</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="2096"/> - <location filename="settingsdialog.ui" line="2099"/> + <location filename="settingsdialog.ui" line="2086"/> + <location filename="settingsdialog.ui" line="2089"/> <source>Files to skip or ignore from the virtual file system.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="2102"/> + <location filename="settingsdialog.ui" line="2092"/> <source>Skip File Suffixes</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="2112"/> - <location filename="settingsdialog.ui" line="2115"/> + <location filename="settingsdialog.ui" line="2102"/> + <location filename="settingsdialog.ui" line="2105"/> <source>Directories to skip or ignore from the virtual file system.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="2118"/> + <location filename="settingsdialog.ui" line="2108"/> <source>Skip Directories</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="2161"/> + <location filename="settingsdialog.ui" line="2151"/> <source>These are workarounds for problems with Mod Organizer. Please make sure you read the help text before changing anything here.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="2172"/> + <location filename="settingsdialog.ui" line="2162"/> <source>Diagnostics</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="2181"/> + <location filename="settingsdialog.ui" line="2171"/> <source>Logs and Crashes</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="2190"/> + <location filename="settingsdialog.ui" line="2180"/> <source>Log Level</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="2197"/> + <location filename="settingsdialog.ui" line="2187"/> <source>Decides the amount of data printed to "ModOrganizer.log"</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="2200"/> + <location filename="settingsdialog.ui" line="2190"/> <source> 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.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="2210"/> + <location filename="settingsdialog.ui" line="2200"/> <source>Crash Dumps</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="2217"/> + <location filename="settingsdialog.ui" line="2207"/> <source>Decides which type of crash dumps are collected when injected processes crash.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="2220"/> + <location filename="settingsdialog.ui" line="2210"/> <source> 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.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="2233"/> + <location filename="settingsdialog.ui" line="2223"/> <source>Max Dumps To Keep</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="2240"/> + <location filename="settingsdialog.ui" line="2230"/> <source>Maximum number of crash dumps to keep on disk. Use 0 for unlimited.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="2243"/> + <location filename="settingsdialog.ui" line="2233"/> <source> 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.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="2256"/> + <location filename="settingsdialog.ui" line="2246"/> <source>Integrated LOOT</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="2265"/> + <location filename="settingsdialog.ui" line="2255"/> <source>LOOT Log Level</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="2278"/> + <location filename="settingsdialog.ui" line="2268"/> <source>Click a link to open the location</source> <translation type="unfinished"></translation> </message> <message> - <location filename="settingsdialog.ui" line="2281"/> + <location filename="settingsdialog.ui" line="2271"/> <source> 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. @@ -9326,14 +9343,6 @@ programs you are intentionally running.</source> </message> </context> <context> - <name>T</name> - <message> - <location filename="plugincontainer.cpp" line="89"/> - <source>Plugin</source> - <translation type="unfinished"></translation> - </message> -</context> -<context> <name>TransferSavesDialog</name> <message> <location filename="transfersavesdialog.ui" line="14"/> @@ -9479,7 +9488,7 @@ On Windows XP: </message> <message> <location filename="validationprogressdialog.ui" line="35"/> - <location filename="nxmaccessmanager.cpp" line="150"/> + <location filename="nxmaccessmanager.cpp" line="151"/> <source>Connecting to Nexus...</source> <translation type="unfinished"></translation> </message> @@ -9494,7 +9503,7 @@ On Windows XP: <translation type="unfinished"></translation> </message> <message> - <location filename="nxmaccessmanager.cpp" line="148"/> + <location filename="nxmaccessmanager.cpp" line="149"/> <source>Trying again...</source> <translation type="unfinished"></translation> </message> @@ -9840,7 +9849,8 @@ Please open the "Nexus" tab.</source> </message> <message> <location filename="tutorials/tutorial_firststeps_settings.js" line="21"/> - <source>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.</source> + <source>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.</source> + <oldsource>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.</oldsource> <translation type="unfinished"></translation> </message> </context> diff --git a/src/organizercore.cpp b/src/organizercore.cpp index dff3485e..68ccd2ff 100644 --- a/src/organizercore.cpp +++ b/src/organizercore.cpp @@ -299,11 +299,13 @@ bool OrganizerCore::nexusApi(bool retry) // previous attempt, maybe even successful return false; } else { - QString apiKey; - if (GlobalSettings::nexusApiKey(apiKey)) { + NexusOAuthTokens tokens; + GlobalSettings::nexusOAuthTokens(tokens); + GlobalSettings::nexusApiKey(tokens.apiKey); + if (tokens.isValid() || !tokens.apiKey.isEmpty()) { // credentials stored or user entered them manually - log::debug("attempt to verify nexus api key"); - accessManager->apiCheck(apiKey); + log::debug("attempt to verify nexus credentials"); + accessManager->apiCheck(tokens); return true; } else { // no credentials stored and user didn't enter them @@ -1473,12 +1475,12 @@ void OrganizerCore::loggedInAction(QWidget* parent, std::function<void()> f) if (NexusInterface::instance().getAccessManager()->validated()) { f(); } else if (!m_Settings.network().offlineMode()) { - QString apiKey; - if (GlobalSettings::nexusApiKey(apiKey)) { + NexusOAuthTokens tokens; + if (GlobalSettings::nexusOAuthTokens(tokens)) { doAfterLogin([f] { f(); }); - NexusInterface::instance().getAccessManager()->apiCheck(apiKey); + NexusInterface::instance().getAccessManager()->apiCheck(tokens); } else { MessageDialog::showMessage(tr("You need to be logged in with Nexus"), parent); } @@ -250,7 +250,6 @@ #include <QWebEngineProfile> #include <QWebEngineSettings> #include <QWebEngineView> -#include <QWebSocket> #include <QWhatsThis> #include <QWhatsThisClickedEvent> #include <QWidget> diff --git a/src/plugincontainer.cpp b/src/plugincontainer.cpp index 97c6bb09..d70bdc72 100644 --- a/src/plugincontainer.cpp +++ b/src/plugincontainer.cpp @@ -86,47 +86,47 @@ struct PluginTypeName; template <> struct PluginTypeName<MOBase::IPlugin> { - static QString value() { return QT_TR_NOOP("Plugin"); } + static QString value() { return PluginContainer::tr("Plugin"); } }; template <> struct PluginTypeName<MOBase::IPluginDiagnose> { - static QString value() { return QT_TR_NOOP("Diagnose"); } + static QString value() { return PluginContainer::tr("Diagnose"); } }; template <> struct PluginTypeName<MOBase::IPluginGame> { - static QString value() { return QT_TR_NOOP("Game"); } + static QString value() { return PluginContainer::tr("Game"); } }; template <> struct PluginTypeName<MOBase::IPluginInstaller> { - static QString value() { return QT_TR_NOOP("Installer"); } + static QString value() { return PluginContainer::tr("Installer"); } }; template <> struct PluginTypeName<MOBase::IPluginModPage> { - static QString value() { return QT_TR_NOOP("Mod Page"); } + static QString value() { return PluginContainer::tr("Mod Page"); } }; template <> struct PluginTypeName<MOBase::IPluginPreview> { - static QString value() { return QT_TR_NOOP("Preview"); } + static QString value() { return PluginContainer::tr("Preview"); } }; template <> struct PluginTypeName<MOBase::IPluginTool> { - static QString value() { return QT_TR_NOOP("Tool"); } + static QString value() { return PluginContainer::tr("Tool"); } }; template <> struct PluginTypeName<MOBase::IPluginProxy> { - static QString value() { return QT_TR_NOOP("Proxy"); } + static QString value() { return PluginContainer::tr("Proxy"); } }; template <> struct PluginTypeName<MOBase::IPluginFileMapper> { - static QString value() { return QT_TR_NOOP("File Mapper"); } + static QString value() { return PluginContainer::tr("File Mapper"); } }; QStringList PluginContainer::pluginInterfaces() diff --git a/src/settings.cpp b/src/settings.cpp index 82e99de0..849f64ec 100644 --- a/src/settings.cpp +++ b/src/settings.cpp @@ -26,8 +26,10 @@ along with Mod Organizer. If not, see <http://www.gnu.org/licenses/>. #include "serverinfo.h" #include "settingsutilities.h" #include "shared/appconfig.h" +#include <QJsonDocument> #include <expanderwidget.h> #include <iplugingame.h> +#include <optional> #include <utility.h> using namespace MOBase; @@ -2535,9 +2537,29 @@ void GlobalSettings::setHideAssignCategoriesQuestion(bool b) settings().setValue("HideAssignCategoriesQuestion", b); } +namespace +{ +constexpr auto NexusLegacyCredentialKey = "APIKEY"; +constexpr auto NexusOAuthCredentialKey = "NEXUS_OAUTH_TOKENS"; + +std::optional<NexusOAuthTokens> parseStoredTokens(const QString& raw) +{ + if (raw.isEmpty()) { + return std::nullopt; + } + + const auto doc = QJsonDocument::fromJson(raw.toUtf8()); + if (doc.isNull() || !doc.isObject()) { + return std::nullopt; + } + + return NexusOAuthTokens::fromJson(doc.object()); +} +} // namespace + bool GlobalSettings::nexusApiKey(QString& apiKey) { - QString tempKey = getWindowsCredential("APIKEY"); + QString tempKey = getWindowsCredential(NexusLegacyCredentialKey); if (tempKey.isEmpty()) return false; @@ -2547,7 +2569,7 @@ bool GlobalSettings::nexusApiKey(QString& apiKey) bool GlobalSettings::setNexusApiKey(const QString& apiKey) { - if (!setWindowsCredential("APIKEY", apiKey)) { + if (!setWindowsCredential(NexusLegacyCredentialKey, apiKey)) { const auto e = GetLastError(); log::error("Storing API key failed: {}", formatSystemMessage(e)); return false; @@ -2563,7 +2585,42 @@ bool GlobalSettings::clearNexusApiKey() bool GlobalSettings::hasNexusApiKey() { - return !getWindowsCredential("APIKEY").isEmpty(); + return !getWindowsCredential(NexusLegacyCredentialKey).isEmpty(); +} + +bool GlobalSettings::nexusOAuthTokens(NexusOAuthTokens& tokens) +{ + const auto raw = getWindowsCredential(NexusOAuthCredentialKey); + const auto parsed = parseStoredTokens(raw); + if (!parsed) { + return false; + } else { + tokens = *parsed; + } + return true; +} + +bool GlobalSettings::setNexusOAuthTokens(const NexusOAuthTokens& tokens) +{ + const auto payload = QJsonDocument(tokens.toJson()).toJson(QJsonDocument::Compact); + + if (!setWindowsCredential(NexusOAuthCredentialKey, payload)) { + const auto e = GetLastError(); + log::error("Storing OAuth tokens failed: {}", formatSystemMessage(e)); + return false; + } + + return true; +} + +bool GlobalSettings::clearNexusOAuthTokens() +{ + return setWindowsCredential(NexusOAuthCredentialKey, ""); +} + +bool GlobalSettings::hasNexusOAuthTokens() +{ + return !getWindowsCredential(NexusOAuthCredentialKey).isEmpty(); } void GlobalSettings::resetDialogs() diff --git a/src/settings.h b/src/settings.h index 626582d1..28eeb1fd 100644 --- a/src/settings.h +++ b/src/settings.h @@ -21,6 +21,7 @@ along with Mod Organizer. If not, see <http://www.gnu.org/licenses/>. #define SETTINGS_H #include "envdump.h" +#include "nexusoauthtokens.h" #include <lootcli/lootcli.h> #include <questionboxmemory.h> #include <uibase/filterwidget.h> @@ -975,6 +976,24 @@ public: // static bool hasNexusApiKey(); + // Retrieves the stored OAuth tokens. Returns false if the credential doesn't exist + // or can't be parsed. + // + static bool nexusOAuthTokens(NexusOAuthTokens& tokens); + + // Persists the OAuth tokens inside the credentials store, replacing the previous + // entry. Returns false on errors. + // + static bool setNexusOAuthTokens(const NexusOAuthTokens& tokens); + + // Removes the stored OAuth tokens; returns false on errors. + // + static bool clearNexusOAuthTokens(); + + // Returns whether OAuth tokens are currently stored. + // + static bool hasNexusOAuthTokens(); + // resets anything that the user can disable static void resetDialogs(); diff --git a/src/settingsdialog.ui b/src/settingsdialog.ui index 983d25a4..9e335909 100644 --- a/src/settingsdialog.ui +++ b/src/settingsdialog.ui @@ -1227,7 +1227,7 @@ If you disable this feature, MO will only display official DLCs this way. Please <item> <widget class="QPushButton" name="nexusDisconnect"> <property name="toolTip"> - <string>Clear the stored Nexus API key and force reauthorization.</string> + <string>Clear the stored Nexus authorization and force reauthorization.</string> </property> <property name="text"> <string>Disconnect from Nexus</string> diff --git a/src/settingsdialognexus.cpp b/src/settingsdialognexus.cpp index 29873d23..14a16204 100644 --- a/src/settingsdialognexus.cpp +++ b/src/settingsdialognexus.cpp @@ -1,6 +1,7 @@ #include "settingsdialognexus.h" #include "log.h" #include "nexusinterface.h" +#include "nexusoauthlogin.h" #include "serverinfo.h" #include "ui_nexusmanualkey.h" #include "ui_settingsdialog.h" @@ -100,7 +101,19 @@ NexusConnectionUI::NexusConnectionUI(QWidget* parent, Settings* s, }); } - if (GlobalSettings::hasNexusApiKey()) { + QObject::connect(NexusInterface::instance().getAccessManager(), + &NXMAccessManager::updateOAuthState, this, + [&](NXMAccessManager::OAuthState state, QString message) { + onOAuthStateChanged(state, message); + }); + + QObject::connect(NexusInterface::instance().getAccessManager(), + &NXMAccessManager::tokensReceived, this, + [&](const NexusOAuthTokens tokens) { + onTokensReceived(tokens); + }); + + if (GlobalSettings::hasNexusOAuthTokens() || GlobalSettings::hasNexusApiKey()) { addLog(tr("Connected.")); } else { addLog(tr("Not connected.")); @@ -117,18 +130,11 @@ void NexusConnectionUI::connect() } if (!m_nexusLogin) { - m_nexusLogin.reset(new NexusSSOLogin); - - m_nexusLogin->keyChanged = [&](auto&& s) { - onSSOKeyChanged(s); - }; - - m_nexusLogin->stateChanged = [&](auto&& s, auto&& e) { - onSSOStateChanged(s, e); - }; + m_nexusLogin.reset(new NexusOAuthLogin(m_parent)); } m_log->clear(); + m_pendingTokens.reset(); m_nexusLogin->start(); updateState(); } @@ -147,22 +153,29 @@ void NexusConnectionUI::manual() const auto key = d.key(); if (key.isEmpty()) { - clearKey(); + clearCredentials(); return; } m_log->clear(); - validateKey(key); + auto tokens = NexusInterface::instance().getAccessManager()->tokens(); + if (!tokens) { + tokens = NexusOAuthTokens(); + } + tokens->apiKey = key; + NexusInterface::instance().getAccessManager()->setTokens(*tokens); + m_pendingTokens = tokens; + validateCredentials(*tokens); } void NexusConnectionUI::disconnect() { - clearKey(); + clearCredentials(); m_log->clear(); addLog(tr("Disconnected.")); } -void NexusConnectionUI::validateKey(const QString& key) +void NexusConnectionUI::validateCredentials(const NexusOAuthTokens& tokens) { if (!m_nexusValidator) { m_nexusValidator.reset(new NexusKeyValidator( @@ -173,25 +186,40 @@ void NexusConnectionUI::validateKey(const QString& key) }; } - addLog(tr("Checking API key...")); - m_nexusValidator->start(key, NexusKeyValidator::OneShot); + addLog(tr("Authorizing with Nexus...")); + m_nexusValidator->start(tokens, NexusKeyValidator::OneShot); } -void NexusConnectionUI::onSSOKeyChanged(const QString& key) +void NexusConnectionUI::onTokensReceived(const NexusOAuthTokens& tokens) { - if (key.isEmpty()) { - clearKey(); + if (GlobalSettings::hasNexusOAuthTokens() || GlobalSettings::hasNexusApiKey()) { + NexusOAuthTokens oldTokens; + GlobalSettings::nexusOAuthTokens(oldTokens); + GlobalSettings::nexusApiKey(oldTokens.apiKey); + NexusOAuthTokens newTokens(tokens); + if (tokens.apiKey.isEmpty()) { + newTokens.apiKey = oldTokens.apiKey; + } + if (tokens.accessToken.isEmpty()) { + newTokens.accessToken = oldTokens.accessToken; + newTokens.refreshToken = oldTokens.refreshToken; + newTokens.tokenType = oldTokens.tokenType; + newTokens.expiresAt = oldTokens.expiresAt; + newTokens.scope = oldTokens.scope; + } + m_pendingTokens = newTokens; } else { - addLog(tr("Received API key.")); - validateKey(key); + m_pendingTokens = tokens; } + addLog(tr("Received authorization from Nexus.")); + validateCredentials(tokens); } -void NexusConnectionUI::onSSOStateChanged(NexusSSOLogin::States s, const QString& e) +void NexusConnectionUI::onOAuthStateChanged(NXMAccessManager::OAuthState s, + const QString& e) { - if (s != NexusSSOLogin::Finished) { - // finished state is handled in onSSOKeyChanged() - const auto log = NexusSSOLogin::stateToString(s, e); + if (s != NXMAccessManager::OAuthState::Finished) { + const auto log = NXMAccessManager::stateToString(s, e); for (auto&& line : log.split("\n")) { addLog(line); @@ -205,14 +233,19 @@ void NexusConnectionUI::onValidatorFinished(ValidationAttempt::Result r, const QString& message, std::optional<APIUserAccount> user) { + Q_UNUSED(r); if (user) { NexusInterface::instance().setUserAccount(*user); addLog(tr("Received user account information")); - if (setKey(user->apiKey())) { - addLog(tr("Linked with Nexus successfully.")); + if (m_pendingTokens) { + if (persistTokens(*m_pendingTokens)) { + addLog(tr("Linked with Nexus successfully.")); + } else { + addLog(tr("Failed to store OAuth tokens.")); + } } else { - addLog(tr("Failed to set API key")); + addLog(tr("Linked with Nexus successfully.")); } } else { if (message.isEmpty()) { @@ -223,6 +256,7 @@ void NexusConnectionUI::onValidatorFinished(ValidationAttempt::Result r, } } + m_pendingTokens.reset(); updateState(); } @@ -232,26 +266,32 @@ void NexusConnectionUI::addLog(const QString& s) m_log->scrollToBottom(); } -bool NexusConnectionUI::setKey(const QString& key) +bool NexusConnectionUI::persistTokens(const NexusOAuthTokens& tokens) { - const bool ret = GlobalSettings::setNexusApiKey(key); + const bool ret = GlobalSettings::setNexusOAuthTokens(tokens); + const bool ret2 = GlobalSettings::setNexusApiKey(tokens.apiKey); + if (ret && ret2) { + NexusInterface::instance().getAccessManager()->setTokens(tokens); + } + updateState(); emit keyChanged(); - return ret; + return ret && ret2; } -bool NexusConnectionUI::clearKey() +bool NexusConnectionUI::clearCredentials() { - const auto ret = GlobalSettings::clearNexusApiKey(); + auto ret = GlobalSettings::clearNexusOAuthTokens(); + auto ret2 = GlobalSettings::clearNexusApiKey(); - NexusInterface::instance().getAccessManager()->clearApiKey(); + NexusInterface::instance().getAccessManager()->clearCredentials(); updateState(); emit keyChanged(); - return ret; + return ret && ret2; } void NexusConnectionUI::updateState() @@ -266,22 +306,29 @@ void NexusConnectionUI::updateState() }; if (m_nexusLogin && m_nexusLogin->isActive()) { - // api key is in the process of being retrieved setButton(m_connect, true, QObject::tr("Cancel")); setButton(m_disconnect, false); setButton(m_manual, false, QObject::tr("Enter API Key Manually")); } else if (m_nexusValidator && m_nexusValidator->isActive()) { - // api key is in the process of being tested setButton(m_connect, false, QObject::tr("Connect to Nexus")); setButton(m_disconnect, false); - setButton(m_manual, true, QObject::tr("Cancel")); - } else if (GlobalSettings::hasNexusApiKey()) { - // api key is present - setButton(m_connect, false, QObject::tr("Connect to Nexus")); + } else if (GlobalSettings::hasNexusOAuthTokens() || + GlobalSettings::hasNexusApiKey()) { + NexusOAuthTokens tokens; + GlobalSettings::nexusOAuthTokens(tokens); + GlobalSettings::nexusApiKey(tokens.apiKey); + if (tokens.accessToken.isEmpty()) { + setButton(m_connect, true, QObject::tr("Connect to Nexus")); + } else { + setButton(m_connect, false, QObject::tr("Connect to Nexus")); + } setButton(m_disconnect, true); - setButton(m_manual, false, QObject::tr("Enter API Key Manually")); + if (tokens.apiKey.isEmpty()) { + setButton(m_manual, true, QObject::tr("Enter API Key Manually")); + } else { + setButton(m_manual, false, QObject::tr("Enter API Key Manually")); + } } else { - // api key not present setButton(m_connect, true, QObject::tr("Connect to Nexus")); setButton(m_disconnect, false); setButton(m_manual, true, QObject::tr("Enter API Key Manually")); diff --git a/src/settingsdialognexus.h b/src/settingsdialognexus.h index 0c14ebf7..fdc1d292 100644 --- a/src/settingsdialognexus.h +++ b/src/settingsdialognexus.h @@ -1,10 +1,16 @@ #ifndef SETTINGSDIALOGNEXUS_H #define SETTINGSDIALOGNEXUS_H +#include "nexusoauthlogin.h" +#include "nexusoauthtokens.h" #include "nxmaccessmanager.h" #include "settings.h" #include "settingsdialog.h" +#include <memory> +#include <optional> +class QAbstractButton; +class QListWidget; // used by the settings dialog and the create instance dialog // class NexusConnectionUI : public QObject @@ -32,22 +38,23 @@ private: QAbstractButton* m_manual; QListWidget* m_log; - std::unique_ptr<NexusSSOLogin> m_nexusLogin; + std::unique_ptr<NexusOAuthLogin> m_nexusLogin; std::unique_ptr<NexusKeyValidator> m_nexusValidator; + std::optional<NexusOAuthTokens> m_pendingTokens; void addLog(const QString& s); void updateState(); - void validateKey(const QString& key); - bool setKey(const QString& key); - bool clearKey(); + void validateCredentials(const NexusOAuthTokens& tokens); + bool persistTokens(const NexusOAuthTokens& tokens); + bool clearCredentials(); - void onSSOKeyChanged(const QString& key); - void onSSOStateChanged(NexusSSOLogin::States s, const QString& e); + void onTokensReceived(const NexusOAuthTokens& tokens); + void onOAuthStateChanged(NXMAccessManager::OAuthState s, const QString& message); void onValidatorFinished(ValidationAttempt::Result r, const QString& message, - std::optional<APIUserAccount> useR); + std::optional<APIUserAccount> user); }; class NexusSettingsTab : public SettingsTab diff --git a/src/spawn.cpp b/src/spawn.cpp index e9ff1276..b82cbba4 100644 --- a/src/spawn.cpp +++ b/src/spawn.cpp @@ -37,6 +37,8 @@ along with Mod Organizer. If not, see <http://www.gnu.org/licenses/>. #include <uibase/utility.h> #include <usvfs/usvfs.h> +class QMessageBox; + using namespace MOBase; using namespace MOShared; diff --git a/src/tutorials/tutorial_firststeps_settings.js b/src/tutorials/tutorial_firststeps_settings.js index 94ca26a3..19926eb8 100644 --- a/src/tutorials/tutorial_firststeps_settings.js +++ b/src/tutorials/tutorial_firststeps_settings.js @@ -18,11 +18,10 @@ function getTutorialSteps() function() {
highlightItem("nexusBox", false)
- tutorial.text = qsTr("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.")
+ tutorial.text = qsTr("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.") waitForClick()
}
]
|
