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 @@ + @@ -3406,6 +3407,14 @@ Custom Scripts... + + + Check for Updates... + + + QAction::ApplicationSpecificRole + + 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); + } +}