Merge pull request #590 from GriffinRichards/update-promoter

Add update promoter
This commit is contained in:
GriffinR 2024-02-12 13:27:49 -05:00 committed by GitHub
commit b228c4e76c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 782 additions and 50 deletions

View file

@ -52,9 +52,6 @@
<pointsize>12</pointsize>
</font>
</property>
<property name="text">
<string>Version 5.3.0 - January 15th, 2024</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>

View file

@ -1715,7 +1715,7 @@
<x>0</x>
<y>0</y>
<width>100</width>
<height>30</height>
<height>16</height>
</rect>
</property>
<property name="sizePolicy">
@ -1809,7 +1809,7 @@
<x>0</x>
<y>0</y>
<width>100</width>
<height>30</height>
<height>16</height>
</rect>
</property>
<property name="sizePolicy">
@ -1903,7 +1903,7 @@
<x>0</x>
<y>0</y>
<width>100</width>
<height>30</height>
<height>16</height>
</rect>
</property>
<property name="sizePolicy">
@ -2003,7 +2003,7 @@
<x>0</x>
<y>0</y>
<width>100</width>
<height>30</height>
<height>16</height>
</rect>
</property>
<property name="sizePolicy">
@ -2097,7 +2097,7 @@
<x>0</x>
<y>0</y>
<width>100</width>
<height>30</height>
<height>16</height>
</rect>
</property>
<property name="sizePolicy">
@ -3112,6 +3112,7 @@
<addaction name="actionAbout_Porymap"/>
<addaction name="actionOpen_Log_File"/>
<addaction name="actionOpen_Config_Folder"/>
<addaction name="actionCheck_for_Updates"/>
</widget>
<widget class="QMenu" name="menuOptions">
<property name="title">
@ -3406,6 +3407,14 @@
<string>Custom Scripts...</string>
</property>
</action>
<action name="actionCheck_for_Updates">
<property name="text">
<string>Check for Updates...</string>
</property>
<property name="menuRole">
<enum>QAction::ApplicationSpecificRole</enum>
</property>
</action>
</widget>
<layoutdefault spacing="6" margin="11"/>
<customwidgets>

View file

@ -26,6 +26,9 @@
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="QCheckBox" name="checkBox_MonitorProjectFiles">
<property name="toolTip">
<string>If checked, a prompt to reload your project will appear if relevant project files are edited</string>
</property>
<property name="text">
<string>Monitor project files</string>
</property>
@ -33,11 +36,24 @@
</item>
<item>
<widget class="QCheckBox" name="checkBox_OpenRecentProject">
<property name="toolTip">
<string>If checked, Porymap will automatically open your most recently opened project on startup</string>
</property>
<property name="text">
<string>Open recent project on launch</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="checkBox_CheckForUpdates">
<property name="toolTip">
<string>If checked, Porymap will automatically alert you on startup if a new release is available</string>
</property>
<property name="text">
<string>Automatically check for updates</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>

104
forms/updatepromoter.ui Normal file
View file

@ -0,0 +1,104 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>UpdatePromoter</class>
<widget class="QDialog" name="UpdatePromoter">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>592</width>
<height>484</height>
</rect>
</property>
<property name="windowTitle">
<string>Porymap Version Update</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QFrame" name="frame">
<property name="frameShape">
<enum>QFrame::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Raised</enum>
</property>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="QLabel" name="label_Status">
<property name="text">
<string/>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label_Warning">
<property name="text">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;&lt;span style=&quot; font-size:13pt; color:#d7000c;&quot;&gt;WARNING: &lt;/span&gt;&lt;span style=&quot; font-weight:400;&quot;&gt;Updating Porymap may require you to update your projects. See &quot;Breaking Changes&quot; in the Changelog for details.&lt;/span&gt;&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QFrame" name="frame_Changelog">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="frameShape">
<enum>QFrame::NoFrame</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Plain</enum>
</property>
<layout class="QVBoxLayout" name="verticalLayout_3">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QTextBrowser" name="text_Changelog">
<property name="openExternalLinks">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QCheckBox" name="checkBox_StopAlerts">
<property name="text">
<string>Do not alert me about new updates</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="standardButtons">
<set>QDialogButtonBox::Close|QDialogButtonBox::Retry</set>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View file

@ -8,9 +8,14 @@
#include <QSize>
#include <QKeySequence>
#include <QMultiMap>
#include <QDateTime>
#include <QUrl>
#include <QVersionNumber>
#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<uint16_t>{0x1D4, 0x1D5, 0x1DC, 0x1DD})
#define DEFAULT_BORDER_FRLG (QList<uint16_t>{0x14, 0x15, 0x1C, 0x1D})
@ -74,6 +79,10 @@ public:
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 @@ public:
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<QUrl, QDateTime> map);
QString getRecentProject();
QStringList getRecentProjects();
bool getReopenOnLaunch();
@ -135,6 +148,10 @@ public:
int getPaletteEditorBitDepth();
int getProjectSettingsTab();
bool getWarpBehaviorWarningDisabled();
bool getCheckForUpdates();
QDateTime getLastUpdateCheckTime();
QVersionNumber getLastUpdateCheckVersion();
QMap<QUrl, QDateTime> getRateLimitTimes();
protected:
virtual QString getConfigFilepath() override;
virtual void parseConfigKeyValue(QString key, QString value) override;
@ -183,6 +200,10 @@ private:
int paletteEditorBitDepth;
int projectSettingsTab;
bool warpBehaviorWarningDisabled;
bool checkForUpdates;
QDateTime lastUpdateCheckTime;
QVersionNumber lastUpdateCheckVersion;
QMap<QUrl, QDateTime> rateLimitTimes;
};
extern PorymapConfig porymapConfig;

87
include/core/network.h Normal file
View 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

View file

@ -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:
QPointer<PreferenceEditor> preferenceEditor = nullptr;
QPointer<ProjectSettingsEditor> projectSettingsEditor = nullptr;
QPointer<CustomScriptsEditor> customScriptsEditor = nullptr;
QPointer<UpdatePromoter> updatePromoter = nullptr;
QPointer<NetworkAccessManager> networkAccessManager = nullptr;
FilterChildrenProxyModel *mapListProxyModel;
QStandardItemModel *mapListModel;
QList<QStandardItem*> *mapGroupItemsList;
@ -397,6 +401,8 @@ private:
QObjectList shortcutableObjects() const;
void addCustomHeaderValue(QString key, QJsonValue value, bool isNew = false);
int insertTilesetLabel(QStringList * list, QString label);
void checkForUpdates(bool requestedByUser);
};
enum MapListUserRoles {

View file

@ -52,7 +52,6 @@ public:
static QJSValue fromBlock(Block block);
static QJSValue fromTile(Tile tile);
static Tile toTile(QJSValue obj);
static QJSValue version(QList<int> versionNums);
static QJSValue dimensions(int width, int height);
static QJSValue position(int x, int y);
static const QImage * getImage(const QString &filepath, bool useCache);

View file

@ -14,7 +14,6 @@ class AboutPorymap : public QMainWindow
public:
explicit AboutPorymap(QWidget *parent = nullptr);
~AboutPorymap();
QList<int> getVersionNumbers();
private:
Ui::AboutPorymap *ui;
};

View file

@ -0,0 +1,50 @@
#ifndef UPDATEPROMOTER_H
#define UPDATEPROMOTER_H
#include "network.h"
#include <QDialog>
#include <QPushButton>
#include <QVersionNumber>
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<QUrl> 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

View file

@ -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 \

View file

@ -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/(?<url>.+)");
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<QString, QString> 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<QUrl, QDateTime> 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<QUrl, QDateTime> PorymapConfig::getRateLimitTimes() {
return this->rateLimitTimes;
}
const QStringList ProjectConfig::versionStrings = {
"pokeruby",
"pokefirered",

154
src/core/network.cpp Normal file
View file

@ -0,0 +1,154 @@
#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;
// 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("<(?<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.
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<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.canConvert<QString>())
cacheEntry->eTag = eTagHeader.toString();
cacheEntry->data = data->m_body = reply->readAll();
}

View file

@ -41,6 +41,12 @@
#include <QSet>
#include <QLoggingCategory>
// 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) {

View file

@ -1,7 +1,6 @@
#include "scripting.h"
#include "log.h"
#include "config.h"
#include "aboutporymap.h"
QMap<CallbackType, QString> 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<int> 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();

View file

@ -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<int> 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<int>({0, 0, 0});
}
return QList<int>({match.captured(1).toInt(), match.captured(2).toInt(), match.captured(3).toInt()});
}

View file

@ -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();
}

187
src/ui/updatepromoter.cpp Normal file
View file

@ -0,0 +1,187 @@
#include "updatepromoter.h"
#include "ui_updatepromoter.h"
#include "log.h"
#include "config.h"
#include <QJsonDocument>
#include <QJsonArray>
#include <QJsonObject>
#include <QDesktopServices>
#include <QTimer>
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);
}
}