Better client etiquette
This commit is contained in:
parent
34b2f9d881
commit
a5ed554c68
9 changed files with 403 additions and 98 deletions
|
@ -8,6 +8,8 @@
|
||||||
#include <QSize>
|
#include <QSize>
|
||||||
#include <QKeySequence>
|
#include <QKeySequence>
|
||||||
#include <QMultiMap>
|
#include <QMultiMap>
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QUrl>
|
||||||
|
|
||||||
#include "events.h"
|
#include "events.h"
|
||||||
|
|
||||||
|
@ -75,6 +77,8 @@ public:
|
||||||
this->projectSettingsTab = 0;
|
this->projectSettingsTab = 0;
|
||||||
this->warpBehaviorWarningDisabled = false;
|
this->warpBehaviorWarningDisabled = false;
|
||||||
this->checkForUpdates = true;
|
this->checkForUpdates = true;
|
||||||
|
this->lastUpdateCheckTime = QDateTime();
|
||||||
|
this->rateLimitTimes.clear();
|
||||||
}
|
}
|
||||||
void addRecentProject(QString project);
|
void addRecentProject(QString project);
|
||||||
void setRecentProjects(QStringList projects);
|
void setRecentProjects(QStringList projects);
|
||||||
|
@ -107,6 +111,8 @@ public:
|
||||||
void setProjectSettingsTab(int tab);
|
void setProjectSettingsTab(int tab);
|
||||||
void setWarpBehaviorWarningDisabled(bool disabled);
|
void setWarpBehaviorWarningDisabled(bool disabled);
|
||||||
void setCheckForUpdates(bool enabled);
|
void setCheckForUpdates(bool enabled);
|
||||||
|
void setLastUpdateCheckTime(QDateTime time);
|
||||||
|
void setRateLimitTimes(QMap<QUrl, QDateTime> map);
|
||||||
QString getRecentProject();
|
QString getRecentProject();
|
||||||
QStringList getRecentProjects();
|
QStringList getRecentProjects();
|
||||||
bool getReopenOnLaunch();
|
bool getReopenOnLaunch();
|
||||||
|
@ -138,6 +144,8 @@ public:
|
||||||
int getProjectSettingsTab();
|
int getProjectSettingsTab();
|
||||||
bool getWarpBehaviorWarningDisabled();
|
bool getWarpBehaviorWarningDisabled();
|
||||||
bool getCheckForUpdates();
|
bool getCheckForUpdates();
|
||||||
|
QDateTime getLastUpdateCheckTime();
|
||||||
|
QMap<QUrl, QDateTime> getRateLimitTimes();
|
||||||
protected:
|
protected:
|
||||||
virtual QString getConfigFilepath() override;
|
virtual QString getConfigFilepath() override;
|
||||||
virtual void parseConfigKeyValue(QString key, QString value) override;
|
virtual void parseConfigKeyValue(QString key, QString value) override;
|
||||||
|
@ -187,6 +195,8 @@ private:
|
||||||
int projectSettingsTab;
|
int projectSettingsTab;
|
||||||
bool warpBehaviorWarningDisabled;
|
bool warpBehaviorWarningDisabled;
|
||||||
bool checkForUpdates;
|
bool checkForUpdates;
|
||||||
|
QDateTime lastUpdateCheckTime;
|
||||||
|
QMap<QUrl, QDateTime> rateLimitTimes;
|
||||||
};
|
};
|
||||||
|
|
||||||
extern PorymapConfig porymapConfig;
|
extern PorymapConfig porymapConfig;
|
||||||
|
|
87
include/core/network.h
Normal file
87
include/core/network.h
Normal file
|
@ -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 <QNetworkAccessManager>
|
||||||
|
#include <QNetworkRequest>
|
||||||
|
#include <QNetworkReply>
|
||||||
|
#include <QDateTime>
|
||||||
|
|
||||||
|
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<QUrl, CacheEntry*> cache;
|
||||||
|
QMap<QUrl, QDateTime> rateLimitTimes;
|
||||||
|
void processReply(QNetworkReply * reply, NetworkReplyData * data);
|
||||||
|
const QNetworkRequest getRequest(const QUrl &url);
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // NETWORK_H
|
|
@ -12,7 +12,6 @@
|
||||||
#include <QCloseEvent>
|
#include <QCloseEvent>
|
||||||
#include <QAbstractItemModel>
|
#include <QAbstractItemModel>
|
||||||
#include <QJSValue>
|
#include <QJSValue>
|
||||||
#include <QNetworkAccessManager>
|
|
||||||
#include "project.h"
|
#include "project.h"
|
||||||
#include "orderedjson.h"
|
#include "orderedjson.h"
|
||||||
#include "config.h"
|
#include "config.h"
|
||||||
|
@ -311,7 +310,7 @@ private:
|
||||||
QPointer<ProjectSettingsEditor> projectSettingsEditor = nullptr;
|
QPointer<ProjectSettingsEditor> projectSettingsEditor = nullptr;
|
||||||
QPointer<CustomScriptsEditor> customScriptsEditor = nullptr;
|
QPointer<CustomScriptsEditor> customScriptsEditor = nullptr;
|
||||||
QPointer<UpdatePromoter> updatePromoter = nullptr;
|
QPointer<UpdatePromoter> updatePromoter = nullptr;
|
||||||
QPointer<QNetworkAccessManager> networkAccessManager = nullptr;
|
QPointer<NetworkAccessManager> networkAccessManager = nullptr;
|
||||||
FilterChildrenProxyModel *mapListProxyModel;
|
FilterChildrenProxyModel *mapListProxyModel;
|
||||||
QStandardItemModel *mapListModel;
|
QStandardItemModel *mapListModel;
|
||||||
QList<QStandardItem*> *mapGroupItemsList;
|
QList<QStandardItem*> *mapGroupItemsList;
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
#ifndef UPDATEPROMOTER_H
|
#ifndef UPDATEPROMOTER_H
|
||||||
#define UPDATEPROMOTER_H
|
#define UPDATEPROMOTER_H
|
||||||
|
|
||||||
|
#include "network.h"
|
||||||
|
|
||||||
#include <QDialog>
|
#include <QDialog>
|
||||||
#include <QPushButton>
|
#include <QPushButton>
|
||||||
#include <QNetworkAccessManager>
|
|
||||||
#include <QNetworkReply>
|
|
||||||
|
|
||||||
namespace Ui {
|
namespace Ui {
|
||||||
class UpdatePromoter;
|
class UpdatePromoter;
|
||||||
|
@ -15,24 +15,30 @@ class UpdatePromoter : public QDialog
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit UpdatePromoter(QWidget *parent, QNetworkAccessManager *manager);
|
explicit UpdatePromoter(QWidget *parent, NetworkAccessManager *manager);
|
||||||
~UpdatePromoter() {};
|
~UpdatePromoter() {};
|
||||||
|
|
||||||
void checkForUpdates();
|
void checkForUpdates();
|
||||||
void requestDialog();
|
|
||||||
void updatePreferences();
|
void updatePreferences();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
Ui::UpdatePromoter *ui;
|
Ui::UpdatePromoter *ui;
|
||||||
QNetworkAccessManager *const manager;
|
NetworkAccessManager *const manager;
|
||||||
QNetworkReply * reply = nullptr;
|
|
||||||
QPushButton * button_Downloads;
|
QPushButton * button_Downloads;
|
||||||
QString downloadLink;
|
QPushButton * button_Retry;
|
||||||
|
|
||||||
QString changelog;
|
QString changelog;
|
||||||
|
QUrl downloadUrl;
|
||||||
|
bool breakingChanges;
|
||||||
|
bool foundReleases;
|
||||||
|
|
||||||
|
QSet<QUrl> visitedUrls; // Prevent infinite redirection
|
||||||
|
|
||||||
void resetDialog();
|
void resetDialog();
|
||||||
void processWebpage(const QJsonDocument &data);
|
void get(const QUrl &url);
|
||||||
void processError(const QString &err);
|
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);
|
bool isNewerVersion(int major, int minor, int patch);
|
||||||
|
|
||||||
private slots:
|
private slots:
|
||||||
|
|
|
@ -34,6 +34,7 @@ SOURCES += src/core/block.cpp \
|
||||||
src/core/mapparser.cpp \
|
src/core/mapparser.cpp \
|
||||||
src/core/metatile.cpp \
|
src/core/metatile.cpp \
|
||||||
src/core/metatileparser.cpp \
|
src/core/metatileparser.cpp \
|
||||||
|
src/core/network.cpp \
|
||||||
src/core/paletteutil.cpp \
|
src/core/paletteutil.cpp \
|
||||||
src/core/parseutil.cpp \
|
src/core/parseutil.cpp \
|
||||||
src/core/tile.cpp \
|
src/core/tile.cpp \
|
||||||
|
@ -126,6 +127,7 @@ HEADERS += include/core/block.h \
|
||||||
include/core/mapparser.h \
|
include/core/mapparser.h \
|
||||||
include/core/metatile.h \
|
include/core/metatile.h \
|
||||||
include/core/metatileparser.h \
|
include/core/metatileparser.h \
|
||||||
|
include/core/network.h \
|
||||||
include/core/paletteutil.h \
|
include/core/paletteutil.h \
|
||||||
include/core/parseutil.h \
|
include/core/parseutil.h \
|
||||||
include/core/tile.h \
|
include/core/tile.h \
|
||||||
|
|
|
@ -409,6 +409,14 @@ void PorymapConfig::parseConfigKeyValue(QString key, QString value) {
|
||||||
this->warpBehaviorWarningDisabled = getConfigBool(key, value);
|
this->warpBehaviorWarningDisabled = getConfigBool(key, value);
|
||||||
} else if (key == "check_for_updates") {
|
} else if (key == "check_for_updates") {
|
||||||
this->checkForUpdates = getConfigBool(key, value);
|
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/(?<url>.+)");
|
||||||
|
QRegularExpressionMatch match = regex.match(key);
|
||||||
|
if (match.hasMatch()) {
|
||||||
|
this->rateLimitTimes.insert(match.captured("url"), QDateTime::fromString(value).toLocalTime());
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
logWarn(QString("Invalid config key found in config file %1: '%2'").arg(this->getConfigFilepath()).arg(key));
|
logWarn(QString("Invalid config key found in config file %1: '%2'").arg(this->getConfigFilepath()).arg(key));
|
||||||
}
|
}
|
||||||
|
@ -456,6 +464,13 @@ QMap<QString, QString> PorymapConfig::getKeyValueMap() {
|
||||||
map.insert("project_settings_tab", QString::number(this->projectSettingsTab));
|
map.insert("project_settings_tab", QString::number(this->projectSettingsTab));
|
||||||
map.insert("warp_behavior_warning_disabled", QString::number(this->warpBehaviorWarningDisabled));
|
map.insert("warp_behavior_warning_disabled", QString::number(this->warpBehaviorWarningDisabled));
|
||||||
map.insert("check_for_updates", QString::number(this->checkForUpdates));
|
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;
|
return map;
|
||||||
}
|
}
|
||||||
|
@ -634,6 +649,26 @@ void PorymapConfig::setProjectSettingsTab(int tab) {
|
||||||
this->save();
|
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<QUrl, QDateTime> map) {
|
||||||
|
this->rateLimitTimes = map;
|
||||||
|
this->save();
|
||||||
|
}
|
||||||
|
|
||||||
QString PorymapConfig::getRecentProject() {
|
QString PorymapConfig::getRecentProject() {
|
||||||
return this->recentProjects.value(0);
|
return this->recentProjects.value(0);
|
||||||
}
|
}
|
||||||
|
@ -784,24 +819,22 @@ int PorymapConfig::getProjectSettingsTab() {
|
||||||
return this->projectSettingsTab;
|
return this->projectSettingsTab;
|
||||||
}
|
}
|
||||||
|
|
||||||
void PorymapConfig::setWarpBehaviorWarningDisabled(bool disabled) {
|
|
||||||
this->warpBehaviorWarningDisabled = disabled;
|
|
||||||
this->save();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool PorymapConfig::getWarpBehaviorWarningDisabled() {
|
bool PorymapConfig::getWarpBehaviorWarningDisabled() {
|
||||||
return this->warpBehaviorWarningDisabled;
|
return this->warpBehaviorWarningDisabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
void PorymapConfig::setCheckForUpdates(bool enabled) {
|
|
||||||
this->checkForUpdates = enabled;
|
|
||||||
this->save();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool PorymapConfig::getCheckForUpdates() {
|
bool PorymapConfig::getCheckForUpdates() {
|
||||||
return this->checkForUpdates;
|
return this->checkForUpdates;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QDateTime PorymapConfig::getLastUpdateCheckTime() {
|
||||||
|
return this->lastUpdateCheckTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
QMap<QUrl, QDateTime> PorymapConfig::getRateLimitTimes() {
|
||||||
|
return this->rateLimitTimes;
|
||||||
|
}
|
||||||
|
|
||||||
const QStringList ProjectConfig::versionStrings = {
|
const QStringList ProjectConfig::versionStrings = {
|
||||||
"pokeruby",
|
"pokeruby",
|
||||||
"pokefirered",
|
"pokefirered",
|
||||||
|
|
146
src/core/network.cpp
Normal file
146
src/core/network.cpp
Normal file
|
@ -0,0 +1,146 @@
|
||||||
|
#include "network.h"
|
||||||
|
#include "config.h"
|
||||||
|
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QRegularExpression>
|
||||||
|
#include <QTimer>
|
||||||
|
|
||||||
|
// 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("<(?<url>.+)?>; 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<QDateTime>()) {
|
||||||
|
data->m_retryAfter = retryAfter.toDateTime().toLocalTime();
|
||||||
|
} else if (retryAfter.canConvert<int>()) {
|
||||||
|
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();
|
||||||
|
}
|
|
@ -59,6 +59,7 @@ MainWindow::MainWindow(QWidget *parent) :
|
||||||
ui->setupUi(this);
|
ui->setupUi(this);
|
||||||
|
|
||||||
cleanupLargeLog();
|
cleanupLargeLog();
|
||||||
|
logInfo(QString("Launching Porymap v%1").arg(PORYMAP_VERSION));
|
||||||
|
|
||||||
this->initWindow();
|
this->initWindow();
|
||||||
if (porymapConfig.getReopenOnLaunch() && this->openProject(porymapConfig.getRecentProject(), true))
|
if (porymapConfig.getReopenOnLaunch() && this->openProject(porymapConfig.getRecentProject(), true))
|
||||||
|
@ -255,7 +256,7 @@ void MainWindow::on_actionCheck_for_Updates_triggered() {
|
||||||
|
|
||||||
void MainWindow::checkForUpdates(bool requestedByUser) {
|
void MainWindow::checkForUpdates(bool requestedByUser) {
|
||||||
if (!this->networkAccessManager)
|
if (!this->networkAccessManager)
|
||||||
this->networkAccessManager = new QNetworkAccessManager(this);
|
this->networkAccessManager = new NetworkAccessManager(this);
|
||||||
|
|
||||||
if (!this->updatePromoter) {
|
if (!this->updatePromoter) {
|
||||||
this->updatePromoter = new UpdatePromoter(this, this->networkAccessManager);
|
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();
|
this->updatePromoter->checkForUpdates();
|
||||||
|
porymapConfig.setLastUpdateCheckTime(QDateTime::currentDateTime());
|
||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::initEditor() {
|
void MainWindow::initEditor() {
|
||||||
|
|
|
@ -3,13 +3,13 @@
|
||||||
#include "log.h"
|
#include "log.h"
|
||||||
#include "config.h"
|
#include "config.h"
|
||||||
|
|
||||||
#include <QNetworkRequest>
|
|
||||||
#include <QJsonDocument>
|
#include <QJsonDocument>
|
||||||
#include <QJsonArray>
|
#include <QJsonArray>
|
||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
#include <QDesktopServices>
|
#include <QDesktopServices>
|
||||||
|
#include <QTimer>
|
||||||
|
|
||||||
UpdatePromoter::UpdatePromoter(QWidget *parent, QNetworkAccessManager *manager)
|
UpdatePromoter::UpdatePromoter(QWidget *parent, NetworkAccessManager *manager)
|
||||||
: QDialog(parent),
|
: QDialog(parent),
|
||||||
ui(new Ui::UpdatePromoter),
|
ui(new Ui::UpdatePromoter),
|
||||||
manager(manager)
|
manager(manager)
|
||||||
|
@ -18,6 +18,7 @@ UpdatePromoter::UpdatePromoter(QWidget *parent, QNetworkAccessManager *manager)
|
||||||
|
|
||||||
// Set up "Do not alert me" check box
|
// Set up "Do not alert me" check box
|
||||||
this->updatePreferences();
|
this->updatePreferences();
|
||||||
|
ui->checkBox_StopAlerts->setVisible(false);
|
||||||
connect(ui->checkBox_StopAlerts, &QCheckBox::stateChanged, [this](int state) {
|
connect(ui->checkBox_StopAlerts, &QCheckBox::stateChanged, [this](int state) {
|
||||||
bool enable = (state != Qt::Checked);
|
bool enable = (state != Qt::Checked);
|
||||||
porymapConfig.setCheckForUpdates(enable);
|
porymapConfig.setCheckForUpdates(enable);
|
||||||
|
@ -25,7 +26,9 @@ UpdatePromoter::UpdatePromoter(QWidget *parent, QNetworkAccessManager *manager)
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set up button box
|
// Set up button box
|
||||||
|
this->button_Retry = ui->buttonBox->button(QDialogButtonBox::Retry);
|
||||||
this->button_Downloads = ui->buttonBox->addButton("Go to Downloads...", QDialogButtonBox::ActionRole);
|
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);
|
connect(ui->buttonBox, &QDialogButtonBox::clicked, this, &UpdatePromoter::dialogButtonClicked);
|
||||||
|
|
||||||
this->resetDialog();
|
this->resetDialog();
|
||||||
|
@ -33,47 +36,55 @@ UpdatePromoter::UpdatePromoter(QWidget *parent, QNetworkAccessManager *manager)
|
||||||
|
|
||||||
void UpdatePromoter::resetDialog() {
|
void UpdatePromoter::resetDialog() {
|
||||||
this->button_Downloads->setEnabled(false);
|
this->button_Downloads->setEnabled(false);
|
||||||
|
|
||||||
ui->text_Changelog->setVisible(false);
|
ui->text_Changelog->setVisible(false);
|
||||||
ui->label_Warning->setVisible(false);
|
ui->label_Warning->setVisible(false);
|
||||||
ui->label_Status->setText("Checking for updates...");
|
ui->label_Status->setText("");
|
||||||
|
|
||||||
this->changelog = QString();
|
this->changelog = QString();
|
||||||
this->downloadLink = QString();
|
this->downloadUrl = QString();
|
||||||
|
this->breakingChanges = false;
|
||||||
|
this->foundReleases = false;
|
||||||
|
this->visitedUrls.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
void UpdatePromoter::checkForUpdates() {
|
void UpdatePromoter::checkForUpdates() {
|
||||||
// Ignore request if one is still active.
|
// If the Retry button is disabled, making requests is disabled
|
||||||
if (this->reply && !this->reply->isFinished())
|
if (!this->button_Retry->isEnabled())
|
||||||
return;
|
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).
|
// 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.
|
// 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"));
|
static const QUrl url("https://api.github.com/repos/huderlem/porymap/releases");
|
||||||
this->reply = this->manager->get(request);
|
this->get(url);
|
||||||
|
}
|
||||||
|
|
||||||
connect(this->reply, &QNetworkReply::finished, [this] {
|
void UpdatePromoter::get(const QUrl &url) {
|
||||||
ui->buttonBox->button(QDialogButtonBox::Retry)->setEnabled(true);
|
this->visitedUrls.insert(url);
|
||||||
auto error = this->reply->error();
|
auto reply = this->manager->get(url);
|
||||||
if (error == QNetworkReply::NoError) {
|
connect(reply, &NetworkReplyData::finished, [this, reply] () {
|
||||||
this->processWebpage(QJsonDocument::fromJson(this->reply->readAll()));
|
if (!reply->errorString().isEmpty()) {
|
||||||
|
this->error(reply->errorString());
|
||||||
|
this->disableRequestsUntil(reply->retryAfter());
|
||||||
} else {
|
} 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.
|
// 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.
|
// Objects in the releases page data are sorted newest to oldest.
|
||||||
void UpdatePromoter::processWebpage(const QJsonDocument &data) {
|
// Returns true when finished, returns false to request processing for the next page.
|
||||||
bool updateAvailable = false;
|
void UpdatePromoter::processWebpage(const QJsonDocument &data, const QUrl &nextUrl) {
|
||||||
bool breakingChanges = false;
|
|
||||||
bool foundRelease = false;
|
|
||||||
|
|
||||||
const QJsonArray releases = data.array();
|
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();
|
auto release = releases.at(i).toObject();
|
||||||
|
|
||||||
// Convert tag string to version numbers
|
// Convert tag string to version numbers
|
||||||
|
@ -89,57 +100,77 @@ void UpdatePromoter::processWebpage(const QJsonDocument &data) {
|
||||||
if (!ok) continue;
|
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.
|
// 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))
|
if (!this->isNewerVersion(major, minor, patch))
|
||||||
break;
|
break;
|
||||||
|
|
||||||
const QString description = release.value("body").toString();
|
const QString description = release.value("body").toString();
|
||||||
const QString url = release.value("html_url").toString();
|
if (description.isEmpty()) {
|
||||||
if (description.isEmpty() || url.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 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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this->downloadLink.isEmpty()) {
|
if (this->downloadUrl.isEmpty()) {
|
||||||
// This is the first (newest) release we've found. Record its URL for download.
|
// This is the first (newest) release we've found. Record its URL for download.
|
||||||
this->downloadLink = url;
|
const QUrl url = QUrl(release.value("html_url").toString());
|
||||||
breakingChanges = (major > PORYMAP_VERSION_MAJOR);
|
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.
|
// 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));
|
this->changelog.append(QString("## %1\n%2\n\n").arg(tagName).arg(description));
|
||||||
updateAvailable = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!foundRelease) {
|
// If we read the entire page then we didn't find a release as old as the host version.
|
||||||
// We retrieved the webpage but didn't successfully parse any releases.
|
// Keep looking on the second page, there might still be new releases there.
|
||||||
this->processError("Error parsing releases webpage");
|
if (i == releases.size() && !nextUrl.isEmpty() && !this->visitedUrls.contains(nextUrl)) {
|
||||||
|
this->get(nextUrl);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If there's a new update available the dialog will always be opened.
|
if (!this->foundReleases) {
|
||||||
// Otherwise the dialog is only open if the user requested it.
|
// We retrieved the webpage but didn't successfully parse any releases.
|
||||||
if (updateAvailable) {
|
this->error("Error parsing releases webpage");
|
||||||
this->button_Downloads->setEnabled(!this->downloadLink.isEmpty());
|
return;
|
||||||
ui->text_Changelog->setMarkdown(this->changelog);
|
}
|
||||||
ui->text_Changelog->setVisible(true);
|
|
||||||
ui->label_Warning->setVisible(breakingChanges);
|
// Populate dialog with result
|
||||||
ui->label_Status->setText("A new version of Porymap is available!");
|
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();
|
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);
|
const QString message = QString("Failed to check for version update: %1").arg(err);
|
||||||
if (this->isVisible()) {
|
ui->label_Status->setText(message);
|
||||||
ui->label_Status->setText(message);
|
if (!this->isVisible())
|
||||||
} else {
|
|
||||||
logWarn(message);
|
logWarn(message);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool UpdatePromoter::isNewerVersion(int major, int minor, int patch) {
|
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;
|
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() {
|
void UpdatePromoter::updatePreferences() {
|
||||||
const QSignalBlocker blocker(ui->checkBox_StopAlerts);
|
const QSignalBlocker blocker(ui->checkBox_StopAlerts);
|
||||||
ui->checkBox_StopAlerts->setChecked(porymapConfig.getCheckForUpdates());
|
ui->checkBox_StopAlerts->setChecked(!porymapConfig.getCheckForUpdates());
|
||||||
}
|
}
|
||||||
|
|
||||||
void UpdatePromoter::dialogButtonClicked(QAbstractButton *button) {
|
void UpdatePromoter::dialogButtonClicked(QAbstractButton *button) {
|
||||||
auto buttonRole = ui->buttonBox->buttonRole(button);
|
if (ui->buttonBox->buttonRole(button) == QDialogButtonBox::RejectRole) {
|
||||||
if (buttonRole == QDialogButtonBox::RejectRole) {
|
|
||||||
this->close();
|
this->close();
|
||||||
} else if (buttonRole == QDialogButtonBox::AcceptRole) {
|
} else if (button == this->button_Retry) {
|
||||||
// "Retry" button
|
|
||||||
this->checkForUpdates();
|
this->checkForUpdates();
|
||||||
} else if (button == this->button_Downloads && !this->downloadLink.isEmpty()) {
|
} else if (button == this->button_Downloads) {
|
||||||
QDesktopServices::openUrl(QUrl(this->downloadLink));
|
QDesktopServices::openUrl(this->downloadUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue