diff --git a/forms/aboutporymap.ui b/forms/aboutporymap.ui
index cfe22bf5..f823396c 100644
--- a/forms/aboutporymap.ui
+++ b/forms/aboutporymap.ui
@@ -52,9 +52,6 @@
12
-
- Version 5.3.0 - January 15th, 2024
-
Qt::AlignCenter
diff --git a/forms/mainwindow.ui b/forms/mainwindow.ui
index d1ef7767..a28f1db9 100644
--- a/forms/mainwindow.ui
+++ b/forms/mainwindow.ui
@@ -1715,7 +1715,7 @@
0
0
100
- 30
+ 16
@@ -1809,7 +1809,7 @@
0
0
100
- 30
+ 16
@@ -1903,7 +1903,7 @@
0
0
100
- 30
+ 16
@@ -2003,7 +2003,7 @@
0
0
100
- 30
+ 16
@@ -2097,7 +2097,7 @@
0
0
100
- 30
+ 16
@@ -3112,6 +3112,7 @@
+
diff --git a/forms/preferenceeditor.ui b/forms/preferenceeditor.ui
index 974d6322..a7009e6e 100644
--- a/forms/preferenceeditor.ui
+++ b/forms/preferenceeditor.ui
@@ -26,6 +26,9 @@
-
+
+ If checked, a prompt to reload your project will appear if relevant project files are edited
+
Monitor project files
@@ -33,11 +36,24 @@
-
+
+ If checked, Porymap will automatically open your most recently opened project on startup
+
Open recent project on launch
+ -
+
+
+ If checked, Porymap will automatically alert you on startup if a new release is available
+
+
+ Automatically check for updates
+
+
+
diff --git a/forms/updatepromoter.ui b/forms/updatepromoter.ui
new file mode 100644
index 00000000..cbb4e8be
--- /dev/null
+++ b/forms/updatepromoter.ui
@@ -0,0 +1,104 @@
+
+
+ UpdatePromoter
+
+
+
+ 0
+ 0
+ 592
+ 484
+
+
+
+ Porymap Version Update
+
+
+ -
+
+
+ QFrame::StyledPanel
+
+
+ QFrame::Raised
+
+
+
-
+
+
+
+
+
+ true
+
+
+
+ -
+
+
+ <html><head/><body><p><span style=" font-size:13pt; color:#d7000c;">WARNING: </span><span style=" font-weight:400;">Updating Porymap may require you to update your projects. See "Breaking Changes" in the Changelog for details.</span></p></body></html>
+
+
+ true
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ QFrame::NoFrame
+
+
+ QFrame::Plain
+
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
-
+
+
+ true
+
+
+
+
+
+
+ -
+
+
+ Do not alert me about new updates
+
+
+
+
+
+
+ -
+
+
+ QDialogButtonBox::Close|QDialogButtonBox::Retry
+
+
+
+
+
+
+
+
diff --git a/include/config.h b/include/config.h
index 17a19dec..54398cca 100644
--- a/include/config.h
+++ b/include/config.h
@@ -8,9 +8,14 @@
#include
#include
#include
+#include
+#include
+#include
#include "events.h"
+static const QVersionNumber porymapVersion = QVersionNumber::fromString(PORYMAP_VERSION);
+
// In both versions the default new map border is a generic tree
#define DEFAULT_BORDER_RSE (QList{0x1D4, 0x1D5, 0x1DC, 0x1DD})
#define DEFAULT_BORDER_FRLG (QList{0x14, 0x15, 0x1C, 0x1D})
@@ -74,6 +79,10 @@ class PorymapConfig: public KeyValueConfigBase
this->paletteEditorBitDepth = 24;
this->projectSettingsTab = 0;
this->warpBehaviorWarningDisabled = false;
+ this->checkForUpdates = true;
+ this->lastUpdateCheckTime = QDateTime();
+ this->lastUpdateCheckVersion = porymapVersion;
+ this->rateLimitTimes.clear();
}
void addRecentProject(QString project);
void setRecentProjects(QStringList projects);
@@ -105,6 +114,10 @@ class PorymapConfig: public KeyValueConfigBase
void setPaletteEditorBitDepth(int bitDepth);
void setProjectSettingsTab(int tab);
void setWarpBehaviorWarningDisabled(bool disabled);
+ void setCheckForUpdates(bool enabled);
+ void setLastUpdateCheckTime(QDateTime time);
+ void setLastUpdateCheckVersion(QVersionNumber version);
+ void setRateLimitTimes(QMap map);
QString getRecentProject();
QStringList getRecentProjects();
bool getReopenOnLaunch();
@@ -135,6 +148,10 @@ class PorymapConfig: public KeyValueConfigBase
int getPaletteEditorBitDepth();
int getProjectSettingsTab();
bool getWarpBehaviorWarningDisabled();
+ bool getCheckForUpdates();
+ QDateTime getLastUpdateCheckTime();
+ QVersionNumber getLastUpdateCheckVersion();
+ QMap getRateLimitTimes();
protected:
virtual QString getConfigFilepath() override;
virtual void parseConfigKeyValue(QString key, QString value) override;
@@ -183,6 +200,10 @@ class PorymapConfig: public KeyValueConfigBase
int paletteEditorBitDepth;
int projectSettingsTab;
bool warpBehaviorWarningDisabled;
+ bool checkForUpdates;
+ QDateTime lastUpdateCheckTime;
+ QVersionNumber lastUpdateCheckVersion;
+ 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 6f969eba..f3aa9375 100644
--- a/include/mainwindow.h
+++ b/include/mainwindow.h
@@ -27,6 +27,7 @@
#include "preferenceeditor.h"
#include "projectsettingseditor.h"
#include "customscriptseditor.h"
+#include "updatepromoter.h"
@@ -288,6 +289,7 @@ private slots:
void on_spinBox_SelectedCollision_valueChanged(int collision);
void on_actionRegion_Map_Editor_triggered();
void on_actionPreferences_triggered();
+ void on_actionCheck_for_Updates_triggered();
void togglePreferenceSpecificUi();
void on_actionProject_Settings_triggered();
void on_actionCustom_Scripts_triggered();
@@ -307,6 +309,8 @@ private slots:
QPointer preferenceEditor = nullptr;
QPointer projectSettingsEditor = nullptr;
QPointer customScriptsEditor = nullptr;
+ QPointer updatePromoter = nullptr;
+ QPointer networkAccessManager = nullptr;
FilterChildrenProxyModel *mapListProxyModel;
QStandardItemModel *mapListModel;
QList *mapGroupItemsList;
@@ -397,6 +401,8 @@ private slots:
QObjectList shortcutableObjects() const;
void addCustomHeaderValue(QString key, QJsonValue value, bool isNew = false);
int insertTilesetLabel(QStringList * list, QString label);
+
+ void checkForUpdates(bool requestedByUser);
};
enum MapListUserRoles {
diff --git a/include/scripting.h b/include/scripting.h
index 7fd2a170..fb96e7ae 100644
--- a/include/scripting.h
+++ b/include/scripting.h
@@ -52,7 +52,6 @@ class Scripting
static QJSValue fromBlock(Block block);
static QJSValue fromTile(Tile tile);
static Tile toTile(QJSValue obj);
- static QJSValue version(QList versionNums);
static QJSValue dimensions(int width, int height);
static QJSValue position(int x, int y);
static const QImage * getImage(const QString &filepath, bool useCache);
diff --git a/include/ui/aboutporymap.h b/include/ui/aboutporymap.h
index cc6cb6e8..28b06249 100644
--- a/include/ui/aboutporymap.h
+++ b/include/ui/aboutporymap.h
@@ -14,7 +14,6 @@ class AboutPorymap : public QMainWindow
public:
explicit AboutPorymap(QWidget *parent = nullptr);
~AboutPorymap();
- QList getVersionNumbers();
private:
Ui::AboutPorymap *ui;
};
diff --git a/include/ui/updatepromoter.h b/include/ui/updatepromoter.h
new file mode 100644
index 00000000..8b67c69e
--- /dev/null
+++ b/include/ui/updatepromoter.h
@@ -0,0 +1,50 @@
+#ifndef UPDATEPROMOTER_H
+#define UPDATEPROMOTER_H
+
+#include "network.h"
+
+#include
+#include
+#include
+
+namespace Ui {
+class UpdatePromoter;
+}
+
+class UpdatePromoter : public QDialog
+{
+ Q_OBJECT
+
+public:
+ explicit UpdatePromoter(QWidget *parent, NetworkAccessManager *manager);
+ ~UpdatePromoter() {};
+
+ void checkForUpdates();
+ void updatePreferences();
+
+private:
+ Ui::UpdatePromoter *ui;
+ NetworkAccessManager *const manager;
+ QPushButton * button_Downloads;
+ QPushButton * button_Retry;
+
+ QString changelog;
+ QUrl downloadUrl;
+ QVersionNumber newVersion;
+ bool foundReleases;
+
+ QSet visitedUrls; // Prevent infinite redirection
+
+ void resetDialog();
+ void get(const QUrl &url);
+ void processWebpage(const QJsonDocument &data, const QUrl &nextUrl);
+ void error(const QString &err, const QDateTime time = QDateTime());
+
+private slots:
+ void dialogButtonClicked(QAbstractButton *button);
+
+signals:
+ void changedPreferences();
+};
+
+#endif // UPDATEPROMOTER_H
diff --git a/porymap.pro b/porymap.pro
index ddb3f621..43eef36b 100644
--- a/porymap.pro
+++ b/porymap.pro
@@ -4,7 +4,7 @@
#
#-------------------------------------------------
-QT += core gui qml
+QT += core gui qml network
greaterThan(QT_MAJOR_VERSION, 4): QT += widgets
@@ -14,6 +14,8 @@ RC_ICONS = resources/icons/porymap-icon-2.ico
ICON = resources/icons/porymap.icns
QMAKE_CXXFLAGS += -std=c++17 -Wall
QMAKE_TARGET_BUNDLE_PREFIX = com.pret
+VERSION = 5.3.0
+DEFINES += PORYMAP_VERSION=\\\"$$VERSION\\\"
SOURCES += src/core/block.cpp \
src/core/bitpacker.cpp \
@@ -26,6 +28,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 \
@@ -102,7 +105,8 @@ SOURCES += src/core/block.cpp \
src/project.cpp \
src/settings.cpp \
src/log.cpp \
- src/ui/uintspinbox.cpp
+ src/ui/uintspinbox.cpp \
+ src/ui/updatepromoter.cpp
HEADERS += include/core/block.h \
include/core/bitpacker.h \
@@ -117,6 +121,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 \
@@ -196,7 +201,8 @@ HEADERS += include/core/block.h \
include/scriptutility.h \
include/settings.h \
include/log.h \
- include/ui/uintspinbox.h
+ include/ui/uintspinbox.h \
+ include/ui/updatepromoter.h
FORMS += forms/mainwindow.ui \
forms/prefabcreationdialog.ui \
@@ -214,7 +220,8 @@ FORMS += forms/mainwindow.ui \
forms/colorpicker.ui \
forms/projectsettingseditor.ui \
forms/customscriptseditor.ui \
- forms/customscriptslistitem.ui
+ forms/customscriptslistitem.ui \
+ forms/updatepromoter.ui
RESOURCES += \
resources/images.qrc \
diff --git a/src/config.cpp b/src/config.cpp
index 0c49605c..177aeafa 100644
--- a/src/config.cpp
+++ b/src/config.cpp
@@ -407,6 +407,24 @@ void PorymapConfig::parseConfigKeyValue(QString key, QString value) {
this->projectSettingsTab = getConfigInteger(key, value, 0);
} else if (key == "warp_behavior_warning_disabled") {
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 == "last_update_check_version") {
+ auto version = QVersionNumber::fromString(value);
+ if (version.segmentCount() != 3) {
+ logWarn(QString("Invalid config value for %1: '%2'. Must be 3 numbers separated by '.'").arg(key).arg(value));
+ this->lastUpdateCheckVersion = porymapVersion;
+ } else {
+ this->lastUpdateCheckVersion = version;
+ }
+ } 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));
}
@@ -453,6 +471,15 @@ QMap PorymapConfig::getKeyValueMap() {
map.insert("palette_editor_bit_depth", QString::number(this->paletteEditorBitDepth));
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());
+ map.insert("last_update_check_version", this->lastUpdateCheckVersion.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;
}
@@ -631,6 +658,31 @@ 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::setLastUpdateCheckVersion(QVersionNumber version) {
+ this->lastUpdateCheckVersion = version;
+ this->save();
+}
+
+void PorymapConfig::setRateLimitTimes(QMap map) {
+ this->rateLimitTimes = map;
+ this->save();
+}
+
QString PorymapConfig::getRecentProject() {
return this->recentProjects.value(0);
}
@@ -781,15 +833,26 @@ int PorymapConfig::getProjectSettingsTab() {
return this->projectSettingsTab;
}
-void PorymapConfig::setWarpBehaviorWarningDisabled(bool disabled) {
- this->warpBehaviorWarningDisabled = disabled;
- this->save();
-}
-
bool PorymapConfig::getWarpBehaviorWarningDisabled() {
return this->warpBehaviorWarningDisabled;
}
+bool PorymapConfig::getCheckForUpdates() {
+ return this->checkForUpdates;
+}
+
+QDateTime PorymapConfig::getLastUpdateCheckTime() {
+ return this->lastUpdateCheckTime;
+}
+
+QVersionNumber PorymapConfig::getLastUpdateCheckVersion() {
+ return this->lastUpdateCheckVersion;
+}
+
+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..94594d68
--- /dev/null
+++ b/src/core/network.cpp
@@ -0,0 +1,154 @@
+#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;
+
+ // The url in the request and the url ultimately processed (reply->url()) may differ if the request was redirected.
+ // For identification purposes (e.g. knowing if we are rate limited before a request is made) we use the url that
+ // was originally given for the request.
+ auto url = data->m_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.
+ auto cacheEntry = this->cache.value(url, nullptr);
+ if (cacheEntry)
+ data->m_body = cacheEntry->data;
+ else
+ data->m_error = "Failed to read webpage from cache.";
+ 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.canConvert())
+ cacheEntry->eTag = eTagHeader.toString();
+
+ cacheEntry->data = data->m_body = reply->readAll();
+}
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index f7e0dee1..6f3b87d7 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -41,6 +41,12 @@
#include
#include
+// We only publish release binaries for Windows and macOS.
+// This is relevant for the update promoter, which alerts users of a new release.
+#if defined(Q_OS_WIN) || defined(Q_OS_MACOS)
+#define RELEASE_PLATFORM
+#endif
+
using OrderedJson = poryjson::Json;
using OrderedJsonDoc = poryjson::JsonDoc;
@@ -53,11 +59,13 @@ MainWindow::MainWindow(QWidget *parent) :
{
QCoreApplication::setOrganizationName("pret");
QCoreApplication::setApplicationName("porymap");
+ QCoreApplication::setApplicationVersion(PORYMAP_VERSION);
QApplication::setApplicationDisplayName("porymap");
QApplication::setWindowIcon(QIcon(":/icons/porymap-icon-2.ico"));
ui->setupUi(this);
cleanupLargeLog();
+ logInfo(QString("Launching Porymap v%1").arg(QCoreApplication::applicationVersion()));
this->initWindow();
if (porymapConfig.getReopenOnLaunch() && this->openProject(porymapConfig.getRecentProject(), true))
@@ -66,6 +74,9 @@ MainWindow::MainWindow(QWidget *parent) :
// there is a bug affecting macOS users, where the trackpad deilveres a bad touch-release gesture
// the warning is a bit annoying, so it is disabled here
QLoggingCategory::setFilterRules(QStringLiteral("qt.pointer.dispatch=false"));
+
+ if (porymapConfig.getCheckForUpdates())
+ this->checkForUpdates(false);
}
MainWindow::~MainWindow()
@@ -91,6 +102,7 @@ void MainWindow::setWindowDisabled(bool disabled) {
ui->actionAbout_Porymap->setDisabled(false);
ui->actionOpen_Log_File->setDisabled(false);
ui->actionOpen_Config_Folder->setDisabled(false);
+ ui->actionCheck_for_Updates->setDisabled(false);
if (!disabled)
togglePreferenceSpecificUi();
}
@@ -105,6 +117,10 @@ void MainWindow::initWindow() {
this->initShortcuts();
this->restoreWindowState();
+#ifndef RELEASE_PLATFORM
+ ui->actionCheck_for_Updates->setVisible(false);
+#endif
+
setWindowDisabled(true);
}
@@ -244,6 +260,39 @@ void MainWindow::initExtraSignals() {
label_MapRulerStatus->setTextInteractionFlags(Qt::TextSelectableByMouse);
}
+void MainWindow::on_actionCheck_for_Updates_triggered() {
+ checkForUpdates(true);
+}
+
+#ifdef RELEASE_PLATFORM
+void MainWindow::checkForUpdates(bool requestedByUser) {
+ if (!this->networkAccessManager)
+ this->networkAccessManager = new NetworkAccessManager(this);
+
+ if (!this->updatePromoter) {
+ this->updatePromoter = new UpdatePromoter(this, this->networkAccessManager);
+ connect(this->updatePromoter, &UpdatePromoter::changedPreferences, [this] {
+ if (this->preferenceEditor)
+ this->preferenceEditor->updateFields();
+ });
+ }
+
+
+ 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());
+}
+#else
+void MainWindow::checkForUpdates(bool) {}
+#endif
+
void MainWindow::initEditor() {
this->editor = new Editor(ui);
connect(this->editor, &Editor::objectsChanged, this, &MainWindow::updateObjects);
@@ -2749,6 +2798,9 @@ void MainWindow::togglePreferenceSpecificUi() {
ui->actionOpen_Project_in_Text_Editor->setEnabled(false);
else
ui->actionOpen_Project_in_Text_Editor->setEnabled(true);
+
+ if (this->updatePromoter)
+ this->updatePromoter->updatePreferences();
}
void MainWindow::openProjectSettingsEditor(int tab) {
diff --git a/src/scriptapi/scripting.cpp b/src/scriptapi/scripting.cpp
index 626f820b..112585c7 100644
--- a/src/scriptapi/scripting.cpp
+++ b/src/scriptapi/scripting.cpp
@@ -1,7 +1,6 @@
#include "scripting.h"
#include "log.h"
#include "config.h"
-#include "aboutporymap.h"
QMap callbackFunctions = {
{OnProjectOpened, "onProjectOpened"},
@@ -77,15 +76,12 @@ void Scripting::populateGlobalObject(MainWindow *mainWindow) {
QJSValue constants = instance->engine->newObject();
- // Invisibly create an "About" window to read Porymap version
- AboutPorymap *about = new AboutPorymap(mainWindow);
- if (about) {
- QJSValue version = Scripting::version(about->getVersionNumbers());
- constants.setProperty("version", version);
- delete about;
- } else {
- logError("Failed to read Porymap version for API");
- }
+ // Get version numbers
+ QJSValue version = instance->engine->newObject();
+ version.setProperty("major", porymapVersion.majorVersion());
+ version.setProperty("minor", porymapVersion.minorVersion());
+ version.setProperty("patch", porymapVersion.microVersion());
+ constants.setProperty("version", version);
// Get basic tileset information
int numTilesPrimary = Project::getNumTilesPrimary();
@@ -343,14 +339,6 @@ QJSValue Scripting::position(int x, int y) {
return obj;
}
-QJSValue Scripting::version(QList versionNums) {
- QJSValue obj = instance->engine->newObject();
- obj.setProperty("major", versionNums.at(0));
- obj.setProperty("minor", versionNums.at(1));
- obj.setProperty("patch", versionNums.at(2));
- return obj;
-}
-
Tile Scripting::toTile(QJSValue obj) {
Tile tile = Tile();
diff --git a/src/ui/aboutporymap.cpp b/src/ui/aboutporymap.cpp
index ce0a1fe4..38c28144 100644
--- a/src/ui/aboutporymap.cpp
+++ b/src/ui/aboutporymap.cpp
@@ -8,6 +8,7 @@ AboutPorymap::AboutPorymap(QWidget *parent) :
{
ui->setupUi(this);
+ this->ui->label_Version->setText(QString("Version %1 - %2").arg(QCoreApplication::applicationVersion()).arg(QStringLiteral(__DATE__)));
this->ui->textBrowser->setSource(QUrl("qrc:/CHANGELOG.md"));
}
@@ -15,16 +16,3 @@ AboutPorymap::~AboutPorymap()
{
delete ui;
}
-
-// Returns the Porymap version number as a list of ints with the order {major, minor, patch}
-QList AboutPorymap::getVersionNumbers()
-{
- // Get the version string "#.#.#"
- static const QRegularExpression regex("Version (\\d+)\\.(\\d+)\\.(\\d+)");
- QRegularExpressionMatch match = regex.match(ui->label_Version->text());
- if (!match.hasMatch()) {
- logError("Failed to locate Porymap version text");
- return QList({0, 0, 0});
- }
- return QList({match.captured(1).toInt(), match.captured(2).toInt(), match.captured(3).toInt()});
-}
diff --git a/src/ui/preferenceeditor.cpp b/src/ui/preferenceeditor.cpp
index f94c07da..eacc3f3d 100644
--- a/src/ui/preferenceeditor.cpp
+++ b/src/ui/preferenceeditor.cpp
@@ -49,6 +49,7 @@ void PreferenceEditor::updateFields() {
ui->lineEdit_TextEditorGotoLine->setText(porymapConfig.getTextEditorGotoLine());
ui->checkBox_MonitorProjectFiles->setChecked(porymapConfig.getMonitorFiles());
ui->checkBox_OpenRecentProject->setChecked(porymapConfig.getReopenOnLaunch());
+ ui->checkBox_CheckForUpdates->setChecked(porymapConfig.getCheckForUpdates());
}
void PreferenceEditor::saveFields() {
@@ -58,10 +59,14 @@ void PreferenceEditor::saveFields() {
emit themeChanged(theme);
}
+ porymapConfig.setSaveDisabled(true);
porymapConfig.setTextEditorOpenFolder(ui->lineEdit_TextEditorOpenFolder->text());
porymapConfig.setTextEditorGotoLine(ui->lineEdit_TextEditorGotoLine->text());
porymapConfig.setMonitorFiles(ui->checkBox_MonitorProjectFiles->isChecked());
porymapConfig.setReopenOnLaunch(ui->checkBox_OpenRecentProject->isChecked());
+ porymapConfig.setCheckForUpdates(ui->checkBox_CheckForUpdates->isChecked());
+ porymapConfig.setSaveDisabled(false);
+ porymapConfig.save();
emit preferencesSaved();
}
diff --git a/src/ui/updatepromoter.cpp b/src/ui/updatepromoter.cpp
new file mode 100644
index 00000000..8ca2b6cd
--- /dev/null
+++ b/src/ui/updatepromoter.cpp
@@ -0,0 +1,187 @@
+#include "updatepromoter.h"
+#include "ui_updatepromoter.h"
+#include "log.h"
+#include "config.h"
+
+#include
+#include
+#include
+#include
+#include
+
+UpdatePromoter::UpdatePromoter(QWidget *parent, NetworkAccessManager *manager)
+ : QDialog(parent),
+ ui(new Ui::UpdatePromoter),
+ manager(manager)
+{
+ ui->setupUi(this);
+
+ // 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);
+ emit this->changedPreferences();
+ });
+
+ // 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();
+}
+
+void UpdatePromoter::resetDialog() {
+ this->button_Downloads->setEnabled(false);
+
+ ui->text_Changelog->setVisible(false);
+ ui->label_Warning->setVisible(false);
+ ui->label_Status->setText("");
+
+ this->changelog = QString();
+ this->downloadUrl = QString();
+ this->newVersion = QVersionNumber();
+ this->foundReleases = false;
+ this->visitedUrls.clear();
+}
+
+void UpdatePromoter::checkForUpdates() {
+ // If the Retry button is disabled, making requests is disabled
+ if (!this->button_Retry->isEnabled())
+ return;
+
+ 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 QUrl url("https://api.github.com/repos/huderlem/porymap/releases");
+ this->get(url);
+}
+
+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(), reply->retryAfter());
+ } else {
+ 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, const QUrl &nextUrl) {
+ const QJsonArray releases = data.array();
+ int i;
+ for (i = 0; i < releases.size(); i++) {
+ auto release = releases.at(i).toObject();
+
+ // Convert tag string to version numbers
+ const QString tagName = release.value("tag_name").toString();
+ const QVersionNumber version = QVersionNumber::fromString(tagName);
+ if (version.segmentCount() != 3) 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.
+ this->foundReleases = true;
+ if (porymapVersion >= version)
+ break;
+
+ const QString description = release.value("body").toString();
+ 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.
+ continue;
+ }
+
+ if (this->downloadUrl.isEmpty()) {
+ // This is the first (newest) release we've found. Record its URL for download.
+ 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->newVersion = version;
+ }
+
+ // 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));
+ }
+
+ // 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 (!this->foundReleases) {
+ // We retrieved the webpage but didn't successfully parse any releases.
+ this->error("Error parsing releases webpage");
+ return;
+ }
+
+ // Populate dialog with result
+ ui->text_Changelog->setMarkdown(this->changelog);
+ ui->text_Changelog->setVisible(!this->changelog.isEmpty());
+ this->button_Downloads->setEnabled(!this->downloadUrl.isEmpty());
+ this->button_Retry->setEnabled(true);
+ if (!this->newVersion.isNull()) {
+ ui->label_Status->setText("A new version of Porymap is available!");
+ ui->label_Warning->setVisible(this->newVersion.majorVersion() > porymapVersion.majorVersion());
+
+ // Alert the user about the new version if the dialog wasn't already open.
+ // Show the window, but also show the option to turn off automatic alerts in the future.
+ // We only show this alert once for a given release.
+ if (!this->isVisible() && this->newVersion > porymapConfig.getLastUpdateCheckVersion()) {
+ ui->checkBox_StopAlerts->setVisible(true);
+ this->show();
+ }
+ porymapConfig.setLastUpdateCheckVersion(this->newVersion);
+ } else {
+ ui->label_Status->setText("Your version of Porymap is up to date!");
+ ui->label_Warning->setVisible(false);
+ }
+}
+
+void UpdatePromoter::error(const QString &err, const QDateTime retryAfter) {
+ const QString message = QString("Failed to check for version update: %1").arg(err);
+ ui->label_Status->setText(message);
+ if (!this->isVisible())
+ logWarn(message);
+
+ // If a "retry after" date/time is provided, disable the Retry button until then.
+ // Otherwise users are allowed to retry after an error.
+ auto timeUntil = QDateTime::currentDateTime().msecsTo(retryAfter);
+ if (timeUntil > 0) {
+ this->button_Retry->setEnabled(false);
+ QTimer::singleShot(timeUntil, Qt::VeryCoarseTimer, [this]() {
+ this->button_Retry->setEnabled(true);
+ });
+ } else {
+ this->button_Retry->setEnabled(true);
+ }
+}
+
+void UpdatePromoter::updatePreferences() {
+ const QSignalBlocker blocker(ui->checkBox_StopAlerts);
+ ui->checkBox_StopAlerts->setChecked(!porymapConfig.getCheckForUpdates());
+}
+
+void UpdatePromoter::dialogButtonClicked(QAbstractButton *button) {
+ if (ui->buttonBox->buttonRole(button) == QDialogButtonBox::RejectRole) {
+ this->close();
+ } else if (button == this->button_Retry) {
+ this->checkForUpdates();
+ } else if (button == this->button_Downloads) {
+ QDesktopServices::openUrl(this->downloadUrl);
+ }
+}