diff --git a/include/config.h b/include/config.h index 17cc20d0..485e97eb 100644 --- a/include/config.h +++ b/include/config.h @@ -8,6 +8,8 @@ #include #include #include +#include +#include #include "events.h" @@ -75,6 +77,8 @@ public: this->projectSettingsTab = 0; this->warpBehaviorWarningDisabled = false; this->checkForUpdates = true; + this->lastUpdateCheckTime = QDateTime(); + this->rateLimitTimes.clear(); } void addRecentProject(QString project); void setRecentProjects(QStringList projects); @@ -107,6 +111,8 @@ public: void setProjectSettingsTab(int tab); void setWarpBehaviorWarningDisabled(bool disabled); void setCheckForUpdates(bool enabled); + void setLastUpdateCheckTime(QDateTime time); + void setRateLimitTimes(QMap map); QString getRecentProject(); QStringList getRecentProjects(); bool getReopenOnLaunch(); @@ -138,6 +144,8 @@ public: int getProjectSettingsTab(); bool getWarpBehaviorWarningDisabled(); bool getCheckForUpdates(); + QDateTime getLastUpdateCheckTime(); + QMap getRateLimitTimes(); protected: virtual QString getConfigFilepath() override; virtual void parseConfigKeyValue(QString key, QString value) override; @@ -187,6 +195,8 @@ private: int projectSettingsTab; bool warpBehaviorWarningDisabled; bool checkForUpdates; + QDateTime lastUpdateCheckTime; + QMap rateLimitTimes; }; extern PorymapConfig porymapConfig; diff --git a/include/core/network.h b/include/core/network.h new file mode 100644 index 00000000..321b3e99 --- /dev/null +++ b/include/core/network.h @@ -0,0 +1,87 @@ +#ifndef NETWORK_H +#define NETWORK_H + +/* + The two classes defined here provide a simplified interface for Qt's network classes QNetworkAccessManager and QNetworkReply. + + With the Qt classes, the workflow for a GET is roughly: generate a QNetworkRequest, give this request to QNetworkAccessManager::get, + connect the returned object to QNetworkReply::finished, and in the slot of that connection handle the various HTTP headers and attributes, + then manage errors or process the webpage's body. + + These classes handle generating the QNetworkRequest with a given URL and manage the HTTP headers in the reply. They will automatically + respect rate limits and return cached data if the webpage hasn't changed since previous requests. Instead of interacting with a QNetworkReply, + callers interact with a simplified NetworkReplyData. + Example that logs Porymap's description on GitHub: + + NetworkAccessManager * manager = new NetworkAccessManager(this); + NetworkReplyData * reply = manager->get("https://api.github.com/repos/huderlem/porymap"); + connect(reply, &NetworkReplyData::finished, [reply] () { + if (!reply->errorString().isEmpty()) { + logError(QString("Failed to read description: %1").arg(reply->errorString())); + } else { + auto webpage = QJsonDocument::fromJson(reply->body()); + logInfo(QString("Porymap: %1").arg(webpage["description"].toString())); + } + reply->deleteLater(); + }); +*/ + +#include +#include +#include +#include + +class NetworkReplyData : public QObject +{ + Q_OBJECT + +public: + QUrl url() const { return m_url; } + QUrl nextUrl() const { return m_nextUrl; } + QByteArray body() const { return m_body; } + QString errorString() const { return m_error; } + QDateTime retryAfter() const { return m_retryAfter; } + bool isFinished() const { return m_finished; } + + friend class NetworkAccessManager; + +private: + QUrl m_url; + QUrl m_nextUrl; + QByteArray m_body; + QString m_error; + QDateTime m_retryAfter; + bool m_finished; + + void finish() { + m_finished = true; + emit finished(); + }; + +signals: + void finished(); +}; + +class NetworkAccessManager : public QNetworkAccessManager +{ + Q_OBJECT + +public: + NetworkAccessManager(QObject * parent = nullptr); + ~NetworkAccessManager(); + NetworkReplyData * get(const QString &url); + NetworkReplyData * get(const QUrl &url); + +private: + // For a more complex cache we could implement a QAbstractCache for the manager + struct CacheEntry { + QString eTag; + QByteArray data; + }; + QMap cache; + QMap rateLimitTimes; + void processReply(QNetworkReply * reply, NetworkReplyData * data); + const QNetworkRequest getRequest(const QUrl &url); +}; + +#endif // NETWORK_H diff --git a/include/mainwindow.h b/include/mainwindow.h index c499a5ba..29052fd3 100644 --- a/include/mainwindow.h +++ b/include/mainwindow.h @@ -12,7 +12,6 @@ #include #include #include -#include #include "project.h" #include "orderedjson.h" #include "config.h" @@ -311,7 +310,7 @@ private: QPointer projectSettingsEditor = nullptr; QPointer customScriptsEditor = nullptr; QPointer updatePromoter = nullptr; - QPointer networkAccessManager = nullptr; + QPointer networkAccessManager = nullptr; FilterChildrenProxyModel *mapListProxyModel; QStandardItemModel *mapListModel; QList *mapGroupItemsList; diff --git a/include/ui/updatepromoter.h b/include/ui/updatepromoter.h index b9129156..4465e0dc 100644 --- a/include/ui/updatepromoter.h +++ b/include/ui/updatepromoter.h @@ -1,10 +1,10 @@ #ifndef UPDATEPROMOTER_H #define UPDATEPROMOTER_H +#include "network.h" + #include #include -#include -#include namespace Ui { class UpdatePromoter; @@ -15,24 +15,30 @@ class UpdatePromoter : public QDialog Q_OBJECT public: - explicit UpdatePromoter(QWidget *parent, QNetworkAccessManager *manager); + explicit UpdatePromoter(QWidget *parent, NetworkAccessManager *manager); ~UpdatePromoter() {}; void checkForUpdates(); - void requestDialog(); void updatePreferences(); private: Ui::UpdatePromoter *ui; - QNetworkAccessManager *const manager; - QNetworkReply * reply = nullptr; + NetworkAccessManager *const manager; QPushButton * button_Downloads; - QString downloadLink; + QPushButton * button_Retry; + QString changelog; + QUrl downloadUrl; + bool breakingChanges; + bool foundReleases; + + QSet visitedUrls; // Prevent infinite redirection void resetDialog(); - void processWebpage(const QJsonDocument &data); - void processError(const QString &err); + void get(const QUrl &url); + void processWebpage(const QJsonDocument &data, const QUrl &nextUrl); + void disableRequestsUntil(const QDateTime time); + void error(const QString &err); bool isNewerVersion(int major, int minor, int patch); private slots: diff --git a/porymap.pro b/porymap.pro index 4e883b39..cf4cc451 100644 --- a/porymap.pro +++ b/porymap.pro @@ -34,6 +34,7 @@ SOURCES += src/core/block.cpp \ src/core/mapparser.cpp \ src/core/metatile.cpp \ src/core/metatileparser.cpp \ + src/core/network.cpp \ src/core/paletteutil.cpp \ src/core/parseutil.cpp \ src/core/tile.cpp \ @@ -126,6 +127,7 @@ HEADERS += include/core/block.h \ include/core/mapparser.h \ include/core/metatile.h \ include/core/metatileparser.h \ + include/core/network.h \ include/core/paletteutil.h \ include/core/parseutil.h \ include/core/tile.h \ diff --git a/src/config.cpp b/src/config.cpp index 9cf0c54c..967c21c8 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -409,6 +409,14 @@ void PorymapConfig::parseConfigKeyValue(QString key, QString value) { this->warpBehaviorWarningDisabled = getConfigBool(key, value); } else if (key == "check_for_updates") { this->checkForUpdates = getConfigBool(key, value); + } else if (key == "last_update_check_time") { + this->lastUpdateCheckTime = QDateTime::fromString(value).toLocalTime(); + } else if (key.startsWith("rate_limit_time/")) { + static const QRegularExpression regex("\\brate_limit_time/(?.+)"); + QRegularExpressionMatch match = regex.match(key); + if (match.hasMatch()) { + this->rateLimitTimes.insert(match.captured("url"), QDateTime::fromString(value).toLocalTime()); + } } else { logWarn(QString("Invalid config key found in config file %1: '%2'").arg(this->getConfigFilepath()).arg(key)); } @@ -456,6 +464,13 @@ QMap PorymapConfig::getKeyValueMap() { map.insert("project_settings_tab", QString::number(this->projectSettingsTab)); map.insert("warp_behavior_warning_disabled", QString::number(this->warpBehaviorWarningDisabled)); map.insert("check_for_updates", QString::number(this->checkForUpdates)); + map.insert("last_update_check_time", this->lastUpdateCheckTime.toUTC().toString()); + for (auto i = this->rateLimitTimes.cbegin(), end = this->rateLimitTimes.cend(); i != end; i++){ + // Only include rate limit times that are still active (i.e., in the future) + const QDateTime time = i.value(); + if (!time.isNull() && time > QDateTime::currentDateTime()) + map.insert("rate_limit_time/" + i.key().toString(), time.toUTC().toString()); + } return map; } @@ -634,6 +649,26 @@ void PorymapConfig::setProjectSettingsTab(int tab) { this->save(); } +void PorymapConfig::setWarpBehaviorWarningDisabled(bool disabled) { + this->warpBehaviorWarningDisabled = disabled; + this->save(); +} + +void PorymapConfig::setCheckForUpdates(bool enabled) { + this->checkForUpdates = enabled; + this->save(); +} + +void PorymapConfig::setLastUpdateCheckTime(QDateTime time) { + this->lastUpdateCheckTime = time; + this->save(); +} + +void PorymapConfig::setRateLimitTimes(QMap map) { + this->rateLimitTimes = map; + this->save(); +} + QString PorymapConfig::getRecentProject() { return this->recentProjects.value(0); } @@ -784,24 +819,22 @@ int PorymapConfig::getProjectSettingsTab() { return this->projectSettingsTab; } -void PorymapConfig::setWarpBehaviorWarningDisabled(bool disabled) { - this->warpBehaviorWarningDisabled = disabled; - this->save(); -} - bool PorymapConfig::getWarpBehaviorWarningDisabled() { return this->warpBehaviorWarningDisabled; } -void PorymapConfig::setCheckForUpdates(bool enabled) { - this->checkForUpdates = enabled; - this->save(); -} - bool PorymapConfig::getCheckForUpdates() { return this->checkForUpdates; } +QDateTime PorymapConfig::getLastUpdateCheckTime() { + return this->lastUpdateCheckTime; +} + +QMap PorymapConfig::getRateLimitTimes() { + return this->rateLimitTimes; +} + const QStringList ProjectConfig::versionStrings = { "pokeruby", "pokefirered", diff --git a/src/core/network.cpp b/src/core/network.cpp new file mode 100644 index 00000000..3f2d3cb2 --- /dev/null +++ b/src/core/network.cpp @@ -0,0 +1,146 @@ +#include "network.h" +#include "config.h" + +#include +#include +#include + +// Fallback wait time (in seconds) for rate limiting +static const int DefaultWaitTime = 120; + +NetworkAccessManager::NetworkAccessManager(QObject * parent) : QNetworkAccessManager(parent) { + // We store rate limit end times in the user's config so that Porymap will still respect them after a restart. + // To avoid reading/writing to a local file during network operations, we only read/write the file when the + // manager is created/destroyed respectively. + this->rateLimitTimes = porymapConfig.getRateLimitTimes(); + this->setRedirectPolicy(QNetworkRequest::NoLessSafeRedirectPolicy); +}; + +NetworkAccessManager::~NetworkAccessManager() { + porymapConfig.setRateLimitTimes(this->rateLimitTimes); + qDeleteAll(this->cache); +} + +const QNetworkRequest NetworkAccessManager::getRequest(const QUrl &url) { + QNetworkRequest request(url); + + // Set User-Agent to porymap/#.#.# + request.setHeader(QNetworkRequest::UserAgentHeader, QString("%1/%2").arg(QCoreApplication::applicationName()) + .arg(QCoreApplication::applicationVersion())); + + // If we've made a successful request in this session already, set the If-None-Match header. + // We'll only get a full response from the server if the data has changed since this last request. + // This helps to avoid hitting rate limits. + auto cacheEntry = this->cache.value(url, nullptr); + if (cacheEntry) + request.setHeader(QNetworkRequest::IfNoneMatchHeader, cacheEntry->eTag); + + return request; +} + +NetworkReplyData * NetworkAccessManager::get(const QString &url) { + return this->get(QUrl(url)); +} + +NetworkReplyData * NetworkAccessManager::get(const QUrl &url) { + NetworkReplyData * data = new NetworkReplyData(); + data->m_url = url; + + // If we are rate-limited, don't send a new request. + if (this->rateLimitTimes.contains(url)) { + auto time = this->rateLimitTimes.value(url); + if (!time.isNull() && time > QDateTime::currentDateTime()) { + data->m_retryAfter = time; + data->m_error = QString("Rate limit reached. Please try again after %1.").arg(data->m_retryAfter.toString()); + QTimer::singleShot(1000, data, &NetworkReplyData::finish); // We can't emit this signal before caller has a chance to connect + return data; + } + // Rate limiting expired + this->rateLimitTimes.remove(url); + } + + QNetworkReply * reply = QNetworkAccessManager::get(this->getRequest(url)); + connect(reply, &QNetworkReply::finished, [this, reply, data] { + this->processReply(reply, data); + data->finish(); + }); + + return data; +} + +void NetworkAccessManager::processReply(QNetworkReply * reply, NetworkReplyData * data) { + if (!reply || !reply->isFinished()) + return; + + auto url = reply->url(); + reply->deleteLater(); + + int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + // Handle pagination (specifically, the format GitHub uses). + // This header is still sent for a 304, so we don't need to bother caching it. + if (reply->hasRawHeader("link")) { + static const QRegularExpression regex("<(?.+)?>; rel=\"next\""); + QRegularExpressionMatch match = regex.match(QString(reply->rawHeader("link"))); + if (match.hasMatch()) + data->m_nextUrl = QUrl(match.captured("url")); + } + + if (statusCode == 304) { + // "Not Modified", data hasn't changed since our last request. + data->m_body = this->cache.value(url)->data; + return; + } + + // Handle standard rate limit header + if (reply->hasRawHeader("retry-after")) { + auto retryAfter = QVariant(reply->rawHeader("retry-after")); + if (retryAfter.canConvert()) { + data->m_retryAfter = retryAfter.toDateTime().toLocalTime(); + } else if (retryAfter.canConvert()) { + data->m_retryAfter = QDateTime::currentDateTime().addSecs(retryAfter.toInt()); + } + if (data->m_retryAfter.isNull() || data->m_retryAfter <= QDateTime::currentDateTime()) { + data->m_retryAfter = QDateTime::currentDateTime().addSecs(DefaultWaitTime); + } + if (statusCode == 429) { + data->m_error = "Too many requests. "; + } else if (statusCode == 503) { + data->m_error = "Service busy or unavailable. "; + } + data->m_error.append(QString("Please try again after %1.").arg(data->m_retryAfter.toString())); + this->rateLimitTimes.insert(url, data->m_retryAfter); + return; + } + + // Handle GitHub's rate limit headers. As of writing this is (without authentication) 60 requests per IP address per hour. + bool ok; + int limitRemaining = reply->rawHeader("x-ratelimit-remaining").toInt(&ok); + if (ok && limitRemaining <= 0) { + auto limitReset = reply->rawHeader("x-ratelimit-reset").toLongLong(&ok); + data->m_retryAfter = ok ? QDateTime::fromSecsSinceEpoch(limitReset).toLocalTime() + : QDateTime::currentDateTime().addSecs(DefaultWaitTime);; + data->m_error = QString("Too many requests. Please try again after %1.").arg(data->m_retryAfter.toString()); + this->rateLimitTimes.insert(url, data->m_retryAfter); + return; + } + + // Handle remaining errors generically + auto error = reply->error(); + if (error != QNetworkReply::NoError) { + data->m_error = reply->errorString(); + return; + } + + // Successful reply, we've received new data. Insert this data in the cache. + CacheEntry * cacheEntry = this->cache.value(url, nullptr); + if (!cacheEntry) { + cacheEntry = new CacheEntry; + this->cache.insert(url, cacheEntry); + } + auto eTagHeader = reply->header(QNetworkRequest::ETagHeader); + if (eTagHeader.isValid()) + cacheEntry->eTag = eTagHeader.toString(); + + cacheEntry->data = data->m_body = reply->readAll(); +} diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 84c7fd43..bc259533 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -59,6 +59,7 @@ MainWindow::MainWindow(QWidget *parent) : ui->setupUi(this); cleanupLargeLog(); + logInfo(QString("Launching Porymap v%1").arg(PORYMAP_VERSION)); this->initWindow(); if (porymapConfig.getReopenOnLaunch() && this->openProject(porymapConfig.getRecentProject(), true)) @@ -255,7 +256,7 @@ void MainWindow::on_actionCheck_for_Updates_triggered() { void MainWindow::checkForUpdates(bool requestedByUser) { if (!this->networkAccessManager) - this->networkAccessManager = new QNetworkAccessManager(this); + this->networkAccessManager = new NetworkAccessManager(this); if (!this->updatePromoter) { this->updatePromoter = new UpdatePromoter(this, this->networkAccessManager); @@ -265,9 +266,17 @@ void MainWindow::checkForUpdates(bool requestedByUser) { }); } - if (requestedByUser) - this->updatePromoter->requestDialog(); + + if (requestedByUser) { + openSubWindow(this->updatePromoter); + } else { + // This is an automatic update check. Only run if we haven't done one in the last 5 minutes + QDateTime lastCheck = porymapConfig.getLastUpdateCheckTime(); + if (lastCheck.addSecs(5*60) >= QDateTime::currentDateTime()) + return; + } this->updatePromoter->checkForUpdates(); + porymapConfig.setLastUpdateCheckTime(QDateTime::currentDateTime()); } void MainWindow::initEditor() { diff --git a/src/ui/updatepromoter.cpp b/src/ui/updatepromoter.cpp index f8270b4e..71f4dce9 100644 --- a/src/ui/updatepromoter.cpp +++ b/src/ui/updatepromoter.cpp @@ -3,13 +3,13 @@ #include "log.h" #include "config.h" -#include #include #include #include #include +#include -UpdatePromoter::UpdatePromoter(QWidget *parent, QNetworkAccessManager *manager) +UpdatePromoter::UpdatePromoter(QWidget *parent, NetworkAccessManager *manager) : QDialog(parent), ui(new Ui::UpdatePromoter), manager(manager) @@ -18,6 +18,7 @@ UpdatePromoter::UpdatePromoter(QWidget *parent, QNetworkAccessManager *manager) // Set up "Do not alert me" check box this->updatePreferences(); + ui->checkBox_StopAlerts->setVisible(false); connect(ui->checkBox_StopAlerts, &QCheckBox::stateChanged, [this](int state) { bool enable = (state != Qt::Checked); porymapConfig.setCheckForUpdates(enable); @@ -25,7 +26,9 @@ UpdatePromoter::UpdatePromoter(QWidget *parent, QNetworkAccessManager *manager) }); // Set up button box + this->button_Retry = ui->buttonBox->button(QDialogButtonBox::Retry); this->button_Downloads = ui->buttonBox->addButton("Go to Downloads...", QDialogButtonBox::ActionRole); + ui->buttonBox->button(QDialogButtonBox::Close)->setDefault(true); connect(ui->buttonBox, &QDialogButtonBox::clicked, this, &UpdatePromoter::dialogButtonClicked); this->resetDialog(); @@ -33,47 +36,55 @@ UpdatePromoter::UpdatePromoter(QWidget *parent, QNetworkAccessManager *manager) void UpdatePromoter::resetDialog() { this->button_Downloads->setEnabled(false); + ui->text_Changelog->setVisible(false); ui->label_Warning->setVisible(false); - ui->label_Status->setText("Checking for updates..."); + ui->label_Status->setText(""); this->changelog = QString(); - this->downloadLink = QString(); + this->downloadUrl = QString(); + this->breakingChanges = false; + this->foundReleases = false; + this->visitedUrls.clear(); } void UpdatePromoter::checkForUpdates() { - // Ignore request if one is still active. - if (this->reply && !this->reply->isFinished()) + // If the Retry button is disabled, making requests is disabled + if (!this->button_Retry->isEnabled()) return; - this->resetDialog(); - ui->buttonBox->button(QDialogButtonBox::Retry)->setEnabled(false); - // We could get ".../releases/latest" to retrieve less data, but this would run into problems if the + this->resetDialog(); + this->button_Retry->setEnabled(false); + ui->label_Status->setText("Checking for updates..."); + + // We could use the URL ".../releases/latest" to retrieve less data, but this would run into problems if the // most recent item on the releases page is not actually a new release (like the static windows build). // By getting all releases we can also present a multi-version changelog of all changes since the host release. - static const QNetworkRequest request(QUrl("https://api.github.com/repos/huderlem/porymap/releases")); - this->reply = this->manager->get(request); + static const QUrl url("https://api.github.com/repos/huderlem/porymap/releases"); + this->get(url); +} - connect(this->reply, &QNetworkReply::finished, [this] { - ui->buttonBox->button(QDialogButtonBox::Retry)->setEnabled(true); - auto error = this->reply->error(); - if (error == QNetworkReply::NoError) { - this->processWebpage(QJsonDocument::fromJson(this->reply->readAll())); +void UpdatePromoter::get(const QUrl &url) { + this->visitedUrls.insert(url); + auto reply = this->manager->get(url); + connect(reply, &NetworkReplyData::finished, [this, reply] () { + if (!reply->errorString().isEmpty()) { + this->error(reply->errorString()); + this->disableRequestsUntil(reply->retryAfter()); } else { - this->processError(this->reply->errorString()); + this->processWebpage(QJsonDocument::fromJson(reply->body()), reply->nextUrl()); } + reply->deleteLater(); }); } // Read all the items on the releases page, ignoring entries without a version identifier tag. // Objects in the releases page data are sorted newest to oldest. -void UpdatePromoter::processWebpage(const QJsonDocument &data) { - bool updateAvailable = false; - bool breakingChanges = false; - bool foundRelease = false; - +// Returns true when finished, returns false to request processing for the next page. +void UpdatePromoter::processWebpage(const QJsonDocument &data, const QUrl &nextUrl) { const QJsonArray releases = data.array(); - for (int i = 0; i < releases.size(); i++) { + int i; + for (i = 0; i < releases.size(); i++) { auto release = releases.at(i).toObject(); // Convert tag string to version numbers @@ -89,57 +100,77 @@ void UpdatePromoter::processWebpage(const QJsonDocument &data) { if (!ok) continue; // We've found a valid release tag. If the version number is not newer than the host version then we can stop looking at releases. - foundRelease = true; + this->foundReleases = true; if (!this->isNewerVersion(major, minor, patch)) break; const QString description = release.value("body").toString(); - const QString url = release.value("html_url").toString(); - if (description.isEmpty() || url.isEmpty()) { + if (description.isEmpty()) { // If the release was published very recently it won't have a description yet, in which case don't tell the user about it yet. - // If there's no URL, something has gone wrong and we should skip this release. continue; } - if (this->downloadLink.isEmpty()) { + if (this->downloadUrl.isEmpty()) { // This is the first (newest) release we've found. Record its URL for download. - this->downloadLink = url; - breakingChanges = (major > PORYMAP_VERSION_MAJOR); + const QUrl url = QUrl(release.value("html_url").toString()); + if (url.isEmpty()) { + // If there's no URL, something has gone wrong and we should skip this release. + continue; + } + this->downloadUrl = url; + this->breakingChanges = (major > PORYMAP_VERSION_MAJOR); } // Record the changelog of this release so we can show all changes since the host release. this->changelog.append(QString("## %1\n%2\n\n").arg(tagName).arg(description)); - updateAvailable = true; } - if (!foundRelease) { - // We retrieved the webpage but didn't successfully parse any releases. - this->processError("Error parsing releases webpage"); + // If we read the entire page then we didn't find a release as old as the host version. + // Keep looking on the second page, there might still be new releases there. + if (i == releases.size() && !nextUrl.isEmpty() && !this->visitedUrls.contains(nextUrl)) { + this->get(nextUrl); return; } - // If there's a new update available the dialog will always be opened. - // Otherwise the dialog is only open if the user requested it. - if (updateAvailable) { - this->button_Downloads->setEnabled(!this->downloadLink.isEmpty()); - ui->text_Changelog->setMarkdown(this->changelog); - ui->text_Changelog->setVisible(true); - ui->label_Warning->setVisible(breakingChanges); - ui->label_Status->setText("A new version of Porymap is available!"); + if (!this->foundReleases) { + // We retrieved the webpage but didn't successfully parse any releases. + this->error("Error parsing releases webpage"); + return; + } + + // Populate dialog with result + bool updateAvailable = !this->changelog.isEmpty(); + ui->label_Status->setText(updateAvailable ? "A new version of Porymap is available!" + : "Your version of Porymap is up to date!"); + ui->label_Warning->setVisible(this->breakingChanges); + ui->text_Changelog->setMarkdown(this->changelog); + ui->text_Changelog->setVisible(updateAvailable); + this->button_Downloads->setEnabled(!this->downloadUrl.isEmpty()); + this->button_Retry->setEnabled(true); + + // Alert the user if there's a new update available and the dialog wasn't already open. + // Show the window, but also show the option to turn off automatic alerts in the future. + if (updateAvailable && !this->isVisible()) { + ui->checkBox_StopAlerts->setVisible(true); this->show(); - } else { - // The rest of the UI remains in the state set by resetDialog - ui->label_Status->setText("Your version of Porymap is up to date!"); - } + } } -void UpdatePromoter::processError(const QString &err) { +void UpdatePromoter::disableRequestsUntil(const QDateTime time) { + this->button_Retry->setEnabled(false); + + auto timeUntil = QDateTime::currentDateTime().msecsTo(time); + if (timeUntil < 0) timeUntil = 0; + QTimer::singleShot(timeUntil, Qt::VeryCoarseTimer, [this]() { + this->button_Retry->setEnabled(true); + }); +} + +void UpdatePromoter::error(const QString &err) { const QString message = QString("Failed to check for version update: %1").arg(err); - if (this->isVisible()) { - ui->label_Status->setText(message); - } else { + ui->label_Status->setText(message); + if (!this->isVisible()) logWarn(message); - } } bool UpdatePromoter::isNewerVersion(int major, int minor, int patch) { @@ -150,35 +181,17 @@ bool UpdatePromoter::isNewerVersion(int major, int minor, int patch) { return patch > PORYMAP_VERSION_PATCH; } -// The dialog can either be shown programmatically when an update is available -// or if the user manually selects "Check for Updates" in the menu. -// When the dialog is shown programmatically there is a check box to disable automatic alerts. -// If the user requested the dialog (and it wasn't already open) this check box should be hidden. -void UpdatePromoter::requestDialog() { - if (!this->isVisible()){ - ui->checkBox_StopAlerts->setVisible(false); - this->show(); - } else if (this->isMinimized()) { - this->showNormal(); - } else { - this->raise(); - this->activateWindow(); - } -} - void UpdatePromoter::updatePreferences() { const QSignalBlocker blocker(ui->checkBox_StopAlerts); - ui->checkBox_StopAlerts->setChecked(porymapConfig.getCheckForUpdates()); + ui->checkBox_StopAlerts->setChecked(!porymapConfig.getCheckForUpdates()); } void UpdatePromoter::dialogButtonClicked(QAbstractButton *button) { - auto buttonRole = ui->buttonBox->buttonRole(button); - if (buttonRole == QDialogButtonBox::RejectRole) { + if (ui->buttonBox->buttonRole(button) == QDialogButtonBox::RejectRole) { this->close(); - } else if (buttonRole == QDialogButtonBox::AcceptRole) { - // "Retry" button + } else if (button == this->button_Retry) { this->checkForUpdates(); - } else if (button == this->button_Downloads && !this->downloadLink.isEmpty()) { - QDesktopServices::openUrl(QUrl(this->downloadLink)); + } else if (button == this->button_Downloads) { + QDesktopServices::openUrl(this->downloadUrl); } }