From d6dfab180592288289b2ba7cf76d5cfcfca825a3 Mon Sep 17 00:00:00 2001 From: GriffinR Date: Sat, 20 Jan 2024 22:07:52 -0500 Subject: [PATCH 01/12] Add update promoter network functions --- forms/mainwindow.ui | 19 +++++++++++---- include/mainwindow.h | 5 ++++ porymap.pro | 2 +- src/mainwindow.cpp | 57 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 6 deletions(-) diff --git a/forms/mainwindow.ui b/forms/mainwindow.ui index d70180d45..ea7d340ad 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/include/mainwindow.h b/include/mainwindow.h index e95db0033..7d512ef56 100644 --- a/include/mainwindow.h +++ b/include/mainwindow.h @@ -12,6 +12,7 @@ #include #include #include +#include #include "project.h" #include "orderedjson.h" #include "config.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(); @@ -296,6 +298,7 @@ private slots: public: Ui::MainWindow *ui; Editor *editor = nullptr; + QPointer networkAccessManager = nullptr; private: QLabel *label_MapRulerStatus = nullptr; @@ -396,6 +399,8 @@ private slots: QObjectList shortcutableObjects() const; void addCustomHeaderValue(QString key, QJsonValue value, bool isNew = false); int insertTilesetLabel(QStringList * list, QString label); + + void checkForUpdates(); }; enum MapListUserRoles { diff --git a/porymap.pro b/porymap.pro index ddb3f6217..900728308 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 diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 3a649062e..7027ebcac 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -242,6 +242,63 @@ void MainWindow::initExtraSignals() { label_MapRulerStatus->setAlignment(Qt::AlignCenter); label_MapRulerStatus->setTextFormat(Qt::PlainText); label_MapRulerStatus->setTextInteractionFlags(Qt::TextSelectableByMouse); + + // TODO: (if enabled) queue an automatic "Check for Updates" +} + +// TODO: Relocate +#include +#include +void MainWindow::on_actionCheck_for_Updates_triggered() { + checkForUpdates(); +} + +void MainWindow::checkForUpdates() { + if (!this->networkAccessManager) + this->networkAccessManager = new QNetworkAccessManager(this); + + // We could get ".../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). + QNetworkRequest request(QUrl("https://api.github.com/repos/huderlem/porymap/releases")); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + + QNetworkReply * reply = this->networkAccessManager->get(request); + + connect(reply, &QNetworkReply::finished, [this, reply] { + QJsonDocument data = QJsonDocument::fromJson(reply->readAll()); + QJsonArray releases = data.array(); + + // Read all the items on the releases page, stopping when we find a tag that parses as a version identifier. + // Objects in the releases page data are sorted newest to oldest. Although I can't find a guarantee of this in + // GitHub's API documentation, this seems unlikely to change. + for (int i = 0; i < releases.size(); i++) { + auto release = releases.at(i).toObject(); + + const QStringList tag = release.value("tag_name").toString().split("."); + if (tag.length() != 3) continue; + + bool ok; + int major = tag.at(0).toInt(&ok); + if (!ok) continue; + int minor = tag.at(1).toInt(&ok); + if (!ok) continue; + int patch = tag.at(2).toInt(&ok); + if (!ok) continue; + + const QString downloadLink = release.value("html_url").toString(); + if (downloadLink.isEmpty()) continue; + + // We've found a valid release tag, we can stop reading. + logInfo(QString("Newest release is %1.%2.%3\n%4").arg(major).arg(minor).arg(patch).arg(downloadLink)); + + // If the release was published very recently it won't have a description yet, in which case don't tell the user. + const QString changelog = release.value("body").toString(); + if (changelog.isEmpty()) break; + + // TODO: Compare version to host version, then show appropriate dialog + break; + } + }); } void MainWindow::initEditor() { From 09c2ed6b3098591dbb09c9ef169b1be799f97d52 Mon Sep 17 00:00:00 2001 From: GriffinR Date: Sat, 20 Jan 2024 23:02:43 -0500 Subject: [PATCH 02/12] Add check for updates setting --- forms/preferenceeditor.ui | 16 ++++++++++++++++ include/config.h | 4 ++++ include/mainwindow.h | 2 +- src/config.cpp | 12 ++++++++++++ src/mainwindow.cpp | 16 +++++++++++----- src/ui/preferenceeditor.cpp | 5 +++++ 6 files changed, 49 insertions(+), 6 deletions(-) diff --git a/forms/preferenceeditor.ui b/forms/preferenceeditor.ui index 974d63226..a7009e6e0 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/include/config.h b/include/config.h index 17a19dec5..17cc20d0b 100644 --- a/include/config.h +++ b/include/config.h @@ -74,6 +74,7 @@ class PorymapConfig: public KeyValueConfigBase this->paletteEditorBitDepth = 24; this->projectSettingsTab = 0; this->warpBehaviorWarningDisabled = false; + this->checkForUpdates = true; } void addRecentProject(QString project); void setRecentProjects(QStringList projects); @@ -105,6 +106,7 @@ class PorymapConfig: public KeyValueConfigBase void setPaletteEditorBitDepth(int bitDepth); void setProjectSettingsTab(int tab); void setWarpBehaviorWarningDisabled(bool disabled); + void setCheckForUpdates(bool enabled); QString getRecentProject(); QStringList getRecentProjects(); bool getReopenOnLaunch(); @@ -135,6 +137,7 @@ class PorymapConfig: public KeyValueConfigBase int getPaletteEditorBitDepth(); int getProjectSettingsTab(); bool getWarpBehaviorWarningDisabled(); + bool getCheckForUpdates(); protected: virtual QString getConfigFilepath() override; virtual void parseConfigKeyValue(QString key, QString value) override; @@ -183,6 +186,7 @@ class PorymapConfig: public KeyValueConfigBase int paletteEditorBitDepth; int projectSettingsTab; bool warpBehaviorWarningDisabled; + bool checkForUpdates; }; extern PorymapConfig porymapConfig; diff --git a/include/mainwindow.h b/include/mainwindow.h index 7d512ef56..75c38fc9f 100644 --- a/include/mainwindow.h +++ b/include/mainwindow.h @@ -400,7 +400,7 @@ private slots: void addCustomHeaderValue(QString key, QJsonValue value, bool isNew = false); int insertTilesetLabel(QStringList * list, QString label); - void checkForUpdates(); + void checkForUpdates(bool requestedByUser); }; enum MapListUserRoles { diff --git a/src/config.cpp b/src/config.cpp index 0c49605ca..9cf0c54cc 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -407,6 +407,8 @@ 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 { logWarn(QString("Invalid config key found in config file %1: '%2'").arg(this->getConfigFilepath()).arg(key)); } @@ -453,6 +455,7 @@ 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)); return map; } @@ -790,6 +793,15 @@ bool PorymapConfig::getWarpBehaviorWarningDisabled() { return this->warpBehaviorWarningDisabled; } +void PorymapConfig::setCheckForUpdates(bool enabled) { + this->checkForUpdates = enabled; + this->save(); +} + +bool PorymapConfig::getCheckForUpdates() { + return this->checkForUpdates; +} + const QStringList ProjectConfig::versionStrings = { "pokeruby", "pokefirered", diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 7027ebcac..6e2ca5091 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -66,6 +66,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() @@ -242,28 +245,31 @@ void MainWindow::initExtraSignals() { label_MapRulerStatus->setAlignment(Qt::AlignCenter); label_MapRulerStatus->setTextFormat(Qt::PlainText); label_MapRulerStatus->setTextInteractionFlags(Qt::TextSelectableByMouse); - - // TODO: (if enabled) queue an automatic "Check for Updates" } // TODO: Relocate #include #include void MainWindow::on_actionCheck_for_Updates_triggered() { - checkForUpdates(); + checkForUpdates(true); } -void MainWindow::checkForUpdates() { +void MainWindow::checkForUpdates(bool requestedByUser) { if (!this->networkAccessManager) this->networkAccessManager = new QNetworkAccessManager(this); // We could get ".../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). - QNetworkRequest request(QUrl("https://api.github.com/repos/huderlem/porymap/releases")); + static const QUrl url("https://api.github.com/repos/huderlem/porymap/releases"); + QNetworkRequest request(url); request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); QNetworkReply * reply = this->networkAccessManager->get(request); + if (requestedByUser) { + // TODO: Show dialog + } + connect(reply, &QNetworkReply::finished, [this, reply] { QJsonDocument data = QJsonDocument::fromJson(reply->readAll()); QJsonArray releases = data.array(); diff --git a/src/ui/preferenceeditor.cpp b/src/ui/preferenceeditor.cpp index f94c07da6..eacc3f3de 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(); } From 97b485284e49e893e3cc7dae177268305f92d234 Mon Sep 17 00:00:00 2001 From: GriffinR Date: Sun, 21 Jan 2024 00:14:40 -0500 Subject: [PATCH 03/12] Move version info to porymap.pro --- forms/aboutporymap.ui | 3 --- include/scripting.h | 1 - include/ui/aboutporymap.h | 1 - porymap.pro | 8 ++++++++ src/mainwindow.cpp | 13 ++++++++++++- src/scriptapi/scripting.cpp | 24 ++++++------------------ src/ui/aboutporymap.cpp | 14 +------------- 7 files changed, 27 insertions(+), 37 deletions(-) diff --git a/forms/aboutporymap.ui b/forms/aboutporymap.ui index cfe22bf55..f823396ce 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/include/scripting.h b/include/scripting.h index 7fd2a170f..fb96e7aec 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 cc6cb6e8f..28b06249d 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/porymap.pro b/porymap.pro index 900728308..f5cb04522 100644 --- a/porymap.pro +++ b/porymap.pro @@ -14,6 +14,14 @@ 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_MAJOR = 5 +VERSION_MINOR = 3 +VERSION_PATCH = 0 +VERSION = $${VERSION_MAJOR}.$${VERSION_MINOR}.$${VERSION_PATCH} +DEFINES += PORYMAP_VERSION_MAJOR=$$VERSION_MAJOR \ + PORYMAP_VERSION_MINOR=$$VERSION_MINOR \ + PORYMAP_VERSION_PATCH=$$VERSION_PATCH \ + PORYMAP_VERSION=\\\"$$VERSION\\\" SOURCES += src/core/block.cpp \ src/core/bitpacker.cpp \ diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 6e2ca5091..f98b3a301 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -53,6 +53,7 @@ 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); @@ -301,7 +302,17 @@ void MainWindow::checkForUpdates(bool requestedByUser) { const QString changelog = release.value("body").toString(); if (changelog.isEmpty()) break; - // TODO: Compare version to host version, then show appropriate dialog + bool newVersionAvailable; + if (major != PORYMAP_VERSION_MAJOR) { + newVersionAvailable = major > PORYMAP_VERSION_MAJOR; + } else if (minor != PORYMAP_VERSION_MINOR) { + newVersionAvailable = minor > PORYMAP_VERSION_MINOR; + } else { + newVersionAvailable = patch > PORYMAP_VERSION_PATCH; + } + logInfo(QString("Host version is %1").arg(newVersionAvailable ? "old" : "up to date")); + + // TODO: Show appropriate dialog break; } }); diff --git a/src/scriptapi/scripting.cpp b/src/scriptapi/scripting.cpp index 626f820ba..aa5477244 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", PORYMAP_VERSION_MAJOR); + version.setProperty("minor", PORYMAP_VERSION_MINOR); + version.setProperty("patch", PORYMAP_VERSION_PATCH); + 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 ce0a1fe4b..96f20ee8d 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(PORYMAP_VERSION).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()}); -} From c04a89396c139d1cadf1e13cc96458b61e2363d2 Mon Sep 17 00:00:00 2001 From: GriffinR Date: Sun, 21 Jan 2024 02:00:28 -0500 Subject: [PATCH 04/12] Add update promoter dialog --- forms/updatepromoter.ui | 104 +++++++++++++++++++++ include/mainwindow.h | 4 +- include/ui/updatepromoter.h | 44 +++++++++ porymap.pro | 9 +- src/mainwindow.cpp | 70 +++----------- src/ui/updatepromoter.cpp | 177 ++++++++++++++++++++++++++++++++++++ 6 files changed, 346 insertions(+), 62 deletions(-) create mode 100644 forms/updatepromoter.ui create mode 100644 include/ui/updatepromoter.h create mode 100644 src/ui/updatepromoter.cpp diff --git a/forms/updatepromoter.ui b/forms/updatepromoter.ui new file mode 100644 index 000000000..cbb4e8be5 --- /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/mainwindow.h b/include/mainwindow.h index 75c38fc9f..c499a5ba2 100644 --- a/include/mainwindow.h +++ b/include/mainwindow.h @@ -28,6 +28,7 @@ #include "preferenceeditor.h" #include "projectsettingseditor.h" #include "customscriptseditor.h" +#include "updatepromoter.h" @@ -298,7 +299,6 @@ private slots: public: Ui::MainWindow *ui; Editor *editor = nullptr; - QPointer networkAccessManager = nullptr; private: QLabel *label_MapRulerStatus = nullptr; @@ -310,6 +310,8 @@ private slots: QPointer preferenceEditor = nullptr; QPointer projectSettingsEditor = nullptr; QPointer customScriptsEditor = nullptr; + QPointer updatePromoter = nullptr; + QPointer networkAccessManager = nullptr; FilterChildrenProxyModel *mapListProxyModel; QStandardItemModel *mapListModel; QList *mapGroupItemsList; diff --git a/include/ui/updatepromoter.h b/include/ui/updatepromoter.h new file mode 100644 index 000000000..4b0f209dd --- /dev/null +++ b/include/ui/updatepromoter.h @@ -0,0 +1,44 @@ +#ifndef UPDATEPROMOTER_H +#define UPDATEPROMOTER_H + +#include +#include +#include +#include + +namespace Ui { +class UpdatePromoter; +} + +class UpdatePromoter : public QDialog +{ + Q_OBJECT + +public: + explicit UpdatePromoter(QWidget *parent, QNetworkAccessManager *manager); + ~UpdatePromoter() {}; + + void checkForUpdates(); + void requestDialog(); + void updatePreferences(); + +private: + Ui::UpdatePromoter *ui; + QNetworkAccessManager *const manager; + QNetworkReply * reply = nullptr; + QPushButton * button_Downloads; + QString downloadLink; + QString changelog; + + void resetDialog(); + void processWebpage(const QJsonDocument &data); + void processError(const QString &err); + +private slots: + void dialogButtonClicked(QAbstractButton *button); + +signals: + void changedPreferences(); +}; + +#endif // UPDATEPROMOTER_H diff --git a/porymap.pro b/porymap.pro index f5cb04522..4e883b39b 100644 --- a/porymap.pro +++ b/porymap.pro @@ -110,7 +110,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 \ @@ -204,7 +205,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 \ @@ -222,7 +224,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/mainwindow.cpp b/src/mainwindow.cpp index f98b3a301..9fd45720b 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -248,9 +248,6 @@ void MainWindow::initExtraSignals() { label_MapRulerStatus->setTextInteractionFlags(Qt::TextSelectableByMouse); } -// TODO: Relocate -#include -#include void MainWindow::on_actionCheck_for_Updates_triggered() { checkForUpdates(true); } @@ -259,63 +256,17 @@ void MainWindow::checkForUpdates(bool requestedByUser) { if (!this->networkAccessManager) this->networkAccessManager = new QNetworkAccessManager(this); - // We could get ".../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). - static const QUrl url("https://api.github.com/repos/huderlem/porymap/releases"); - QNetworkRequest request(url); - request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); - - QNetworkReply * reply = this->networkAccessManager->get(request); - - if (requestedByUser) { - // TODO: Show dialog + if (!this->updatePromoter) { + this->updatePromoter = new UpdatePromoter(this, this->networkAccessManager); + connect(this->updatePromoter, &UpdatePromoter::changedPreferences, [this] { + if (this->preferenceEditor) + this->preferenceEditor->updateFields(); + }); } - connect(reply, &QNetworkReply::finished, [this, reply] { - QJsonDocument data = QJsonDocument::fromJson(reply->readAll()); - QJsonArray releases = data.array(); - - // Read all the items on the releases page, stopping when we find a tag that parses as a version identifier. - // Objects in the releases page data are sorted newest to oldest. Although I can't find a guarantee of this in - // GitHub's API documentation, this seems unlikely to change. - for (int i = 0; i < releases.size(); i++) { - auto release = releases.at(i).toObject(); - - const QStringList tag = release.value("tag_name").toString().split("."); - if (tag.length() != 3) continue; - - bool ok; - int major = tag.at(0).toInt(&ok); - if (!ok) continue; - int minor = tag.at(1).toInt(&ok); - if (!ok) continue; - int patch = tag.at(2).toInt(&ok); - if (!ok) continue; - - const QString downloadLink = release.value("html_url").toString(); - if (downloadLink.isEmpty()) continue; - - // We've found a valid release tag, we can stop reading. - logInfo(QString("Newest release is %1.%2.%3\n%4").arg(major).arg(minor).arg(patch).arg(downloadLink)); - - // If the release was published very recently it won't have a description yet, in which case don't tell the user. - const QString changelog = release.value("body").toString(); - if (changelog.isEmpty()) break; - - bool newVersionAvailable; - if (major != PORYMAP_VERSION_MAJOR) { - newVersionAvailable = major > PORYMAP_VERSION_MAJOR; - } else if (minor != PORYMAP_VERSION_MINOR) { - newVersionAvailable = minor > PORYMAP_VERSION_MINOR; - } else { - newVersionAvailable = patch > PORYMAP_VERSION_PATCH; - } - logInfo(QString("Host version is %1").arg(newVersionAvailable ? "old" : "up to date")); - - // TODO: Show appropriate dialog - break; - } - }); + if (requestedByUser) + this->updatePromoter->requestDialog(); + this->updatePromoter->checkForUpdates(); } void MainWindow::initEditor() { @@ -2812,6 +2763,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/ui/updatepromoter.cpp b/src/ui/updatepromoter.cpp new file mode 100644 index 000000000..c7dc9de70 --- /dev/null +++ b/src/ui/updatepromoter.cpp @@ -0,0 +1,177 @@ +#include "updatepromoter.h" +#include "ui_updatepromoter.h" +#include "log.h" +#include "config.h" + +#include +#include +#include +#include +#include + +UpdatePromoter::UpdatePromoter(QWidget *parent, QNetworkAccessManager *manager) + : QDialog(parent), + ui(new Ui::UpdatePromoter), + manager(manager) +{ + ui->setupUi(this); + + // Set up "Do not alert me" check box + this->updatePreferences(); + 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_Downloads = ui->buttonBox->addButton("Go to Downloads...", QDialogButtonBox::ActionRole); + 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("Checking for updates..."); + + this->changelog = QString(); + this->downloadLink = QString(); +} + +void UpdatePromoter::checkForUpdates() { + // Ignore request if one is still active. + if (this->reply && !this->reply->isFinished()) + return; + this->resetDialog(); + ui->buttonBox->button(QDialogButtonBox::Retry)->setEnabled(false); + + // We could get ".../releases/latest" to retrieve less data, but this would run into problems if the + // most recent item on the releases page is not actually a new release (like the static windows build). + // By getting all releases we can also present a multi-version changelog of all changes since the host release. + static const QNetworkRequest request(QUrl("https://api.github.com/repos/huderlem/porymap/releases")); + this->reply = this->manager->get(request); + + connect(this->reply, &QNetworkReply::finished, [this] { + ui->buttonBox->button(QDialogButtonBox::Retry)->setEnabled(true); + auto error = this->reply->error(); + if (error == QNetworkReply::NoError) { + this->processWebpage(QJsonDocument::fromJson(this->reply->readAll())); + } else { + this->processError(this->reply->errorString()); + } + }); +} + +// Read all the items on the releases page, ignoring entries without a version identifier tag. +// Objects in the releases page data are sorted newest to oldest. +void UpdatePromoter::processWebpage(const QJsonDocument &data) { + bool updateAvailable = false; + bool breakingChanges = false; + bool foundRelease = false; + + const QJsonArray releases = data.array(); + for (int 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 QStringList tag = tagName.split("."); + if (tag.length() != 3) continue; + bool ok; + int major = tag.at(0).toInt(&ok); + if (!ok) continue; + int minor = tag.at(1).toInt(&ok); + if (!ok) continue; + int patch = tag.at(2).toInt(&ok); + if (!ok) continue; + + // We've found a valid release tag. If the version number is not newer than the host version then we can stop looking at releases. + foundRelease = true; + logInfo(QString("Found release %1.%2.%3").arg(major).arg(minor).arg(patch)); // TODO: Remove + if (major <= PORYMAP_VERSION_MAJOR && minor <= PORYMAP_VERSION_MINOR && patch <= PORYMAP_VERSION_PATCH) + break; + + const QString description = release.value("body").toString(); + const QString url = release.value("html_url").toString(); + if (description.isEmpty() || url.isEmpty()) { + // If the release was published very recently it won't have a description yet, in which case don't tell the user about it yet. + // If there's no URL, something has gone wrong and we should skip this release. + continue; + } + + if (this->downloadLink.isEmpty()) { + // This is the first (newest) release we've found. Record its URL for download. + this->downloadLink = url; + breakingChanges = (major > PORYMAP_VERSION_MAJOR); + } + + // Record the changelog of this release so we can show all changes since the host release. + this->changelog.append(QString("## %1\n%2\n\n").arg(tagName).arg(description)); + updateAvailable = true; + } + + if (!foundRelease) { + // We retrieved the webpage but didn't successfully parse any releases. + this->processError("Error parsing releases webpage"); + return; + } + + // If there's a new update available the dialog will always be opened. + // Otherwise the dialog is only open if the user requested it. + if (updateAvailable) { + this->button_Downloads->setEnabled(!this->downloadLink.isEmpty()); + ui->text_Changelog->setMarkdown(this->changelog); + ui->text_Changelog->setVisible(true); + ui->label_Warning->setVisible(breakingChanges); + ui->label_Status->setText("A new version of Porymap is available!"); + this->show(); + } else { + // The rest of the UI remains in the state set by resetDialog + ui->label_Status->setText("Your version of Porymap is up to date!"); + } +} + +void UpdatePromoter::processError(const QString &err) { + const QString message = QString("Failed to check for version update: %1").arg(err); + if (this->isVisible()) { + ui->label_Status->setText(message); + } else { + logWarn(message); + } +} + +// The dialog can either be shown programmatically when an update is available +// or if the user manually selects "Check for Updates" in the menu. +// When the dialog is shown programmatically there is a check box to disable automatic alerts. +// If the user requested the dialog (and it wasn't already open) this check box should be hidden. +void UpdatePromoter::requestDialog() { + if (!this->isVisible()){ + ui->checkBox_StopAlerts->setVisible(false); + this->show(); + } else if (this->isMinimized()) { + this->showNormal(); + } else { + this->raise(); + this->activateWindow(); + } +} + +void UpdatePromoter::updatePreferences() { + const QSignalBlocker blocker(ui->checkBox_StopAlerts); + ui->checkBox_StopAlerts->setChecked(porymapConfig.getCheckForUpdates()); +} + +void UpdatePromoter::dialogButtonClicked(QAbstractButton *button) { + auto buttonRole = ui->buttonBox->buttonRole(button); + if (buttonRole == QDialogButtonBox::RejectRole) { + this->close(); + } else if (buttonRole == QDialogButtonBox::AcceptRole) { + // "Retry" button + this->checkForUpdates(); + } else if (button == this->button_Downloads && !this->downloadLink.isEmpty()) { + QDesktopServices::openUrl(QUrl(this->downloadLink)); + } +} From fec1d1fdd4c544ccb965263ea64e6b294ae421bf Mon Sep 17 00:00:00 2001 From: GriffinR Date: Sun, 21 Jan 2024 12:09:23 -0500 Subject: [PATCH 05/12] Remove debug log --- src/ui/updatepromoter.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ui/updatepromoter.cpp b/src/ui/updatepromoter.cpp index c7dc9de70..5d91d2710 100644 --- a/src/ui/updatepromoter.cpp +++ b/src/ui/updatepromoter.cpp @@ -90,7 +90,6 @@ void UpdatePromoter::processWebpage(const QJsonDocument &data) { // We've found a valid release tag. If the version number is not newer than the host version then we can stop looking at releases. foundRelease = true; - logInfo(QString("Found release %1.%2.%3").arg(major).arg(minor).arg(patch)); // TODO: Remove if (major <= PORYMAP_VERSION_MAJOR && minor <= PORYMAP_VERSION_MINOR && patch <= PORYMAP_VERSION_PATCH) break; From 58e4a21aa6fe68b49438812a5a1df0dd02616850 Mon Sep 17 00:00:00 2001 From: GriffinR Date: Sun, 21 Jan 2024 18:41:23 -0500 Subject: [PATCH 06/12] Revert oversimplified version check --- include/ui/updatepromoter.h | 1 + src/ui/updatepromoter.cpp | 10 +++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/include/ui/updatepromoter.h b/include/ui/updatepromoter.h index 4b0f209dd..b9129156a 100644 --- a/include/ui/updatepromoter.h +++ b/include/ui/updatepromoter.h @@ -33,6 +33,7 @@ class UpdatePromoter : public QDialog void resetDialog(); void processWebpage(const QJsonDocument &data); void processError(const QString &err); + bool isNewerVersion(int major, int minor, int patch); private slots: void dialogButtonClicked(QAbstractButton *button); diff --git a/src/ui/updatepromoter.cpp b/src/ui/updatepromoter.cpp index 5d91d2710..f8270b4ee 100644 --- a/src/ui/updatepromoter.cpp +++ b/src/ui/updatepromoter.cpp @@ -90,7 +90,7 @@ void UpdatePromoter::processWebpage(const QJsonDocument &data) { // We've found a valid release tag. If the version number is not newer than the host version then we can stop looking at releases. foundRelease = true; - if (major <= PORYMAP_VERSION_MAJOR && minor <= PORYMAP_VERSION_MINOR && patch <= PORYMAP_VERSION_PATCH) + if (!this->isNewerVersion(major, minor, patch)) break; const QString description = release.value("body").toString(); @@ -142,6 +142,14 @@ void UpdatePromoter::processError(const QString &err) { } } +bool UpdatePromoter::isNewerVersion(int major, int minor, int patch) { + if (major != PORYMAP_VERSION_MAJOR) + return major > PORYMAP_VERSION_MAJOR; + if (minor != PORYMAP_VERSION_MINOR) + return minor > PORYMAP_VERSION_MINOR; + return patch > PORYMAP_VERSION_PATCH; +} + // The dialog can either be shown programmatically when an update is available // or if the user manually selects "Check for Updates" in the menu. // When the dialog is shown programmatically there is a check box to disable automatic alerts. From 34b2f9d881b0f1a5ed5a7e6d3111d4c79b48ffba Mon Sep 17 00:00:00 2001 From: GriffinR Date: Sun, 21 Jan 2024 19:18:26 -0500 Subject: [PATCH 07/12] Allow update checking with no project --- src/mainwindow.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 9fd45720b..84c7fd432 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -95,6 +95,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(); } From a5ed554c686029dc706de3767ec2541e0cf821e0 Mon Sep 17 00:00:00 2001 From: GriffinR Date: Wed, 24 Jan 2024 11:56:04 -0500 Subject: [PATCH 08/12] Better client etiquette --- include/config.h | 10 +++ include/core/network.h | 87 ++++++++++++++++++++ include/mainwindow.h | 3 +- include/ui/updatepromoter.h | 24 ++++-- porymap.pro | 2 + src/config.cpp | 53 +++++++++--- src/core/network.cpp | 146 +++++++++++++++++++++++++++++++++ src/mainwindow.cpp | 15 +++- src/ui/updatepromoter.cpp | 159 +++++++++++++++++++----------------- 9 files changed, 402 insertions(+), 97 deletions(-) create mode 100644 include/core/network.h create mode 100644 src/core/network.cpp diff --git a/include/config.h b/include/config.h index 17cc20d0b..485e97eb0 100644 --- a/include/config.h +++ b/include/config.h @@ -8,6 +8,8 @@ #include #include #include +#include +#include #include "events.h" @@ -75,6 +77,8 @@ class PorymapConfig: public KeyValueConfigBase this->projectSettingsTab = 0; this->warpBehaviorWarningDisabled = false; this->checkForUpdates = true; + this->lastUpdateCheckTime = QDateTime(); + this->rateLimitTimes.clear(); } void addRecentProject(QString project); void setRecentProjects(QStringList projects); @@ -107,6 +111,8 @@ class PorymapConfig: public KeyValueConfigBase void setProjectSettingsTab(int tab); void setWarpBehaviorWarningDisabled(bool disabled); void setCheckForUpdates(bool enabled); + void setLastUpdateCheckTime(QDateTime time); + void setRateLimitTimes(QMap map); QString getRecentProject(); QStringList getRecentProjects(); bool getReopenOnLaunch(); @@ -138,6 +144,8 @@ class PorymapConfig: public KeyValueConfigBase int getProjectSettingsTab(); bool getWarpBehaviorWarningDisabled(); bool getCheckForUpdates(); + QDateTime getLastUpdateCheckTime(); + QMap getRateLimitTimes(); protected: virtual QString getConfigFilepath() override; virtual void parseConfigKeyValue(QString key, QString value) override; @@ -187,6 +195,8 @@ class PorymapConfig: public KeyValueConfigBase int projectSettingsTab; bool warpBehaviorWarningDisabled; bool checkForUpdates; + QDateTime lastUpdateCheckTime; + QMap rateLimitTimes; }; extern PorymapConfig porymapConfig; diff --git a/include/core/network.h b/include/core/network.h new file mode 100644 index 000000000..321b3e995 --- /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 c499a5ba2..29052fd3b 100644 --- a/include/mainwindow.h +++ b/include/mainwindow.h @@ -12,7 +12,6 @@ #include #include #include -#include #include "project.h" #include "orderedjson.h" #include "config.h" @@ -311,7 +310,7 @@ private slots: QPointer projectSettingsEditor = nullptr; QPointer customScriptsEditor = nullptr; QPointer updatePromoter = nullptr; - QPointer networkAccessManager = nullptr; + QPointer networkAccessManager = nullptr; FilterChildrenProxyModel *mapListProxyModel; QStandardItemModel *mapListModel; QList *mapGroupItemsList; diff --git a/include/ui/updatepromoter.h b/include/ui/updatepromoter.h index b9129156a..4465e0dcc 100644 --- a/include/ui/updatepromoter.h +++ b/include/ui/updatepromoter.h @@ -1,10 +1,10 @@ #ifndef UPDATEPROMOTER_H #define UPDATEPROMOTER_H +#include "network.h" + #include #include -#include -#include namespace Ui { class UpdatePromoter; @@ -15,24 +15,30 @@ class UpdatePromoter : public QDialog Q_OBJECT public: - explicit UpdatePromoter(QWidget *parent, QNetworkAccessManager *manager); + explicit UpdatePromoter(QWidget *parent, NetworkAccessManager *manager); ~UpdatePromoter() {}; void checkForUpdates(); - void requestDialog(); void updatePreferences(); private: Ui::UpdatePromoter *ui; - QNetworkAccessManager *const manager; - QNetworkReply * reply = nullptr; + NetworkAccessManager *const manager; QPushButton * button_Downloads; - QString downloadLink; + QPushButton * button_Retry; + QString changelog; + QUrl downloadUrl; + bool breakingChanges; + bool foundReleases; + + QSet visitedUrls; // Prevent infinite redirection void resetDialog(); - void processWebpage(const QJsonDocument &data); - void processError(const QString &err); + void get(const QUrl &url); + void processWebpage(const QJsonDocument &data, const QUrl &nextUrl); + void disableRequestsUntil(const QDateTime time); + void error(const QString &err); bool isNewerVersion(int major, int minor, int patch); private slots: diff --git a/porymap.pro b/porymap.pro index 4e883b39b..cf4cc4516 100644 --- a/porymap.pro +++ b/porymap.pro @@ -34,6 +34,7 @@ SOURCES += src/core/block.cpp \ src/core/mapparser.cpp \ src/core/metatile.cpp \ src/core/metatileparser.cpp \ + src/core/network.cpp \ src/core/paletteutil.cpp \ src/core/parseutil.cpp \ src/core/tile.cpp \ @@ -126,6 +127,7 @@ HEADERS += include/core/block.h \ include/core/mapparser.h \ include/core/metatile.h \ include/core/metatileparser.h \ + include/core/network.h \ include/core/paletteutil.h \ include/core/parseutil.h \ include/core/tile.h \ diff --git a/src/config.cpp b/src/config.cpp index 9cf0c54cc..967c21c88 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -409,6 +409,14 @@ void PorymapConfig::parseConfigKeyValue(QString key, QString value) { this->warpBehaviorWarningDisabled = getConfigBool(key, value); } else if (key == "check_for_updates") { this->checkForUpdates = getConfigBool(key, value); + } else if (key == "last_update_check_time") { + this->lastUpdateCheckTime = QDateTime::fromString(value).toLocalTime(); + } else if (key.startsWith("rate_limit_time/")) { + static const QRegularExpression regex("\\brate_limit_time/(?.+)"); + QRegularExpressionMatch match = regex.match(key); + if (match.hasMatch()) { + this->rateLimitTimes.insert(match.captured("url"), QDateTime::fromString(value).toLocalTime()); + } } else { logWarn(QString("Invalid config key found in config file %1: '%2'").arg(this->getConfigFilepath()).arg(key)); } @@ -456,6 +464,13 @@ QMap PorymapConfig::getKeyValueMap() { map.insert("project_settings_tab", QString::number(this->projectSettingsTab)); map.insert("warp_behavior_warning_disabled", QString::number(this->warpBehaviorWarningDisabled)); map.insert("check_for_updates", QString::number(this->checkForUpdates)); + map.insert("last_update_check_time", this->lastUpdateCheckTime.toUTC().toString()); + for (auto i = this->rateLimitTimes.cbegin(), end = this->rateLimitTimes.cend(); i != end; i++){ + // Only include rate limit times that are still active (i.e., in the future) + const QDateTime time = i.value(); + if (!time.isNull() && time > QDateTime::currentDateTime()) + map.insert("rate_limit_time/" + i.key().toString(), time.toUTC().toString()); + } return map; } @@ -634,6 +649,26 @@ void PorymapConfig::setProjectSettingsTab(int tab) { this->save(); } +void PorymapConfig::setWarpBehaviorWarningDisabled(bool disabled) { + this->warpBehaviorWarningDisabled = disabled; + this->save(); +} + +void PorymapConfig::setCheckForUpdates(bool enabled) { + this->checkForUpdates = enabled; + this->save(); +} + +void PorymapConfig::setLastUpdateCheckTime(QDateTime time) { + this->lastUpdateCheckTime = time; + this->save(); +} + +void PorymapConfig::setRateLimitTimes(QMap map) { + this->rateLimitTimes = map; + this->save(); +} + QString PorymapConfig::getRecentProject() { return this->recentProjects.value(0); } @@ -784,24 +819,22 @@ int PorymapConfig::getProjectSettingsTab() { return this->projectSettingsTab; } -void PorymapConfig::setWarpBehaviorWarningDisabled(bool disabled) { - this->warpBehaviorWarningDisabled = disabled; - this->save(); -} - bool PorymapConfig::getWarpBehaviorWarningDisabled() { return this->warpBehaviorWarningDisabled; } -void PorymapConfig::setCheckForUpdates(bool enabled) { - this->checkForUpdates = enabled; - this->save(); -} - bool PorymapConfig::getCheckForUpdates() { return this->checkForUpdates; } +QDateTime PorymapConfig::getLastUpdateCheckTime() { + return this->lastUpdateCheckTime; +} + +QMap PorymapConfig::getRateLimitTimes() { + return this->rateLimitTimes; +} + const QStringList ProjectConfig::versionStrings = { "pokeruby", "pokefirered", diff --git a/src/core/network.cpp b/src/core/network.cpp new file mode 100644 index 000000000..3f2d3cb2b --- /dev/null +++ b/src/core/network.cpp @@ -0,0 +1,146 @@ +#include "network.h" +#include "config.h" + +#include +#include +#include + +// Fallback wait time (in seconds) for rate limiting +static const int DefaultWaitTime = 120; + +NetworkAccessManager::NetworkAccessManager(QObject * parent) : QNetworkAccessManager(parent) { + // We store rate limit end times in the user's config so that Porymap will still respect them after a restart. + // To avoid reading/writing to a local file during network operations, we only read/write the file when the + // manager is created/destroyed respectively. + this->rateLimitTimes = porymapConfig.getRateLimitTimes(); + this->setRedirectPolicy(QNetworkRequest::NoLessSafeRedirectPolicy); +}; + +NetworkAccessManager::~NetworkAccessManager() { + porymapConfig.setRateLimitTimes(this->rateLimitTimes); + qDeleteAll(this->cache); +} + +const QNetworkRequest NetworkAccessManager::getRequest(const QUrl &url) { + QNetworkRequest request(url); + + // Set User-Agent to porymap/#.#.# + request.setHeader(QNetworkRequest::UserAgentHeader, QString("%1/%2").arg(QCoreApplication::applicationName()) + .arg(QCoreApplication::applicationVersion())); + + // If we've made a successful request in this session already, set the If-None-Match header. + // We'll only get a full response from the server if the data has changed since this last request. + // This helps to avoid hitting rate limits. + auto cacheEntry = this->cache.value(url, nullptr); + if (cacheEntry) + request.setHeader(QNetworkRequest::IfNoneMatchHeader, cacheEntry->eTag); + + return request; +} + +NetworkReplyData * NetworkAccessManager::get(const QString &url) { + return this->get(QUrl(url)); +} + +NetworkReplyData * NetworkAccessManager::get(const QUrl &url) { + NetworkReplyData * data = new NetworkReplyData(); + data->m_url = url; + + // If we are rate-limited, don't send a new request. + if (this->rateLimitTimes.contains(url)) { + auto time = this->rateLimitTimes.value(url); + if (!time.isNull() && time > QDateTime::currentDateTime()) { + data->m_retryAfter = time; + data->m_error = QString("Rate limit reached. Please try again after %1.").arg(data->m_retryAfter.toString()); + QTimer::singleShot(1000, data, &NetworkReplyData::finish); // We can't emit this signal before caller has a chance to connect + return data; + } + // Rate limiting expired + this->rateLimitTimes.remove(url); + } + + QNetworkReply * reply = QNetworkAccessManager::get(this->getRequest(url)); + connect(reply, &QNetworkReply::finished, [this, reply, data] { + this->processReply(reply, data); + data->finish(); + }); + + return data; +} + +void NetworkAccessManager::processReply(QNetworkReply * reply, NetworkReplyData * data) { + if (!reply || !reply->isFinished()) + return; + + auto url = reply->url(); + reply->deleteLater(); + + int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + // Handle pagination (specifically, the format GitHub uses). + // This header is still sent for a 304, so we don't need to bother caching it. + if (reply->hasRawHeader("link")) { + static const QRegularExpression regex("<(?.+)?>; rel=\"next\""); + QRegularExpressionMatch match = regex.match(QString(reply->rawHeader("link"))); + if (match.hasMatch()) + data->m_nextUrl = QUrl(match.captured("url")); + } + + if (statusCode == 304) { + // "Not Modified", data hasn't changed since our last request. + data->m_body = this->cache.value(url)->data; + return; + } + + // Handle standard rate limit header + if (reply->hasRawHeader("retry-after")) { + auto retryAfter = QVariant(reply->rawHeader("retry-after")); + if (retryAfter.canConvert()) { + data->m_retryAfter = retryAfter.toDateTime().toLocalTime(); + } else if (retryAfter.canConvert()) { + data->m_retryAfter = QDateTime::currentDateTime().addSecs(retryAfter.toInt()); + } + if (data->m_retryAfter.isNull() || data->m_retryAfter <= QDateTime::currentDateTime()) { + data->m_retryAfter = QDateTime::currentDateTime().addSecs(DefaultWaitTime); + } + if (statusCode == 429) { + data->m_error = "Too many requests. "; + } else if (statusCode == 503) { + data->m_error = "Service busy or unavailable. "; + } + data->m_error.append(QString("Please try again after %1.").arg(data->m_retryAfter.toString())); + this->rateLimitTimes.insert(url, data->m_retryAfter); + return; + } + + // Handle GitHub's rate limit headers. As of writing this is (without authentication) 60 requests per IP address per hour. + bool ok; + int limitRemaining = reply->rawHeader("x-ratelimit-remaining").toInt(&ok); + if (ok && limitRemaining <= 0) { + auto limitReset = reply->rawHeader("x-ratelimit-reset").toLongLong(&ok); + data->m_retryAfter = ok ? QDateTime::fromSecsSinceEpoch(limitReset).toLocalTime() + : QDateTime::currentDateTime().addSecs(DefaultWaitTime);; + data->m_error = QString("Too many requests. Please try again after %1.").arg(data->m_retryAfter.toString()); + this->rateLimitTimes.insert(url, data->m_retryAfter); + return; + } + + // Handle remaining errors generically + auto error = reply->error(); + if (error != QNetworkReply::NoError) { + data->m_error = reply->errorString(); + return; + } + + // Successful reply, we've received new data. Insert this data in the cache. + CacheEntry * cacheEntry = this->cache.value(url, nullptr); + if (!cacheEntry) { + cacheEntry = new CacheEntry; + this->cache.insert(url, cacheEntry); + } + auto eTagHeader = reply->header(QNetworkRequest::ETagHeader); + if (eTagHeader.isValid()) + cacheEntry->eTag = eTagHeader.toString(); + + cacheEntry->data = data->m_body = reply->readAll(); +} diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 84c7fd432..bc2595335 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -59,6 +59,7 @@ MainWindow::MainWindow(QWidget *parent) : ui->setupUi(this); cleanupLargeLog(); + logInfo(QString("Launching Porymap v%1").arg(PORYMAP_VERSION)); this->initWindow(); if (porymapConfig.getReopenOnLaunch() && this->openProject(porymapConfig.getRecentProject(), true)) @@ -255,7 +256,7 @@ void MainWindow::on_actionCheck_for_Updates_triggered() { void MainWindow::checkForUpdates(bool requestedByUser) { if (!this->networkAccessManager) - this->networkAccessManager = new QNetworkAccessManager(this); + this->networkAccessManager = new NetworkAccessManager(this); if (!this->updatePromoter) { this->updatePromoter = new UpdatePromoter(this, this->networkAccessManager); @@ -265,9 +266,17 @@ void MainWindow::checkForUpdates(bool requestedByUser) { }); } - if (requestedByUser) - this->updatePromoter->requestDialog(); + + if (requestedByUser) { + openSubWindow(this->updatePromoter); + } else { + // This is an automatic update check. Only run if we haven't done one in the last 5 minutes + QDateTime lastCheck = porymapConfig.getLastUpdateCheckTime(); + if (lastCheck.addSecs(5*60) >= QDateTime::currentDateTime()) + return; + } this->updatePromoter->checkForUpdates(); + porymapConfig.setLastUpdateCheckTime(QDateTime::currentDateTime()); } void MainWindow::initEditor() { diff --git a/src/ui/updatepromoter.cpp b/src/ui/updatepromoter.cpp index f8270b4ee..71f4dce9d 100644 --- a/src/ui/updatepromoter.cpp +++ b/src/ui/updatepromoter.cpp @@ -3,13 +3,13 @@ #include "log.h" #include "config.h" -#include #include #include #include #include +#include -UpdatePromoter::UpdatePromoter(QWidget *parent, QNetworkAccessManager *manager) +UpdatePromoter::UpdatePromoter(QWidget *parent, NetworkAccessManager *manager) : QDialog(parent), ui(new Ui::UpdatePromoter), manager(manager) @@ -18,6 +18,7 @@ UpdatePromoter::UpdatePromoter(QWidget *parent, QNetworkAccessManager *manager) // Set up "Do not alert me" check box this->updatePreferences(); + ui->checkBox_StopAlerts->setVisible(false); connect(ui->checkBox_StopAlerts, &QCheckBox::stateChanged, [this](int state) { bool enable = (state != Qt::Checked); porymapConfig.setCheckForUpdates(enable); @@ -25,7 +26,9 @@ UpdatePromoter::UpdatePromoter(QWidget *parent, QNetworkAccessManager *manager) }); // Set up button box + this->button_Retry = ui->buttonBox->button(QDialogButtonBox::Retry); this->button_Downloads = ui->buttonBox->addButton("Go to Downloads...", QDialogButtonBox::ActionRole); + ui->buttonBox->button(QDialogButtonBox::Close)->setDefault(true); connect(ui->buttonBox, &QDialogButtonBox::clicked, this, &UpdatePromoter::dialogButtonClicked); this->resetDialog(); @@ -33,47 +36,55 @@ UpdatePromoter::UpdatePromoter(QWidget *parent, QNetworkAccessManager *manager) void UpdatePromoter::resetDialog() { this->button_Downloads->setEnabled(false); + ui->text_Changelog->setVisible(false); ui->label_Warning->setVisible(false); - ui->label_Status->setText("Checking for updates..."); + ui->label_Status->setText(""); this->changelog = QString(); - this->downloadLink = QString(); + this->downloadUrl = QString(); + this->breakingChanges = false; + this->foundReleases = false; + this->visitedUrls.clear(); } void UpdatePromoter::checkForUpdates() { - // Ignore request if one is still active. - if (this->reply && !this->reply->isFinished()) + // If the Retry button is disabled, making requests is disabled + if (!this->button_Retry->isEnabled()) return; + this->resetDialog(); - ui->buttonBox->button(QDialogButtonBox::Retry)->setEnabled(false); + this->button_Retry->setEnabled(false); + ui->label_Status->setText("Checking for updates..."); - // We could get ".../releases/latest" to retrieve less data, but this would run into problems if the + // We could use the URL ".../releases/latest" to retrieve less data, but this would run into problems if the // most recent item on the releases page is not actually a new release (like the static windows build). // By getting all releases we can also present a multi-version changelog of all changes since the host release. - static const QNetworkRequest request(QUrl("https://api.github.com/repos/huderlem/porymap/releases")); - this->reply = this->manager->get(request); - - connect(this->reply, &QNetworkReply::finished, [this] { - ui->buttonBox->button(QDialogButtonBox::Retry)->setEnabled(true); - auto error = this->reply->error(); - if (error == QNetworkReply::NoError) { - this->processWebpage(QJsonDocument::fromJson(this->reply->readAll())); + 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()); + this->disableRequestsUntil(reply->retryAfter()); } else { - this->processError(this->reply->errorString()); + this->processWebpage(QJsonDocument::fromJson(reply->body()), reply->nextUrl()); } + reply->deleteLater(); }); } // Read all the items on the releases page, ignoring entries without a version identifier tag. // Objects in the releases page data are sorted newest to oldest. -void UpdatePromoter::processWebpage(const QJsonDocument &data) { - bool updateAvailable = false; - bool breakingChanges = false; - bool foundRelease = false; - +// Returns true when finished, returns false to request processing for the next page. +void UpdatePromoter::processWebpage(const QJsonDocument &data, const QUrl &nextUrl) { const QJsonArray releases = data.array(); - for (int i = 0; i < releases.size(); i++) { + int i; + for (i = 0; i < releases.size(); i++) { auto release = releases.at(i).toObject(); // Convert tag string to version numbers @@ -89,57 +100,77 @@ void UpdatePromoter::processWebpage(const QJsonDocument &data) { if (!ok) continue; // We've found a valid release tag. If the version number is not newer than the host version then we can stop looking at releases. - foundRelease = true; + this->foundReleases = true; if (!this->isNewerVersion(major, minor, patch)) break; const QString description = release.value("body").toString(); - const QString url = release.value("html_url").toString(); - if (description.isEmpty() || url.isEmpty()) { + if (description.isEmpty()) { // If the release was published very recently it won't have a description yet, in which case don't tell the user about it yet. - // If there's no URL, something has gone wrong and we should skip this release. continue; } - if (this->downloadLink.isEmpty()) { + if (this->downloadUrl.isEmpty()) { // This is the first (newest) release we've found. Record its URL for download. - this->downloadLink = url; - breakingChanges = (major > PORYMAP_VERSION_MAJOR); + const QUrl url = QUrl(release.value("html_url").toString()); + if (url.isEmpty()) { + // If there's no URL, something has gone wrong and we should skip this release. + continue; + } + this->downloadUrl = url; + this->breakingChanges = (major > PORYMAP_VERSION_MAJOR); } // Record the changelog of this release so we can show all changes since the host release. this->changelog.append(QString("## %1\n%2\n\n").arg(tagName).arg(description)); - updateAvailable = true; } - if (!foundRelease) { + // 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->processError("Error parsing releases webpage"); + this->error("Error parsing releases webpage"); return; } - // If there's a new update available the dialog will always be opened. - // Otherwise the dialog is only open if the user requested it. - if (updateAvailable) { - this->button_Downloads->setEnabled(!this->downloadLink.isEmpty()); - ui->text_Changelog->setMarkdown(this->changelog); - ui->text_Changelog->setVisible(true); - ui->label_Warning->setVisible(breakingChanges); - ui->label_Status->setText("A new version of Porymap is available!"); + // Populate dialog with result + bool updateAvailable = !this->changelog.isEmpty(); + ui->label_Status->setText(updateAvailable ? "A new version of Porymap is available!" + : "Your version of Porymap is up to date!"); + ui->label_Warning->setVisible(this->breakingChanges); + ui->text_Changelog->setMarkdown(this->changelog); + ui->text_Changelog->setVisible(updateAvailable); + this->button_Downloads->setEnabled(!this->downloadUrl.isEmpty()); + this->button_Retry->setEnabled(true); + + // Alert the user if there's a new update available and the dialog wasn't already open. + // Show the window, but also show the option to turn off automatic alerts in the future. + if (updateAvailable && !this->isVisible()) { + ui->checkBox_StopAlerts->setVisible(true); this->show(); - } else { - // The rest of the UI remains in the state set by resetDialog - ui->label_Status->setText("Your version of Porymap is up to date!"); - } + } +} + +void UpdatePromoter::disableRequestsUntil(const QDateTime time) { + this->button_Retry->setEnabled(false); + + auto timeUntil = QDateTime::currentDateTime().msecsTo(time); + if (timeUntil < 0) timeUntil = 0; + QTimer::singleShot(timeUntil, Qt::VeryCoarseTimer, [this]() { + this->button_Retry->setEnabled(true); + }); } -void UpdatePromoter::processError(const QString &err) { +void UpdatePromoter::error(const QString &err) { const QString message = QString("Failed to check for version update: %1").arg(err); - if (this->isVisible()) { - ui->label_Status->setText(message); - } else { + ui->label_Status->setText(message); + if (!this->isVisible()) logWarn(message); - } } bool UpdatePromoter::isNewerVersion(int major, int minor, int patch) { @@ -150,35 +181,17 @@ bool UpdatePromoter::isNewerVersion(int major, int minor, int patch) { return patch > PORYMAP_VERSION_PATCH; } -// The dialog can either be shown programmatically when an update is available -// or if the user manually selects "Check for Updates" in the menu. -// When the dialog is shown programmatically there is a check box to disable automatic alerts. -// If the user requested the dialog (and it wasn't already open) this check box should be hidden. -void UpdatePromoter::requestDialog() { - if (!this->isVisible()){ - ui->checkBox_StopAlerts->setVisible(false); - this->show(); - } else if (this->isMinimized()) { - this->showNormal(); - } else { - this->raise(); - this->activateWindow(); - } -} - void UpdatePromoter::updatePreferences() { const QSignalBlocker blocker(ui->checkBox_StopAlerts); - ui->checkBox_StopAlerts->setChecked(porymapConfig.getCheckForUpdates()); + ui->checkBox_StopAlerts->setChecked(!porymapConfig.getCheckForUpdates()); } void UpdatePromoter::dialogButtonClicked(QAbstractButton *button) { - auto buttonRole = ui->buttonBox->buttonRole(button); - if (buttonRole == QDialogButtonBox::RejectRole) { + if (ui->buttonBox->buttonRole(button) == QDialogButtonBox::RejectRole) { this->close(); - } else if (buttonRole == QDialogButtonBox::AcceptRole) { - // "Retry" button + } else if (button == this->button_Retry) { this->checkForUpdates(); - } else if (button == this->button_Downloads && !this->downloadLink.isEmpty()) { - QDesktopServices::openUrl(QUrl(this->downloadLink)); + } else if (button == this->button_Downloads) { + QDesktopServices::openUrl(this->downloadUrl); } } From 582fb101cf86ce2ab05136ce0099aa6c4af0728d Mon Sep 17 00:00:00 2001 From: GriffinR Date: Fri, 2 Feb 2024 10:31:11 -0500 Subject: [PATCH 09/12] Minor network fixes --- src/core/network.cpp | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/core/network.cpp b/src/core/network.cpp index 3f2d3cb2b..901d291f5 100644 --- a/src/core/network.cpp +++ b/src/core/network.cpp @@ -72,7 +72,11 @@ void NetworkAccessManager::processReply(QNetworkReply * reply, NetworkReplyData if (!reply || !reply->isFinished()) return; - auto url = reply->url(); + // 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(); @@ -88,7 +92,11 @@ void NetworkAccessManager::processReply(QNetworkReply * reply, NetworkReplyData if (statusCode == 304) { // "Not Modified", data hasn't changed since our last request. - data->m_body = this->cache.value(url)->data; + 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; } From 5def0e8be188ecdd9145de4dd4fcff015a069285 Mon Sep 17 00:00:00 2001 From: GriffinR Date: Mon, 5 Feb 2024 11:54:35 -0500 Subject: [PATCH 10/12] Reenable Retry button for parsing errors --- include/ui/updatepromoter.h | 3 +-- src/core/network.cpp | 2 +- src/ui/updatepromoter.cpp | 30 +++++++++++++++--------------- 3 files changed, 17 insertions(+), 18 deletions(-) diff --git a/include/ui/updatepromoter.h b/include/ui/updatepromoter.h index 4465e0dcc..b3abd3819 100644 --- a/include/ui/updatepromoter.h +++ b/include/ui/updatepromoter.h @@ -37,8 +37,7 @@ class UpdatePromoter : public QDialog void resetDialog(); void get(const QUrl &url); void processWebpage(const QJsonDocument &data, const QUrl &nextUrl); - void disableRequestsUntil(const QDateTime time); - void error(const QString &err); + void error(const QString &err, const QDateTime time = QDateTime()); bool isNewerVersion(int major, int minor, int patch); private slots: diff --git a/src/core/network.cpp b/src/core/network.cpp index 901d291f5..94594d682 100644 --- a/src/core/network.cpp +++ b/src/core/network.cpp @@ -147,7 +147,7 @@ void NetworkAccessManager::processReply(QNetworkReply * reply, NetworkReplyData this->cache.insert(url, cacheEntry); } auto eTagHeader = reply->header(QNetworkRequest::ETagHeader); - if (eTagHeader.isValid()) + if (eTagHeader.canConvert()) cacheEntry->eTag = eTagHeader.toString(); cacheEntry->data = data->m_body = reply->readAll(); diff --git a/src/ui/updatepromoter.cpp b/src/ui/updatepromoter.cpp index 71f4dce9d..5305a6474 100644 --- a/src/ui/updatepromoter.cpp +++ b/src/ui/updatepromoter.cpp @@ -69,8 +69,7 @@ void UpdatePromoter::get(const QUrl &url) { auto reply = this->manager->get(url); connect(reply, &NetworkReplyData::finished, [this, reply] () { if (!reply->errorString().isEmpty()) { - this->error(reply->errorString()); - this->disableRequestsUntil(reply->retryAfter()); + this->error(reply->errorString(), reply->retryAfter()); } else { this->processWebpage(QJsonDocument::fromJson(reply->body()), reply->nextUrl()); } @@ -79,8 +78,7 @@ void UpdatePromoter::get(const QUrl &url) { } // 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. -// Returns true when finished, returns false to request processing for the next page. +// 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; @@ -156,21 +154,23 @@ void UpdatePromoter::processWebpage(const QJsonDocument &data, const QUrl &nextU } } -void UpdatePromoter::disableRequestsUntil(const QDateTime time) { - this->button_Retry->setEnabled(false); - - auto timeUntil = QDateTime::currentDateTime().msecsTo(time); - if (timeUntil < 0) timeUntil = 0; - QTimer::singleShot(timeUntil, Qt::VeryCoarseTimer, [this]() { - this->button_Retry->setEnabled(true); - }); -} - -void UpdatePromoter::error(const QString &err) { +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); + } } bool UpdatePromoter::isNewerVersion(int major, int minor, int patch) { From e76729ce62b0217991f912593a2297a153125c16 Mon Sep 17 00:00:00 2001 From: GriffinR Date: Tue, 6 Feb 2024 16:15:56 -0500 Subject: [PATCH 11/12] Limit update promoter to Windows/macOS --- src/mainwindow.cpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index bc2595335..3d094be25 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; @@ -111,6 +117,10 @@ void MainWindow::initWindow() { this->initShortcuts(); this->restoreWindowState(); +#ifndef RELEASE_PLATFORM + ui->actionCheck_for_Updates->setVisible(false); +#endif + setWindowDisabled(true); } @@ -254,6 +264,7 @@ 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); @@ -278,6 +289,9 @@ void MainWindow::checkForUpdates(bool requestedByUser) { this->updatePromoter->checkForUpdates(); porymapConfig.setLastUpdateCheckTime(QDateTime::currentDateTime()); } +#else +void MainWindow::checkForUpdates(bool) {} +#endif void MainWindow::initEditor() { this->editor = new Editor(ui); From 73b5c0501d675890e52aef076b83f82e3b6e510a Mon Sep 17 00:00:00 2001 From: GriffinR Date: Wed, 7 Feb 2024 15:35:11 -0500 Subject: [PATCH 12/12] Only alert user at most once per new release --- include/config.h | 7 +++++ include/ui/updatepromoter.h | 4 +-- porymap.pro | 10 ++----- src/config.cpp | 18 +++++++++++++ src/mainwindow.cpp | 2 +- src/scriptapi/scripting.cpp | 6 ++--- src/ui/aboutporymap.cpp | 2 +- src/ui/updatepromoter.cpp | 52 +++++++++++++++---------------------- 8 files changed, 55 insertions(+), 46 deletions(-) diff --git a/include/config.h b/include/config.h index 485e97eb0..54398ccad 100644 --- a/include/config.h +++ b/include/config.h @@ -10,9 +10,12 @@ #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}) @@ -78,6 +81,7 @@ class PorymapConfig: public KeyValueConfigBase this->warpBehaviorWarningDisabled = false; this->checkForUpdates = true; this->lastUpdateCheckTime = QDateTime(); + this->lastUpdateCheckVersion = porymapVersion; this->rateLimitTimes.clear(); } void addRecentProject(QString project); @@ -112,6 +116,7 @@ class PorymapConfig: public KeyValueConfigBase void setWarpBehaviorWarningDisabled(bool disabled); void setCheckForUpdates(bool enabled); void setLastUpdateCheckTime(QDateTime time); + void setLastUpdateCheckVersion(QVersionNumber version); void setRateLimitTimes(QMap map); QString getRecentProject(); QStringList getRecentProjects(); @@ -145,6 +150,7 @@ class PorymapConfig: public KeyValueConfigBase bool getWarpBehaviorWarningDisabled(); bool getCheckForUpdates(); QDateTime getLastUpdateCheckTime(); + QVersionNumber getLastUpdateCheckVersion(); QMap getRateLimitTimes(); protected: virtual QString getConfigFilepath() override; @@ -196,6 +202,7 @@ class PorymapConfig: public KeyValueConfigBase bool warpBehaviorWarningDisabled; bool checkForUpdates; QDateTime lastUpdateCheckTime; + QVersionNumber lastUpdateCheckVersion; QMap rateLimitTimes; }; diff --git a/include/ui/updatepromoter.h b/include/ui/updatepromoter.h index b3abd3819..8b67c69e7 100644 --- a/include/ui/updatepromoter.h +++ b/include/ui/updatepromoter.h @@ -5,6 +5,7 @@ #include #include +#include namespace Ui { class UpdatePromoter; @@ -29,7 +30,7 @@ class UpdatePromoter : public QDialog QString changelog; QUrl downloadUrl; - bool breakingChanges; + QVersionNumber newVersion; bool foundReleases; QSet visitedUrls; // Prevent infinite redirection @@ -38,7 +39,6 @@ class UpdatePromoter : public QDialog void get(const QUrl &url); void processWebpage(const QJsonDocument &data, const QUrl &nextUrl); void error(const QString &err, const QDateTime time = QDateTime()); - bool isNewerVersion(int major, int minor, int patch); private slots: void dialogButtonClicked(QAbstractButton *button); diff --git a/porymap.pro b/porymap.pro index cf4cc4516..43eef36b6 100644 --- a/porymap.pro +++ b/porymap.pro @@ -14,14 +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_MAJOR = 5 -VERSION_MINOR = 3 -VERSION_PATCH = 0 -VERSION = $${VERSION_MAJOR}.$${VERSION_MINOR}.$${VERSION_PATCH} -DEFINES += PORYMAP_VERSION_MAJOR=$$VERSION_MAJOR \ - PORYMAP_VERSION_MINOR=$$VERSION_MINOR \ - PORYMAP_VERSION_PATCH=$$VERSION_PATCH \ - PORYMAP_VERSION=\\\"$$VERSION\\\" +VERSION = 5.3.0 +DEFINES += PORYMAP_VERSION=\\\"$$VERSION\\\" SOURCES += src/core/block.cpp \ src/core/bitpacker.cpp \ diff --git a/src/config.cpp b/src/config.cpp index 967c21c88..177aeafad 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -411,6 +411,14 @@ void PorymapConfig::parseConfigKeyValue(QString key, QString value) { 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); @@ -465,6 +473,7 @@ QMap PorymapConfig::getKeyValueMap() { 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(); @@ -664,6 +673,11 @@ void PorymapConfig::setLastUpdateCheckTime(QDateTime time) { this->save(); } +void PorymapConfig::setLastUpdateCheckVersion(QVersionNumber version) { + this->lastUpdateCheckVersion = version; + this->save(); +} + void PorymapConfig::setRateLimitTimes(QMap map) { this->rateLimitTimes = map; this->save(); @@ -831,6 +845,10 @@ QDateTime PorymapConfig::getLastUpdateCheckTime() { return this->lastUpdateCheckTime; } +QVersionNumber PorymapConfig::getLastUpdateCheckVersion() { + return this->lastUpdateCheckVersion; +} + QMap PorymapConfig::getRateLimitTimes() { return this->rateLimitTimes; } diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 3d094be25..fb08e8983 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -65,7 +65,7 @@ MainWindow::MainWindow(QWidget *parent) : ui->setupUi(this); cleanupLargeLog(); - logInfo(QString("Launching Porymap v%1").arg(PORYMAP_VERSION)); + logInfo(QString("Launching Porymap v%1").arg(QCoreApplication::applicationVersion())); this->initWindow(); if (porymapConfig.getReopenOnLaunch() && this->openProject(porymapConfig.getRecentProject(), true)) diff --git a/src/scriptapi/scripting.cpp b/src/scriptapi/scripting.cpp index aa5477244..112585c74 100644 --- a/src/scriptapi/scripting.cpp +++ b/src/scriptapi/scripting.cpp @@ -78,9 +78,9 @@ void Scripting::populateGlobalObject(MainWindow *mainWindow) { // Get version numbers QJSValue version = instance->engine->newObject(); - version.setProperty("major", PORYMAP_VERSION_MAJOR); - version.setProperty("minor", PORYMAP_VERSION_MINOR); - version.setProperty("patch", PORYMAP_VERSION_PATCH); + version.setProperty("major", porymapVersion.majorVersion()); + version.setProperty("minor", porymapVersion.minorVersion()); + version.setProperty("patch", porymapVersion.microVersion()); constants.setProperty("version", version); // Get basic tileset information diff --git a/src/ui/aboutporymap.cpp b/src/ui/aboutporymap.cpp index 96f20ee8d..38c28144d 100644 --- a/src/ui/aboutporymap.cpp +++ b/src/ui/aboutporymap.cpp @@ -8,7 +8,7 @@ AboutPorymap::AboutPorymap(QWidget *parent) : { ui->setupUi(this); - this->ui->label_Version->setText(QString("Version %1 - %2").arg(PORYMAP_VERSION).arg(QStringLiteral(__DATE__))); + this->ui->label_Version->setText(QString("Version %1 - %2").arg(QCoreApplication::applicationVersion()).arg(QStringLiteral(__DATE__))); this->ui->textBrowser->setSource(QUrl("qrc:/CHANGELOG.md")); } diff --git a/src/ui/updatepromoter.cpp b/src/ui/updatepromoter.cpp index 5305a6474..8ca2b6cd8 100644 --- a/src/ui/updatepromoter.cpp +++ b/src/ui/updatepromoter.cpp @@ -43,7 +43,7 @@ void UpdatePromoter::resetDialog() { this->changelog = QString(); this->downloadUrl = QString(); - this->breakingChanges = false; + this->newVersion = QVersionNumber(); this->foundReleases = false; this->visitedUrls.clear(); } @@ -87,19 +87,12 @@ void UpdatePromoter::processWebpage(const QJsonDocument &data, const QUrl &nextU // Convert tag string to version numbers const QString tagName = release.value("tag_name").toString(); - const QStringList tag = tagName.split("."); - if (tag.length() != 3) continue; - bool ok; - int major = tag.at(0).toInt(&ok); - if (!ok) continue; - int minor = tag.at(1).toInt(&ok); - if (!ok) continue; - int patch = tag.at(2).toInt(&ok); - if (!ok) continue; + 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 (!this->isNewerVersion(major, minor, patch)) + if (porymapVersion >= version) break; const QString description = release.value("body").toString(); @@ -116,7 +109,7 @@ void UpdatePromoter::processWebpage(const QJsonDocument &data, const QUrl &nextU continue; } this->downloadUrl = url; - this->breakingChanges = (major > PORYMAP_VERSION_MAJOR); + this->newVersion = version; } // Record the changelog of this release so we can show all changes since the host release. @@ -137,20 +130,25 @@ void UpdatePromoter::processWebpage(const QJsonDocument &data, const QUrl &nextU } // Populate dialog with result - bool updateAvailable = !this->changelog.isEmpty(); - ui->label_Status->setText(updateAvailable ? "A new version of Porymap is available!" - : "Your version of Porymap is up to date!"); - ui->label_Warning->setVisible(this->breakingChanges); ui->text_Changelog->setMarkdown(this->changelog); - ui->text_Changelog->setVisible(updateAvailable); + ui->text_Changelog->setVisible(!this->changelog.isEmpty()); this->button_Downloads->setEnabled(!this->downloadUrl.isEmpty()); this->button_Retry->setEnabled(true); - - // Alert the user if there's a new update available and the dialog wasn't already open. - // Show the window, but also show the option to turn off automatic alerts in the future. - if (updateAvailable && !this->isVisible()) { - ui->checkBox_StopAlerts->setVisible(true); - this->show(); + 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); } } @@ -173,14 +171,6 @@ void UpdatePromoter::error(const QString &err, const QDateTime retryAfter) { } } -bool UpdatePromoter::isNewerVersion(int major, int minor, int patch) { - if (major != PORYMAP_VERSION_MAJOR) - return major > PORYMAP_VERSION_MAJOR; - if (minor != PORYMAP_VERSION_MINOR) - return minor > PORYMAP_VERSION_MINOR; - return patch > PORYMAP_VERSION_PATCH; -} - void UpdatePromoter::updatePreferences() { const QSignalBlocker blocker(ui->checkBox_StopAlerts); ui->checkBox_StopAlerts->setChecked(!porymapConfig.getCheckForUpdates());