From d2ab992c47861b1e9e9526161c1bbcbb445a0c6f Mon Sep 17 00:00:00 2001 From: Vadim Peretokin Date: Wed, 8 Apr 2026 20:19:41 +0200 Subject: [PATCH] Fix: updater to work with github releases (#9125) #### Brief overview of PR changes/additions Migrate updater off dblsqd to work with github releases instead for both official releases and PTBs. #### Motivation for adding to Mudlet dblsqd infrastructure doesn't work anymore. #### Other info (issues closed, discussion etc) The CI part of this where github releases are setup in the right format is not yet ready. --------- Co-authored-by: Stephen Lyons Co-authored-by: Vadim Peretokin --- .gitmodules | 3 - 3rdparty/dblsqd | 1 - CMakeLists.txt | 9 +- src/CMakeLists.txt | 29 +- src/dlgAboutDialog.cpp | 2 +- src/mudlet.cpp | 2 +- src/sparkleupdater.h | 2 +- src/sparkleupdater.mm | 3 +- src/updater.cpp | 450 +++++++++++----------- src/updater.h | 39 +- src/updater/Feed.cpp | 434 +++++++++++++++++++++ src/updater/Feed.h | 100 +++++ src/updater/Release.cpp | 201 ++++++++++ src/updater/Release.h | 66 ++++ src/updater/SemVer.cpp | 119 ++++++ src/updater/SemVer.h | 50 +++ src/updater/UpdateDialog.cpp | 704 +++++++++++++++++++++++++++++++++++ src/updater/UpdateDialog.h | 129 +++++++ src/updater/update_dialog.ui | 372 ++++++++++++++++++ 19 files changed, 2474 insertions(+), 241 deletions(-) delete mode 160000 3rdparty/dblsqd create mode 100644 src/updater/Feed.cpp create mode 100644 src/updater/Feed.h create mode 100644 src/updater/Release.cpp create mode 100644 src/updater/Release.h create mode 100644 src/updater/SemVer.cpp create mode 100644 src/updater/SemVer.h create mode 100644 src/updater/UpdateDialog.cpp create mode 100644 src/updater/UpdateDialog.h create mode 100644 src/updater/update_dialog.ui diff --git a/.gitmodules b/.gitmodules index 2fe30987f..dd85a4eb9 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,9 +4,6 @@ [submodule "3rdparty/lua_code_formatter"] path = 3rdparty/lcf url = https://github.com/martin-eden/lua_code_formatter.git -[submodule "3rdparty/dblsqd"] - path = 3rdparty/dblsqd - url = https://github.com/Mudlet/dblsqd-sdk-qt.git [submodule "3rdparty/qt-tags-widget"] path = 3rdparty/qt-tags-widget url = https://github.com/julian-go/qt-tags-widget.git diff --git a/3rdparty/dblsqd b/3rdparty/dblsqd deleted file mode 160000 index 692697328..000000000 --- a/3rdparty/dblsqd +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 692697328a8312c951df12f07f8c8068d8ae24e7 diff --git a/CMakeLists.txt b/CMakeLists.txt index aca829cd0..c8e47b420 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -192,7 +192,7 @@ find_package(Qt6 COMPONENTS Core5Compat REQUIRED) find_package(Lua 5.1 EXACT) -# Set Qt version for edbee-lib and dblsqd +# Set Qt version for edbee-lib option(BUILD_WITH_QT5 "" OFF) option(BUILD_WITH_QT6 "" ON) @@ -232,10 +232,6 @@ git_submodule_init( # GIT_SHALLOW TRUE) # FetchContent_MakeAvailable(nanobench) -if(USE_UPDATER) - git_submodule_init(CHECK_FILE "3rdparty/dblsqd/CMakeLists.txt" SUBMODULE_PATH - "3rdparty/dblsqd" READABLE_NAME "DBLSQD updater") -endif() if(APPLE) if(USE_UPDATER) @@ -267,9 +263,6 @@ add_subdirectory(translations/translated) add_subdirectory(src) add_subdirectory(test) -if(USE_UPDATER) - add_subdirectory(3rdparty/dblsqd) -endif() set(CMAKE_INTERPROCEDURAL_OPTIMIZATION ON) set(CMAKE_INTERPROCEDURAL_OPTIMIZATION_RELEASE -O3) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 0761bfac8..94f432a59 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -436,6 +436,15 @@ set(mudlet_HDRS if(USE_UPDATER) list(APPEND mudlet_SRCS updater.cpp) list(APPEND mudlet_HDRS updater.h) + list(APPEND mudlet_SRCS updater/Feed.cpp) + list(APPEND mudlet_HDRS updater/Feed.h) + list(APPEND mudlet_SRCS updater/Release.cpp) + list(APPEND mudlet_HDRS updater/Release.h) + list(APPEND mudlet_SRCS updater/SemVer.cpp) + list(APPEND mudlet_HDRS updater/SemVer.h) + list(APPEND mudlet_SRCS updater/UpdateDialog.cpp) + list(APPEND mudlet_HDRS updater/UpdateDialog.h) + list(APPEND mudlet_UIS updater/update_dialog.ui) endif(USE_UPDATER) if(USE_3DMAPPER) @@ -707,8 +716,8 @@ if (SYSINFO_INCLUDE_DIR) endif (SYSINFO_INCLUDE_DIR) if(USE_UPDATER) - target_link_libraries(${LIB_MUDLET_TARGET} dblsqd) target_compile_definitions(${LIB_MUDLET_TARGET} PUBLIC INCLUDE_UPDATER) + target_include_directories(${LIB_MUDLET_TARGET} PRIVATE ${CMAKE_CURRENT_BINARY_DIR}) endif(USE_UPDATER) if(USE_3DMAPPER) @@ -804,6 +813,24 @@ if(APPLE) file(GLOB ICON_FILE "icons/mudlet.icns") endif() + # Set Sparkle feed URL in Info.plist for PTB and release builds + if(USE_UPDATER) + if(APP_BUILD MATCHES "-ptb.+") + set(SPARKLE_CHANNEL "ptb") + elseif(NOT (APP_BUILD MATCHES "-dev.+" OR APP_BUILD MATCHES "-test.+")) + set(SPARKLE_CHANNEL "release") + endif() + + if(SPARKLE_CHANNEL) + set(SPARKLE_FEED_URL "https://www.mudlet.org/wp-content/files/appcast/${SPARKLE_CHANNEL}-${CMAKE_SYSTEM_PROCESSOR}.xml") + add_custom_command(TARGET ${EXE_MUDLET_TARGET} POST_BUILD + COMMAND bash -c + "/usr/libexec/PlistBuddy -c 'Delete :SUFeedURL' '$/Contents/Info.plist' 2>/dev/null || true && /usr/libexec/PlistBuddy -c 'Add :SUFeedURL string ${SPARKLE_FEED_URL}' '$/Contents/Info.plist'" + COMMENT "Setting Sparkle feed URL for ${SPARKLE_CHANNEL}-${CMAKE_SYSTEM_PROCESSOR}" + ) + endif() + endif() + file(GLOB_RECURSE MUDLET_LUA_FILES LIST_DIRECTORIES true "mudlet-lua/*") file(GLOB_RECURSE LUA_TRANSLATIONS LIST_DIRECTORIES true "../translations/lua/*") file(GLOB DIC_FILES "*.dic") diff --git a/src/dlgAboutDialog.cpp b/src/dlgAboutDialog.cpp index 4dc1e0e65..41a899dae 100644 --- a/src/dlgAboutDialog.cpp +++ b/src/dlgAboutDialog.cpp @@ -885,7 +885,7 @@ void dlgAboutDialog::setThirdPartyTab(const QString& htmlHead) const "All rights reserved.")); #if defined(INCLUDE_UPDATER) || defined(DEBUG_SHOWALL) - QString DblsqdHeader(tr("

Dblsqd

" + QString DblsqdHeader(tr("

Dblsqd (derived work)

" "

Copyright © 2017 Philipp Medien

")); #if defined(Q_OS_MACOS) || defined(DEBUG_SHOWALL) QString SparkleHeader(tr("

Sparkle - macOS updater

" diff --git a/src/mudlet.cpp b/src/mudlet.cpp index 870089c6c..3aa8fdb68 100644 --- a/src/mudlet.cpp +++ b/src/mudlet.cpp @@ -740,7 +740,7 @@ void mudlet::init() connect(this, &mudlet::signal_windowStateChanged, this, &mudlet::slot_windowStateChanged); #if defined(INCLUDE_UPDATER) - pUpdater = new Updater(this, mpSettings, publicTestVersion); + pUpdater = new Updater(this, mpSettings, !releaseVersion); connect(pUpdater, &Updater::signal_updateAvailable, this, &mudlet::slot_updateAvailable); connect(pUpdater, &Updater::signal_updateCheckFailed, this, &mudlet::slot_updateCheckFailed); connect(dactionUpdate, &QAction::triggered, this, &mudlet::slot_manualUpdateCheck); diff --git a/src/sparkleupdater.h b/src/sparkleupdater.h index 8dc7271c6..6d61d7d6d 100644 --- a/src/sparkleupdater.h +++ b/src/sparkleupdater.h @@ -31,7 +31,7 @@ class SparkleUpdater : public QObject Q_OBJECT public: - SparkleUpdater(); + explicit SparkleUpdater(QObject* parent = nullptr); ~SparkleUpdater(); diff --git a/src/sparkleupdater.mm b/src/sparkleupdater.mm index 058a87499..5b9c909df 100644 --- a/src/sparkleupdater.mm +++ b/src/sparkleupdater.mm @@ -37,7 +37,8 @@ @end -SparkleUpdater::SparkleUpdater() +SparkleUpdater::SparkleUpdater(QObject* parent) +: QObject(parent) { @autoreleasepool { _updaterDelegate = [[SparkleUpdaterDelegate alloc] init]; diff --git a/src/updater.cpp b/src/updater.cpp index 9f78bf56d..fa9741041 100644 --- a/src/updater.cpp +++ b/src/updater.cpp @@ -19,6 +19,8 @@ #include "updater.h" #include "mudlet.h" +#include "updater/Feed.h" +#include "updater/UpdateDialog.h" #include #include @@ -27,15 +29,10 @@ #include #include "../3rdparty/kdtoolbox/singleshot_connect/singleshot_connect.h" -#if defined(Q_OS_WINDOWS) -#include -#endif - using namespace std::chrono_literals; #if defined(Q_OS_WINDOWS) -// Helper function to clean up .nupkg files from SquirrelTemp directory -// This prevents cross-contamination with other Squirrel-based apps +// Clean up legacy .nupkg files from the previous Squirrel/dblsqd update system's temp directory static void cleanupSquirrelTempFiles() { QString squirrelTempPath = QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation) + qsl("/SquirrelTemp"); @@ -47,7 +44,6 @@ static void cleanupSquirrelTempFiles() qDebug() << "Cleaning up Mudlet files from SquirrelTemp:" << squirrelTempPath; - // Find all Mudlet-related .nupkg files QStringList filters; filters << qsl("Mudlet*.nupkg") << qsl("mudlet*.nupkg"); QFileInfoList nupkgFiles = squirrelTempDir.entryInfoList(filters, QDir::Files); @@ -67,53 +63,39 @@ static void cleanupSquirrelTempFiles() } if (removedCount > 0) { - qWarning() << "Cleaned up" << removedCount << "Mudlet .nupkg files from SquirrelTemp, freed" << (freedSpace / 1024 / 1024) << "MB of disk space"; + qDebug() << "Cleaned up" << removedCount << "Mudlet .nupkg files from SquirrelTemp, freed" << (freedSpace / 1024 / 1024) << "MB of disk space"; } } #endif // Q_OS_WINDOWS // update flows: -// linux: new AppImage is downloaded, unzipped, and put in place of the old one +// linux: new AppImage is downloaded, extracted from its tar archive, and put in place of the old one // user then only restarts mudlet to get the new version -// windows: new squirrel installer is downloaded and saved -// user then restarts, mudlet sees that there's a new installer available: launches it -// and promptly quits. Installer updates Mudlet and launches Mudlet when its done +// windows: installer .exe is downloaded from GitHub Releases. When the user clicks restart, +// a batch file is created that waits for Mudlet to exit, then runs the installer // mac: handled completely outside of Mudlet by Sparkle Updater::Updater(QObject* parent, QSettings* settings, bool testVersion) : QObject(parent) +#if !defined(Q_OS_MACOS) +//: Label for the update/restart button in the main toolbar , mpInstallOrRestart(new QPushButton(tr("Update"))) +#endif , mUpdateInstalled(false) { Q_ASSERT_X(settings, "updater", "QSettings object is required for the updater to work"); - this->settings = settings; + mSettings = settings; - QString baseUrl = QStringLiteral("https://feeds.dblsqd.com/MKMMR7HNSP65PquQQbiDIw"); - QString channel = testVersion ? QStringLiteral("public-test-build") : QStringLiteral("release"); - - // On 32-bit Windows, check if we can upgrade to 64-bit -#if defined(Q_OS_WINDOWS) - QString arch = is64BitCompatible() ? QStringLiteral("x86_64") : QStringLiteral("x86"); -#else - QString arch = QString(); // Let Feed auto-detect for other platforms -#endif - - feed = new dblsqd::Feed(); - feed->setUrl(baseUrl, channel, QString(), arch, QString()); - - if (!mDailyCheck) { - mDailyCheck = std::make_unique(); - } + mFeed = new dblsqd::Feed(this); + mFeed->setRepo(qsl("Mudlet"), qsl("Mudlet"), testVersion); + mPeriodicCheck = std::make_unique(); } Updater::~Updater() { - delete (feed); + delete mUpdateDialog; } -// start the update process and figure out what needs to be done. -// If it's a silent update, do that right away, otherwise -// setup manual updates to do our custom actions void Updater::checkUpdatesOnStart() { #if defined(Q_OS_MACOS) @@ -124,11 +106,11 @@ void Updater::checkUpdatesOnStart() setupOnWindows(); #endif - mDailyCheck->setInterval(12h); - connect(mDailyCheck.get(), &QTimer::timeout, this, [this] { - KDToolBox::connectSingleShot(feed, &dblsqd::Feed::ready, this, [this]() { - auto updates = feed->getUpdates(dblsqd::Release::getCurrentRelease()); - qWarning() << "Bi-daily check for updates:" << updates.size() << "update(s) available"; + mPeriodicCheck->setInterval(12h); + connect(mPeriodicCheck.get(), &QTimer::timeout, this, [this] { + KDToolBox::connectSingleShot(mFeed, &dblsqd::Feed::ready, this, [this]() { + auto updates = mFeed->getUpdates(dblsqd::Release::getCurrentRelease()); + qWarning() << "Twice-daily check for updates:" << updates.size() << "update(s) available"; if (updates.isEmpty()) { return; } @@ -138,14 +120,16 @@ void Updater::checkUpdatesOnStart() return; } - downloadReleaseIfValid(updates.first()); + if (!downloadReleaseIfValid(updates.first())) { + emit signal_updateAvailable(updates.size()); + } }); - KDToolBox::connectSingleShot(feed, &dblsqd::Feed::loadError, this, [](const QString& error) { - qWarning() << "Bi-daily update check: failed to load feed:" << error; + KDToolBox::connectSingleShot(mFeed, &dblsqd::Feed::loadError, this, [](const QString& error) { + qWarning() << "Twice-daily update check: failed to load feed:" << error; }); - feed->load(); + mFeed->load(); }); - mDailyCheck->start(); + mPeriodicCheck->start(); } void Updater::setAutomaticUpdates(const bool state) @@ -153,7 +137,7 @@ void Updater::setAutomaticUpdates(const bool state) #if defined(Q_OS_MACOS) msparkleUpdater->setAutomaticallyDownloadsUpdates(state); #else - dblsqd::UpdateDialog::enableAutoDownload(state, settings); + dblsqd::UpdateDialog::enableAutoDownload(state, mSettings); #endif // The sense of this control is inverted on the dlgProfilePreferences - so // must be inverted here: @@ -165,7 +149,7 @@ bool Updater::updateAutomatically() const #if defined(Q_OS_MACOS) return msparkleUpdater->automaticallyDownloadsUpdates(); #else - return dblsqd::UpdateDialog::autoDownloadEnabled(true, settings); + return dblsqd::UpdateDialog::autoDownloadEnabled(true, mSettings); #endif } @@ -179,12 +163,12 @@ void Updater::manuallyCheckUpdates() } mManualCheckInProgress = true; - feed->load(); - KDToolBox::connectSingleShot(feed, &dblsqd::Feed::ready, this, [this]() { + mFeed->load(); + KDToolBox::connectSingleShot(mFeed, &dblsqd::Feed::ready, this, [this]() { mManualCheckInProgress = false; showDialogManually(); }); - KDToolBox::connectSingleShot(feed, &dblsqd::Feed::loadError, this, [this](const QString& error) { + KDToolBox::connectSingleShot(mFeed, &dblsqd::Feed::loadError, this, [this](const QString& error) { mManualCheckInProgress = false; emit signal_updateCheckFailed(error); }); @@ -193,34 +177,45 @@ void Updater::manuallyCheckUpdates() void Updater::showDialogManually() const { - updateDialog->show(); + if (!mUpdateDialog) { + qWarning() << "showDialogManually called but update dialog not initialized"; + return; + } + mUpdateDialog->show(); } -// only shows the changelog since the last version void Updater::showChangelog() const { - auto changelogDialog = new dblsqd::UpdateDialog(feed, dblsqd::UpdateDialog::ManualChangelog); + auto changelogDialog = new dblsqd::UpdateDialog(mFeed, dblsqd::UpdateDialog::ManualChangelog, mSettings); changelogDialog->setAttribute(Qt::WA_DeleteOnClose); changelogDialog->setPreviousVersion(getPreviousVersion()); changelogDialog->show(); } -// shows the full changelog void Updater::showFullChangelog() const { - if (!feed->isReady()) { - KDToolBox::connectSingleShot(feed, &dblsqd::Feed::ready, feed, [=, this]() { - showChangelog(); + if (!mFeed->isReady()) { + KDToolBox::connectSingleShot(mFeed, &dblsqd::Feed::ready, mFeed, [=, this]() { + showFullChangelog(); }); - feed->load(); + KDToolBox::connectSingleShot(mFeed, &dblsqd::Feed::loadError, mFeed, [](const QString& error) { + qWarning() << "Failed to load feed for changelog:" << error; + //: Error title for dialog shown when changelog fails to load + QMessageBox::warning(nullptr, + tr("Changelog Error"), + //: Error message shown when changelog fails to load from the server + tr("Could not load the changelog. Please try again later.")); + }); + mFeed->load(); return; } - auto changelogDialog = new dblsqd::UpdateDialog(feed, dblsqd::UpdateDialog::ManualChangelog); + auto changelogDialog = new dblsqd::UpdateDialog(mFeed, dblsqd::UpdateDialog::ManualChangelog, mSettings); changelogDialog->setAttribute(Qt::WA_DeleteOnClose); - auto releases = feed->getReleases(); - const auto firstVersion = releases.constLast().getVersion(); - changelogDialog->setMinVersion(firstVersion); + auto releases = mFeed->getReleases(); + if (!releases.isEmpty()) { + changelogDialog->setMinVersion(releases.constLast().getVersion()); + } changelogDialog->setMaxVersion(QApplication::applicationVersion()); changelogDialog->show(); } @@ -231,21 +226,30 @@ bool Updater::downloadReleaseIfValid(const dblsqd::Release& release) if (!downloadUrl.isValid() || downloadUrl.isEmpty()) { qWarning() << "Update check: invalid download URL for release" << release.getVersion(); if (mManualCheckInProgress) { - emit signal_updateCheckFailed(tr("Invalid download URL for version %1").arg(release.getVersion())); + //: Error shown when no download is available for the user's platform. %1 is the version number. + emit signal_updateCheckFailed(tr("No download available for version %1. Please try again later or download manually from https://www.mudlet.org/download/").arg(release.getVersion())); } return false; } - feed->downloadRelease(release); + mFeed->downloadRelease(release); return true; } void Updater::finishSetup() { + auto updates = mFeed->getUpdates(dblsqd::Release::getCurrentRelease()); #if defined(Q_OS_LINUX) - qWarning() << "Successfully updated Mudlet to" << feed->getUpdates(dblsqd::Release::getCurrentRelease()).constFirst().getVersion(); + if (!updates.isEmpty()) { + qWarning() << "Successfully updated Mudlet to" << updates.constFirst().getVersion(); + } else { + qWarning() << "Update finished but could not determine target version"; + } #elif defined(Q_OS_WINDOWS) - qWarning() << "Mudlet prepped to update to" << feed->getUpdates(dblsqd::Release::getCurrentRelease()).first().getVersion() << "on restart"; - // Clean up .nupkg files from SquirrelTemp to prevent cross-app contamination + if (!updates.isEmpty()) { + qWarning() << "Mudlet prepped to update to" << updates.first().getVersion() << "on restart"; + } else { + qWarning() << "Mudlet prepped to update on restart"; + } cleanupSquirrelTempFiles(); #endif recordUpdateTime(); @@ -258,134 +262,108 @@ void Updater::finishSetup() void Updater::setupOnMacOS() { // don't need to explicitly check for updates - sparkle will do so on its own - msparkleUpdater = new SparkleUpdater(); + msparkleUpdater = new SparkleUpdater(this); } #endif // Q_OS_MACOS -#if defined(Q_OS_WINDOWS) -void Updater::setupOnWindows() +#if !defined(Q_OS_MACOS) +void Updater::setupPlatformUpdater() { - // Clean up old .nupkg files on startup - cleanupSquirrelTempFiles(); - - // Setup to automatically download the new release when an update is available - connect(feed, &dblsqd::Feed::ready, feed, [=, this]() { + connect(mFeed, &dblsqd::Feed::ready, this, [=, this]() { auto* pMudlet = mudlet::self(); if (!pMudlet || pMudlet->developmentVersion) { return; } - auto updates = feed->getUpdates(dblsqd::Release::getCurrentRelease()); + auto updates = mFeed->getUpdates(dblsqd::Release::getCurrentRelease()); qWarning() << "Checked for updates:" << updates.size() << "update(s) available"; - if (updates.isEmpty()) { - return; - } else if (!updateAutomatically()) { + if (!updates.isEmpty()) { emit signal_updateAvailable(updates.size()); - } else { - downloadReleaseIfValid(updates.first()); } }); - // Setup to run setup.exe to replace the old installation - connect(feed, &dblsqd::Feed::downloadFinished, this, [=, this]() { - // if automatic updates are enabled, and this isn't a manual check, perform the automatic update - if (!(updateAutomatically() && updateDialog->isHidden())) { + connect(mFeed, &dblsqd::Feed::downloadError, this, [this](const QString& error) { + qWarning() << "Automatic update download failed:" << error; + emit signal_updateCheckFailed(error); + }); + + mUpdateDialog = new dblsqd::UpdateDialog(mFeed, updateAutomatically() ? dblsqd::UpdateDialog::OnLastWindowClosed : dblsqd::UpdateDialog::Manual, mSettings); + //: Label for the update button shown in the update dialog + mpInstallOrRestart->setText(tr("Update")); + mUpdateDialog->addInstallButton(mpInstallOrRestart); + connect(mUpdateDialog, &dblsqd::UpdateDialog::installButtonClicked, this, &Updater::slot_installOrRestartClicked); +} +#endif // !Q_OS_MACOS + +#if defined(Q_OS_WINDOWS) +void Updater::setupOnWindows() +{ + cleanupSquirrelTempFiles(); + setupPlatformUpdater(); + + connect(mFeed, &dblsqd::Feed::downloadFinished, this, [=, this]() { + if (!(updateAutomatically() && mUpdateDialog->isHidden())) { return; } - auto* downloadFile = feed->getDownloadFile(); - if (!downloadFile) { - qWarning() << "Download finished but no download file available - feed URL:" << feed->getUrl(); + const QString fileName = mFeed->getDownloadFilePath(); + if (fileName.isEmpty()) { + qWarning() << "Download finished but no download file available - feed URL:" << mFeed->getUrl(); + //: Error shown when the automatic update download finished but produced no file + emit signal_updateCheckFailed(tr("Update download failed. Please try again or download manually from https://www.mudlet.org/download/")); return; } - const QString fileName = downloadFile->fileName(); QFuture future = QtConcurrent::run([=, this]() { prepareSetupOnWindows(fileName); }); - // replace current binary with the unzipped one auto watcher = new QFutureWatcher; connect(watcher, &QFutureWatcher::finished, this, &Updater::finishSetup); connect(watcher, &QFutureWatcher::finished, watcher, &QObject::deleteLater); watcher->setFuture(future); }); - - // finally, create the dblsqd objects. Constructing the UpdateDialog triggers the update check - updateDialog = new dblsqd::UpdateDialog(feed, updateAutomatically() ? dblsqd::UpdateDialog::OnLastWindowClosed : dblsqd::UpdateDialog::Manual, nullptr, settings); - mpInstallOrRestart->setText(tr("Update")); - updateDialog->addInstallButton(mpInstallOrRestart); - connect(updateDialog, &dblsqd::UpdateDialog::installButtonClicked, this, &Updater::slot_installOrRestartClicked); } -// Store the path to the downloaded installer for use when user clicks "Restart to update" void Updater::prepareSetupOnWindows(const QString& downloadedSetupName) { mDownloadedInstallerPath = downloadedSetupName; qWarning() << "Installer ready at:" << mDownloadedInstallerPath; } -#endif // Q_OS_WIN +#endif // Q_OS_WINDOWS #if defined(Q_OS_LINUX) void Updater::setupOnLinux() { - // Setup to automatically download the new release when an update is - // available or wave a flag when it is to be done manually - // Setup to automatically download the new release when an update is available - connect(feed, &dblsqd::Feed::ready, this, [=, this]() { - // don't update development builds to prevent auto-update from overwriting your - // compiled binary while in development - auto* pMudlet = mudlet::self(); - if (!pMudlet || pMudlet->developmentVersion) { + setupPlatformUpdater(); + + connect(mFeed, &dblsqd::Feed::downloadFinished, this, [=, this]() { + if (!(updateAutomatically() && mUpdateDialog->isHidden())) { return; } - auto updates = feed->getUpdates(dblsqd::Release::getCurrentRelease()); - qWarning() << "Checked for updates:" << updates.size() << "update(s) available"; - if (updates.isEmpty()) { - return; - } else if (!updateAutomatically()) { - emit signal_updateAvailable(updates.size()); - return; - } else { - downloadReleaseIfValid(updates.first()); - } - }); - - // Setup to unzip and replace old binary when the download is done - connect(feed, &dblsqd::Feed::downloadFinished, this, [=, this]() { - // if automatic updates are enabled, and this isn't a manual check, perform the automatic update - if (!(updateAutomatically() && updateDialog->isHidden())) { + const QString fileName = mFeed->getDownloadFilePath(); + if (fileName.isEmpty()) { + qWarning() << "Download finished but no download file available - feed URL:" << mFeed->getUrl(); + //: Error shown when the automatic update download finished but produced no file + emit signal_updateCheckFailed(tr("Update download failed. Please try again or download manually from https://www.mudlet.org/download/")); return; } - auto* downloadFile = feed->getDownloadFile(); - if (!downloadFile) { - qWarning() << "Download finished but no download file available - feed URL:" << feed->getUrl(); - return; - } - const QString fileName = downloadFile->fileName(); - QFuture future = QtConcurrent::run([=, this]() { untarOnLinux(fileName); }); - // replace current binary with the unzipped one auto watcher = new QFutureWatcher; connect(watcher, &QFutureWatcher::finished, this, &Updater::slot_updateLinuxBinary); connect(watcher, &QFutureWatcher::finished, watcher, &QObject::deleteLater); watcher->setFuture(future); }); - - // finally, create the dblsqd objects. Constructing the UpdateDialog triggers the update check - updateDialog = new dblsqd::UpdateDialog(feed, updateAutomatically() ? dblsqd::UpdateDialog::OnLastWindowClosed : dblsqd::UpdateDialog::Manual, nullptr, settings); - mpInstallOrRestart->setText(tr("Update")); - updateDialog->addInstallButton(mpInstallOrRestart); - connect(updateDialog, &dblsqd::UpdateDialog::installButtonClicked, this, &Updater::slot_installOrRestartClicked); } void Updater::untarOnLinux(const QString& fileName) { + mUnzippedBinaryName.clear(); Q_ASSERT_X(QThread::currentThread() != QCoreApplication::instance()->thread(), "untarOnLinux", "method should not be called in the main GUI thread to avoid a degradation in UX"); qWarning() << __func__ << "started"; @@ -394,10 +372,20 @@ void Updater::untarOnLinux(const QString& fileName) // we can assume tar to be present on a Linux system. If it's not, it'd be rather broken. // tar output folder has to end with a slash tar.start(qsl("tar"), QStringList() << qsl("-xvf") << fileName << qsl("-C") << QStandardPaths::writableLocation(QStandardPaths::TempLocation) + qsl("/")); - if (!tar.waitForFinished()) { - qWarning() << "Untarring" << fileName << "failed:" << tar.errorString(); + if (!tar.waitForStarted(5000)) { + qWarning() << "Could not start tar:" << tar.errorString(); + } else if (!tar.waitForFinished(300000)) { + tar.kill(); + qWarning() << "Untarring" << fileName << "timed out after 5 minutes:" << tar.errorString(); + } else if (tar.exitCode() != 0) { + qWarning() << "Untarring" << fileName << "failed - exit code:" << tar.exitCode() << tar.errorString(); } else { - unzippedBinaryName = tar.readAll().trimmed(); + const QString output = tar.readAll().trimmed(); + if (output.isEmpty() || output.contains(QLatin1Char('\n'))) { + qWarning() << "Unexpected tar output (expected single filename):" << output; + } else { + mUnzippedBinaryName = output; + } } qWarning() << __func__ << "finished"; } @@ -406,7 +394,14 @@ void Updater::slot_updateLinuxBinary() { qWarning() << __func__ << "started"; - QFileInfo unzippedBinary(QStandardPaths::writableLocation(QStandardPaths::TempLocation) + "/" + unzippedBinaryName); + if (mUnzippedBinaryName.isEmpty()) { + qWarning() << "Extraction failed - no binary to install, aborting update"; + //: Error shown when extracting the downloaded update archive fails on Linux + emit signal_updateCheckFailed(tr("Failed to extract the update. Please try again or download manually from https://www.mudlet.org/download/")); + return; + } + + QFileInfo unzippedBinary(QStandardPaths::writableLocation(QStandardPaths::TempLocation) + "/" + mUnzippedBinaryName); auto systemEnvironment = QProcessEnvironment::systemEnvironment(); auto appimageLocation = systemEnvironment.contains(qsl("APPIMAGE")) ? systemEnvironment.value(qsl("APPIMAGE"), QString()) : QCoreApplication::applicationFilePath(); @@ -416,16 +411,46 @@ void Updater::slot_updateLinuxBinary() executablePermissions |= QFileDevice::ExeOwner | QFileDevice::ExeUser; QDir dir; - // dir.rename actually moves a file - if (!(dir.remove(installedBinaryPath) && dir.rename(unzippedBinary.filePath(), installedBinaryPath))) { - qWarning() << "updating" << installedBinaryPath << "with new version from" << unzippedBinary.filePath() << "failed"; + // Safely replace the old binary: rename old to backup first so we can + // restore it if placing the new binary fails (e.g. cross-device rename) + const QString backupPath = installedBinaryPath + qsl(".bak"); + if (!dir.remove(backupPath) && QFile::exists(backupPath)) { + qWarning() << "Could not remove stale backup at" << backupPath; + //: Error shown when the automatic update fails to install on Linux + emit signal_updateCheckFailed(tr("Failed to install the update. Please try again or download manually from https://www.mudlet.org/download/")); return; } + if (!dir.rename(installedBinaryPath, backupPath)) { + qWarning() << "could not back up old binary from" << installedBinaryPath << "to" << backupPath; + //: Error shown when the automatic update fails to install on Linux + emit signal_updateCheckFailed(tr("Failed to install the update. Please try again or download manually from https://www.mudlet.org/download/")); + return; + } + if (!dir.rename(unzippedBinary.filePath(), installedBinaryPath)) { + qWarning() << "could not move new binary from" << unzippedBinary.filePath() << "to" << installedBinaryPath << "- restoring backup"; + if (!dir.rename(backupPath, installedBinaryPath)) { + qWarning() << "could not restore backup from" << backupPath << "to" << installedBinaryPath; + //: Error shown when the update fails and the previous version could not be restored automatically. %1 is the file path to the backup copy. + emit signal_updateCheckFailed(tr("Failed to install the update and could not restore the previous version. " + "Your previous version is saved at: %1 - please rename it back manually. " + "Alternatively, download a fresh copy from https://www.mudlet.org/download/") + .arg(backupPath)); + } else { + //: Error shown when the automatic update fails to install on Linux + emit signal_updateCheckFailed(tr("Failed to install the update. Please try again or download manually from https://www.mudlet.org/download/")); + } + return; + } + if (!dir.remove(backupPath)) { + qWarning() << "Could not clean up backup file:" << backupPath; + } qWarning() << "successfully replaced old binary with new binary"; QFile updatedBinary(appimageLocation); if (!updatedBinary.setPermissions(executablePermissions)) { qWarning() << "couldn't set executable permissions on updated Mudlet binary at" << installedBinaryPath; + //: Error shown when the automatic update fails to install on Linux + emit signal_updateCheckFailed(tr("Failed to install the update. Please try again or download manually from https://www.mudlet.org/download/")); return; } qWarning() << "successfully set executable permissions for the new binary"; @@ -439,40 +464,43 @@ void Updater::slot_installOrRestartClicked(QAbstractButton* button, const QStrin { Q_UNUSED(button) - // moc, when used with cmake on macOS bugs out if the entire function declaration and definition is entirely - // commented out so we leave a stub in + // moc on macOS requires this function definition to exist even though macOS uses Sparkle instead #if !defined(Q_OS_MACOS) // if the update is already installed, then the button says 'Restart' - do so if (mUpdateInstalled) { - // timer is necessary as calling close right way doesn't seem to do the trick + // defer to next event loop iteration so the dialog close happens after the button click handler returns QTimer::singleShot(0, this, [=, this]() { - updateDialog->close(); - updateDialog->done(0); + mUpdateDialog->close(); + mUpdateDialog->done(0); }); #if defined(Q_OS_WINDOWS) - // On Windows, launch the installer directly with a delay to ensure Mudlet - // has fully exited. This prevents "file in use" errors during the update. - // The installer will relaunch Mudlet after the update completes. + // On Windows, create and launch a batch file that waits for Mudlet to exit, + // then runs the installer. This prevents "file in use" errors during the update. + //: Error title for update-related warning dialogs + const QString errorTitle = tr("Update Error"); + if (mDownloadedInstallerPath.isEmpty() || !QFile::exists(mDownloadedInstallerPath)) { qWarning() << "Installer not found at:" << mDownloadedInstallerPath; - QMessageBox::warning(nullptr, tr("Update Error"), tr("The update installer could not be found. Please try checking for updates again.")); + //: Error shown when the downloaded installer file cannot be found on disk + QMessageBox::warning(nullptr, errorTitle, tr("The update installer could not be found. Please try checking for updates again.")); return; } - // Copy the installer to a permanent location - the source is a QTemporaryFile - // that will be deleted when Mudlet exits. We copy (not move) because AV - // may still have a lock on the file, and copy only needs read access. + // Copy the installer to a permanent location with a known name. We copy + // (not move) because AV software may still have a lock on the file, and + // copy only needs read access. // Use a unique filename with timestamp to avoid conflicts with locked files. QString installerPath = qsl("%1/mudlet-setup-%2.exe").arg(QStandardPaths::writableLocation(QStandardPaths::TempLocation)).arg(QDateTime::currentSecsSinceEpoch()); if (!QFile::copy(mDownloadedInstallerPath, installerPath)) { qWarning() << "Failed to copy installer from" << mDownloadedInstallerPath << "to" << installerPath; - QMessageBox::warning(nullptr, tr("Update Error"), tr("Could not prepare the update installer. Please try again or download the update manually from https://www.mudlet.org/download/")); + //: Error shown when the installer file cannot be copied to a temporary location for launch + QMessageBox::warning(nullptr, errorTitle, tr("Could not prepare the update installer. Please try again or download the update manually from https://www.mudlet.org/download/")); return; } - // Create a batch file that waits for Mudlet and crashpad_handler to exit before launching installer + // Create a batch file that waits for Mudlet to exit before launching installer // this avoids shell quoting issues that happen with QProcess::startDetached QString batchPath = qsl("%1/mudlet-update.bat").arg(QStandardPaths::writableLocation(QStandardPaths::TempLocation)); QFile batchFile(batchPath); @@ -497,44 +525,59 @@ void Updater::slot_installOrRestartClicked(QAbstractButton* button, const QStrin "\"%2\"\r\n" "echo Mudlet updater: installer finished with exit code %ERRORLEVEL%\r\n") .arg(exeName, QDir::toNativeSeparators(installerPath)); - batchFile.write(batchContent.toLocal8Bit()); + if (batchFile.write(batchContent.toLocal8Bit()) == -1) { + qWarning() << "Failed to write update batch file:" << batchFile.errorString(); + //: Error shown when the batch file for managing the update process cannot be written. %1 is the path to the installer. + QMessageBox::warning(nullptr, errorTitle, tr("Could not prepare the update. Please close Mudlet and run the installer manually:\n%1").arg(QDir::toNativeSeparators(installerPath))); + return; + } batchFile.close(); - QProcess::startDetached(batchPath, QStringList()); + if (!QProcess::startDetached(batchPath, QStringList())) { + qWarning() << "Failed to launch update batch file:" << batchPath; + //: Error shown when the update installer process fails to start + QMessageBox::warning(nullptr, errorTitle, tr("Could not launch the update installer. Please restart Mudlet and try again.")); + return; + } qWarning() << "Launching installer via batch file:" << installerPath; } else { - qWarning() << "Failed to create batch file, attempting direct launch"; - QProcess::startDetached(installerPath, QStringList()); + qWarning() << "Failed to create update batch file:" << batchFile.errorString(); + //: Error shown when the batch file for managing the update process cannot be created. %1 is the path to the installer. + QMessageBox::warning(nullptr, errorTitle, tr("Could not prepare the update. Please close Mudlet and run the installer manually:\n%1").arg(QDir::toNativeSeparators(installerPath))); + return; } if (mudlet::self()) { mudlet::self()->forceClose(); } - // Don't restart Mudlet - the installer will do it after the update + // Mudlet is not restarted here - the installer is expected to handle launching the updated version return; #else - // if the updater is launched manually instead of when Mudlet is quit, - // close Mudlet ourselves if (mudlet::self()) { mudlet::self()->forceClose(); } - QProcess::startDetached(qApp->arguments()[0], qApp->arguments()); + if (!QProcess::startDetached(qApp->arguments()[0], qApp->arguments())) { + qWarning() << "Failed to restart Mudlet after update"; + //: Error title for dialog shown when Mudlet fails to restart after updating + QMessageBox::critical(nullptr, + tr("Update Error"), + //: Error message shown when Mudlet fails to restart after updating on Linux + tr("Could not restart Mudlet after the update. Please start it manually.")); + } return; #endif } -// otherwise the button says 'Install', so install the update #if defined(Q_OS_LINUX) - QFuture future = QtConcurrent::run([&, filePath]() { + QFuture future = QtConcurrent::run([this, filePath]() { untarOnLinux(filePath); }); #elif defined(Q_OS_WINDOWS) - QFuture future = QtConcurrent::run([&, filePath]() { + QFuture future = QtConcurrent::run([this, filePath]() { prepareSetupOnWindows(filePath); }); #endif - // replace current binary with the unzipped one auto watcher = new QFutureWatcher; connect(watcher, &QFutureWatcher::finished, this, [=, this]() { #if defined(Q_OS_LINUX) @@ -542,7 +585,13 @@ void Updater::slot_installOrRestartClicked(QAbstractButton* button, const QStrin #elif defined(Q_OS_WINDOWS) finishSetup(); #endif - mpInstallOrRestart->setText(tr("Restart to apply update")); + if (mUpdateInstalled) { + //: Label for the button shown after the update has been downloaded and installed, prompting user to restart + mpInstallOrRestart->setText(tr("Restart to apply update")); + } else { + //: Label for the update button shown when the update installation failed + mpInstallOrRestart->setText(tr("Update failed")); + } mpInstallOrRestart->setEnabled(true); watcher->deleteLater(); }); @@ -550,11 +599,7 @@ void Updater::slot_installOrRestartClicked(QAbstractButton* button, const QStrin #endif // !Q_OS_MACOS } -// records a unix epoch on disk indicating that an update has happened. -// Mudlet will use that on the next launch to decide whenever it should show -// the window with the new features. The idea is that if you manually update (thus see the -// changelog already) and restart, you shouldn't see it again, and if you automatically -// updated, then you do want to see the changelog. +// Records a timestamp on disk so shouldShowChangelog() can detect automatic updates on next launch void Updater::recordUpdateTime() const { QSaveFile file(mudlet::getMudletPath(enums::mainDataItemPath, qsl("mudlet_updated_at"))); @@ -564,13 +609,13 @@ void Updater::recordUpdateTime() const return; } - QDataStream ifs(&file); + QDataStream ofs(&file); if (mudlet::scmRunTimeQtVersion >= QVersionNumber(5, 13, 0)) { - ifs.setVersion(mudlet::scmQDataStreamFormat_5_12); + ofs.setVersion(mudlet::scmQDataStreamFormat_5_12); } - ifs << QDateTime::currentDateTime().toMSecsSinceEpoch(); + ofs << QDateTime::currentDateTime().toMSecsSinceEpoch(); if (!file.commit()) { - qDebug() << "Updater::recordUpdateTime: error recording update time: " << file.errorString(); + qWarning() << "Updater::recordUpdateTime: error recording update time:" << file.errorString(); } } @@ -585,22 +630,21 @@ void Updater::recordUpdatedVersion() const return; } - QDataStream ifs(&file); + QDataStream ofs(&file); if (mudlet::scmRunTimeQtVersion >= QVersionNumber(5, 13, 0)) { - ifs.setVersion(mudlet::scmQDataStreamFormat_5_12); + ofs.setVersion(mudlet::scmQDataStreamFormat_5_12); } - ifs << APP_VERSION; + ofs << APP_VERSION; if (!file.commit()) { - qDebug() << "Updater::recordUpdatedVersion: error saving old mudlet version: " << file.errorString(); + qWarning() << "Updater::recordUpdatedVersion: error saving old mudlet version:" << file.errorString(); } } -// returns true if Mudlet was updated automatically and a changelog should be shown -// now that the user is on the new version. If the user updated manually, then there -// is no need as they would have seen the changelog while updating +// Returns true if the changelog should be shown on this launch. Only applies to +// non-development builds with auto-updates on non-macOS (Sparkle handles its own changelog). +// Requires at least 5 minutes since the update to avoid re-showing a just-seen changelog. bool Updater::shouldShowChangelog() { -// Don't show changelog for automatic updates on Sparkle - Sparkle doesn't support it #if defined(Q_OS_MACOS) return false; #endif @@ -623,18 +667,20 @@ bool Updater::shouldShowChangelog() ifs >> updateTimestamp; file.close(); + if (ifs.status() != QDataStream::Ok) { + qWarning() << "Failed to read update timestamp file, treating as missing"; + file.remove(); + return false; + } + auto currentDateTime = QDateTime::currentDateTime().toMSecsSinceEpoch(); auto minsSinceUpdate = (currentDateTime - updateTimestamp) / 1000 / 60; - // delete the file on check as well since if we updated and restarted right away - // we won't need to show the changelog - as well as on a launch 5mins after. file.remove(); return minsSinceUpdate >= 5; } -// return the previous version of Mudlet that we updated from -// return a null QString on failure QString Updater::getPreviousVersion() const { QFile file(mudlet::self()->getMudletPath(enums::mainDataItemPath, qsl("mudlet_updated_from"))); @@ -652,26 +698,10 @@ QString Updater::getPreviousVersion() const file.close(); file.remove(); + if (ifs.status() != QDataStream::Ok) { + qWarning() << "Failed to read previous version file, treating as missing"; + return QString(); + } + return previousVersion; } - -#if defined(Q_OS_WINDOWS) -// we are trying to detect machines running a 32-Bit build of Mudlet on a 64-Bit Intel/AMD processor -bool Updater::is64BitCompatible() const -{ -#if defined(Q_OS_WIN64) - return true; -#endif - - BOOL isWow64 = FALSE; - typedef BOOL(WINAPI * LPFN_ISWOW64PROCESS)(HANDLE, PBOOL); - LPFN_ISWOW64PROCESS fnIsWow64Process = (LPFN_ISWOW64PROCESS)GetProcAddress(GetModuleHandle(TEXT("kernel32")), "IsWow64Process"); - - if (fnIsWow64Process) { - if (fnIsWow64Process(GetCurrentProcess(), &isWow64)) { - return isWow64 ? true : false; - } - } - return false; -} -#endif diff --git a/src/updater.h b/src/updater.h index 0f13a3df3..6bd9bac0b 100644 --- a/src/updater.h +++ b/src/updater.h @@ -20,16 +20,25 @@ #ifndef UPDATER_H #define UPDATER_H -// FreeBSD does not support the updater and these missing files upset -// clang-tidy / Clazy when they are run in an environment without them: +// QObject must be included before Q_OS_MACOS checks below +#include + +// Guard for builds without the updater (INCLUDE_UPDATER is defined by CMake when USE_UPDATER is ON): #if defined(INCLUDE_UPDATER) -#include "dblsqd/feed.h" -#include "dblsqd/update_dialog.h" +namespace dblsqd { +class Feed; +class Release; +class UpdateDialog; +} +#if defined(Q_OS_MACOS) #include "sparkleupdater.h" #endif +#endif - -#include +class QAbstractButton; +class QPushButton; +class QSettings; +class QTimer; class Updater : public QObject { @@ -48,13 +57,15 @@ public: bool shouldShowChangelog(); private: - dblsqd::Feed* feed; - dblsqd::UpdateDialog* updateDialog{nullptr}; + dblsqd::Feed* mFeed; + dblsqd::UpdateDialog* mUpdateDialog{nullptr}; +#if !defined(Q_OS_MACOS) QPushButton* mpInstallOrRestart; +#endif bool mUpdateInstalled; bool mManualCheckInProgress{false}; - QSettings* settings; - std::unique_ptr mDailyCheck; + QSettings* mSettings; + std::unique_ptr mPeriodicCheck; #if defined(Q_OS_LINUX) void setupOnLinux(); @@ -62,11 +73,13 @@ private: #elif defined(Q_OS_WINDOWS) void setupOnWindows(); void prepareSetupOnWindows(const QString& fileName); - bool is64BitCompatible() const; #elif defined(Q_OS_MACOS) void setupOnMacOS(); #endif +#if !defined(Q_OS_MACOS) + void setupPlatformUpdater(); +#endif void recordUpdateTime() const; void recordUpdatedVersion() const; QString getPreviousVersion() const; @@ -75,7 +88,7 @@ private: void showDialogManually() const; #if defined(Q_OS_LINUX) - QString unzippedBinaryName; + QString mUnzippedBinaryName; #elif defined(Q_OS_WINDOWS) QString mDownloadedInstallerPath; #elif defined(Q_OS_MACOS) @@ -85,7 +98,6 @@ private: signals: void signal_updateInstalled(); - // Argument is a count of updates available void signal_updateAvailable(const int); void signal_automaticUpdatesChanged(const bool); void signal_updateCheckFailed(const QString& error); @@ -93,7 +105,6 @@ signals: public slots: void slot_installOrRestartClicked(QAbstractButton* button, const QString& filePath); #if defined(Q_OS_LINUX) - // might want to make these private void slot_updateLinuxBinary(); #endif }; diff --git a/src/updater/Feed.cpp b/src/updater/Feed.cpp new file mode 100644 index 000000000..85e71a954 --- /dev/null +++ b/src/updater/Feed.cpp @@ -0,0 +1,434 @@ +/*************************************************************************** + * Copyright (C) 2017 by Philipp Medien - hello@dblsqd.com * + * Copyright (C) 2026 by Vadim Peretokin - vperetokin@gmail.com * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +#include "Feed.h" + +#include "../utils.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dblsqd { + +Feed::Feed(QObject* parent) +: QObject(parent) +{ +} + +Feed::~Feed() +{ + cleanupDownloadFile(); +} + +void Feed::setRepo(const QString& owner, const QString& repo, bool prerelease, const QString& os, const QString& arch) +{ + mOwner = owner; + mRepo = repo; + mPrerelease = prerelease; + mOs = os.isEmpty() ? detectOs() : os; + mArch = arch.isEmpty() ? detectArch() : arch; + + if (prerelease) { + mUrl = QUrl(qsl("https://api.github.com/repos/%1/%2/releases?per_page=10").arg(owner, repo)); + } else { + mUrl = QUrl(qsl("https://api.github.com/repos/%1/%2/releases?per_page=100").arg(owner, repo)); + } +} + +QString Feed::detectOs() +{ + QString os = QSysInfo::productType().toLower(); + if (os == qsl("windows")) { + return qsl("win"); + } else if (os == qsl("osx") || os == qsl("macos")) { + return qsl("mac"); + } + return QSysInfo::kernelType(); +} + +QString Feed::detectArch() +{ + QString autoArch = QSysInfo::buildCpuArchitecture(); + if (autoArch == qsl("i386") || autoArch == qsl("i586")) { + return qsl("x86"); + } + return autoArch; +} + +QUrl Feed::getUrl() const +{ + return mUrl; +} + +QList Feed::getReleases() const +{ + return mReleases; +} + +QList Feed::getUpdates(const Release& currentRelease) const +{ + QList updates; + for (const auto& release : mReleases) { + if (currentRelease.getVersion().toLower() != release.getVersion().toLower() && currentRelease < release) { + updates << release; + } + } + return updates; +} + +QString Feed::getDownloadFilePath() const +{ + return mDownloadFilePath; +} + +bool Feed::isReady() const +{ + return mReady; +} + +void Feed::load() +{ + if (mFeedReply != nullptr) { + if (!mFeedReply->isFinished()) { + qWarning() << "Update check already in progress, ignoring duplicate request"; + //: Error shown when the user triggers an update check while one is already running + emit loadError(tr("Update check already in progress")); + return; + } + mFeedReply->deleteLater(); + mFeedReply = nullptr; + } + + mReady = false; + + QNetworkRequest request(getUrl()); + request.setRawHeader("Accept", "application/vnd.github+json"); + request.setRawHeader("User-Agent", "Mudlet-Updater"); + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); + request.setTransferTimeout(30000); + mFeedReply = mNam.get(request); + connect(mFeedReply, &QNetworkReply::finished, this, &Feed::handleFeedFinished); +} + +void Feed::downloadRelease(const Release& release, bool requireChecksums) +{ + const QUrl downloadUrl = release.getDownloadUrl(); + if (!downloadUrl.isValid() || downloadUrl.isEmpty()) { + //: Error shown when the GitHub release has no binary matching the user's operating system + emit downloadError(tr("No download available for your platform")); + return; + } + + mCurrentDownload = release; + mRequireChecksums = requireChecksums; + const QUrl checksumsUrl = release.getChecksumsUrl(); + if (checksumsUrl.isValid() && !checksumsUrl.isEmpty()) { + fetchChecksums(checksumsUrl); + } else if (requireChecksums) { + //: Error shown when a manual update cannot be verified as safe to install + emit downloadError(tr("Could not verify the integrity of the download. Please try again later.")); + } else { + makeDownloadRequest(downloadUrl); + } +} + +void Feed::fetchChecksums(const QUrl& checksumsUrl) +{ + QNetworkRequest request(checksumsUrl); + request.setRawHeader("Accept", "application/octet-stream"); + request.setRawHeader("User-Agent", "Mudlet-Updater"); + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); + request.setTransferTimeout(30000); + auto* reply = mNam.get(request); + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + if (reply->error() != QNetworkReply::NoError) { + if (mRequireChecksums) { + reply->deleteLater(); + //: Error shown when a manual update cannot be verified as safe to install + emit downloadError(tr("Could not verify the integrity of the download. Please try again later.")); + return; + } + qWarning() << "Failed to fetch checksums:" << reply->errorString() << "- download will proceed without integrity verification"; + } else { + const QString checksumData = QString::fromUtf8(reply->readAll()); + const QStringList lines = checksumData.split(QLatin1Char('\n'), Qt::SkipEmptyParts); + // SHA256 hex digest is 64 characters; search for separator after that + static const QRegularExpression separatorRx(qsl("[\\s*]+")); + static const QRegularExpression hexRx(qsl("^[0-9a-fA-F]{64}$")); + for (const auto& line : lines) { + // Format: "hash filename" or "hash *filename" + const int separatorPos = line.indexOf(separatorRx, 64); + if (separatorPos <= 0) { + continue; + } + const QString hash = line.left(separatorPos).trimmed(); + if (!hexRx.match(hash).hasMatch()) { + continue; + } + const QString filename = line.mid(separatorPos).trimmed().remove(QLatin1Char('*')); + + // Match against the download URL filename + const QString downloadFilename = mCurrentDownload.getDownloadUrl().fileName(); + if (!downloadFilename.isEmpty() && filename.contains(downloadFilename, Qt::CaseInsensitive)) { + mCurrentDownload.setDownloadSHA256(hash); + break; + } + } + if (mCurrentDownload.getDownloadSHA256().isEmpty()) { + if (mRequireChecksums) { + reply->deleteLater(); + //: Error shown when a manual update cannot be verified as safe to install + emit downloadError(tr("Could not verify the integrity of the download. Please try again later.")); + return; + } + qCritical() << "Checksum file downloaded but no matching hash found for" << mCurrentDownload.getDownloadUrl().fileName() << "- download will proceed without integrity verification"; + } + } + reply->deleteLater(); + makeDownloadRequest(mCurrentDownload.getDownloadUrl()); + }); +} + +void Feed::makeDownloadRequest(const QUrl& url) +{ + mDownloadFilePath.clear(); + + if (mDownloadReply != nullptr) { + if (!mDownloadReply->isFinished()) { + disconnect(mDownloadReply); + mDownloadReply->abort(); + } + mDownloadReply->deleteLater(); + mDownloadReply = nullptr; + } + if (mDownloadFile != nullptr) { + mDownloadFile->close(); + mDownloadFile->deleteLater(); + mDownloadFile = nullptr; + } + + QNetworkRequest request(url); + request.setRawHeader("User-Agent", "Mudlet-Updater"); + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); + request.setTransferTimeout(60000); + mDownloadReply = mNam.get(request); + connect(mDownloadReply, &QNetworkReply::downloadProgress, this, &Feed::downloadProgress); + connect(mDownloadReply, &QNetworkReply::readyRead, this, &Feed::handleDownloadReadyRead); + connect(mDownloadReply, &QNetworkReply::finished, this, &Feed::handleDownloadFinished); +} + +void Feed::handleFeedFinished() +{ + if (mFeedReply->error() != QNetworkReply::NoError) { + //: Error shown when the network request to the update server fails. %1 is the technical error description. + emit loadError(tr("Could not connect to the update server: %1").arg(mFeedReply->errorString())); + mFeedReply->deleteLater(); + mFeedReply = nullptr; + return; + } + + mReleases.clear(); + const QByteArray json = mFeedReply->readAll(); + mFeedReply->deleteLater(); + mFeedReply = nullptr; + + const QJsonDocument doc = QJsonDocument::fromJson(json); + if (doc.isNull()) { + //: Error shown when the server response cannot be understood + emit loadError(tr("Could not read update information from the server")); + return; + } + + // Check for GitHub API error responses (e.g. rate limiting, not found) + // Both prerelease and release endpoints return an object with "message" on error + if (doc.isObject() && doc.object().contains(qsl("message"))) { + const QString apiMessage = doc.object().value(qsl("message")).toString(); + qWarning() << "GitHub API error:" << apiMessage; + if (apiMessage.contains(qsl("rate limit"), Qt::CaseInsensitive)) { + //: Error shown when the GitHub API rate limit has been exceeded + emit loadError(tr("Update check temporarily unavailable. Please try again in a few minutes.")); + } else { + //: Error shown when the GitHub API returns an error. %1 is the error message from the server. + emit loadError(tr("Could not check for updates: %1").arg(apiMessage)); + } + return; + } + + // Both channels use the releases list endpoint and return an array + if (!doc.isArray()) { + //: Error shown when the update server response cannot be understood + emit loadError(tr("Could not read update information from the server")); + return; + } + + const QJsonArray releasesArray = doc.array(); + for (const auto& val : releasesArray) { + const QJsonObject releaseObj = val.toObject(); + + // PTB channel: include only prereleases; stable channel: exclude prereleases + if (mPrerelease != releaseObj.value(qsl("prerelease")).toBool()) { + continue; + } + if (releaseObj.value(qsl("draft")).toBool()) { + continue; + } + + Release rel(releaseObj, mOs, mArch); + if (!rel.getVersion().isEmpty()) { + mReleases << rel; + } else { + qWarning() << "Skipping release with empty version, tag_name:" << releaseObj.value(qsl("tag_name")).toString(); + } + } + + std::sort(mReleases.begin(), mReleases.end(), [](const Release& a, const Release& b) { + return b < a; + }); + + mReady = true; + emit ready(); +} + +void Feed::handleDownloadReadyRead() +{ + if (mDownloadFile == nullptr) { + QString fileName = mDownloadReply->url().fileName(); + // handle compound extensions like .AppImage.tar when generating unique temp filenames + static const QRegularExpression extensionRx(qsl("(?:\\.tar)?\\.[a-zA-Z0-9]+$")); + int extensionPos = fileName.indexOf(extensionRx); + if (extensionPos > -1) { + fileName.insert(extensionPos, qsl("-XXXXXX")); + } + mDownloadFile = new QTemporaryFile(QDir::tempPath() + qsl("/") + fileName); + if (!mDownloadFile->open()) { + qWarning() << "Failed to create temporary file for download:" << mDownloadFile->errorString(); + //: Error shown when a temporary file cannot be created for the update download. %1 is the system error message. + emit downloadError(tr("Could not create temporary file for download: %1").arg(mDownloadFile->errorString())); + abortDownload(); + return; + } + } + const QByteArray data = mDownloadReply->readAll(); + const qint64 bytesWritten = mDownloadFile->write(data); + if (bytesWritten != data.size()) { + qWarning() << "Failed to write download data to temporary file:" << mDownloadFile->errorString(); + //: Error shown when writing download data to disk fails. %1 is the system error message. + emit downloadError(tr("Failed to save download data: %1").arg(mDownloadFile->errorString())); + abortDownload(); + return; + } +} + +void Feed::handleDownloadFinished() +{ + if (mDownloadReply->error() != QNetworkReply::NoError) { + qWarning() << "Download failed:" << mDownloadReply->errorString() << "URL:" << mDownloadReply->url(); + //: Error shown when the update file download fails. %1 is the network error message. + emit downloadError(tr("Download failed: %1").arg(mDownloadReply->errorString())); + cleanupDownloadFile(); + cleanupDownloadReply(); + return; + } + + if (mDownloadFile == nullptr) { + //: Error shown when the update download completed but nothing was received + emit downloadError(tr("Download failed. Please try again.")); + cleanupDownloadReply(); + return; + } + + if (!mDownloadFile->flush()) { + qWarning() << "Failed to flush download file:" << mDownloadFile->errorString(); + //: Error shown when flushing the downloaded file to disk fails. %1 is the system error message. + emit downloadError(tr("Failed to save download: %1").arg(mDownloadFile->errorString())); + cleanupDownloadFile(); + cleanupDownloadReply(); + return; + } + if (!mDownloadFile->seek(0)) { + qWarning() << "Failed to seek in download file:" << mDownloadFile->errorString(); + //: Error shown when the downloaded file cannot be read back for checksum verification + emit downloadError(tr("Failed to verify download integrity")); + cleanupDownloadFile(); + cleanupDownloadReply(); + return; + } + QCryptographicHash fileHash(QCryptographicHash::Sha256); + if (!fileHash.addData(mDownloadFile)) { + qWarning() << "Failed to read download file for checksum verification:" << mDownloadFile->errorString(); + //: Error shown when the downloaded file cannot be read back for checksum verification + emit downloadError(tr("Failed to verify download integrity")); + cleanupDownloadFile(); + cleanupDownloadReply(); + return; + } + const QString hashResult = fileHash.result().toHex(); + if (!mCurrentDownload.getDownloadSHA256().isEmpty() && hashResult.toLower() != mCurrentDownload.getDownloadSHA256().toLower()) { + qWarning() << "SHA256 mismatch - expected:" << mCurrentDownload.getDownloadSHA256() << "got:" << hashResult; + //: Error shown when the downloaded file's SHA256 checksum does not match the expected value + emit downloadError(tr("Could not verify download integrity.")); + cleanupDownloadFile(); + cleanupDownloadReply(); + return; + } + + mDownloadFile->setAutoRemove(false); + mDownloadFilePath = mDownloadFile->fileName(); + cleanupDownloadFile(); + cleanupDownloadReply(); + emit downloadFinished(); +} + +void Feed::abortDownload() +{ + if (mDownloadReply) { + disconnect(mDownloadReply, &QNetworkReply::finished, this, &Feed::handleDownloadFinished); + mDownloadReply->abort(); + mDownloadReply->deleteLater(); + mDownloadReply = nullptr; + } + cleanupDownloadFile(); +} + +void Feed::cleanupDownloadReply() +{ + if (mDownloadReply) { + mDownloadReply->deleteLater(); + mDownloadReply = nullptr; + } +} + +void Feed::cleanupDownloadFile() +{ + if (mDownloadFile) { + mDownloadFile->close(); + delete mDownloadFile; + mDownloadFile = nullptr; + } +} + +} // namespace dblsqd diff --git a/src/updater/Feed.h b/src/updater/Feed.h new file mode 100644 index 000000000..59247559d --- /dev/null +++ b/src/updater/Feed.h @@ -0,0 +1,100 @@ +/*************************************************************************** + * Copyright (C) 2017 by Philipp Medien - hello@dblsqd.com * + * Copyright (C) 2026 by Vadim Peretokin - vperetokin@gmail.com * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +#ifndef DBLSQD_FEED_H +#define DBLSQD_FEED_H + +#include "Release.h" + +#include +#include +#include +#include + +class QNetworkReply; +class QTemporaryFile; + +// Namespace retained from the previous dblsqd update system to minimize changes in dependent code +namespace dblsqd { + +class Feed : public QObject +{ + Q_OBJECT + +public: + explicit Feed(QObject* parent = nullptr); + ~Feed(); + + void setRepo(const QString& owner, const QString& repo, bool prerelease = false, const QString& os = QString(), const QString& arch = QString()); + QUrl getUrl() const; + + void load(); + void downloadRelease(const Release& release, bool requireChecksums = false); + + QList getUpdates(const Release& currentRelease) const; + QList getReleases() const; + QString getDownloadFilePath() const; + bool isReady() const; + +signals: + void ready(); + void loadError(const QString& message); + void downloadProgress(qint64 bytesReceived, qint64 bytesTotal); + void downloadFinished(); + void downloadError(const QString& message); + +private: + QUrl mUrl; + + QList mReleases; + + void makeDownloadRequest(const QUrl& url); + void fetchChecksums(const QUrl& checksumsUrl); + void abortDownload(); + void cleanupDownloadReply(); + void cleanupDownloadFile(); + bool mRequireChecksums{false}; + + QNetworkAccessManager mNam; + QNetworkReply* mFeedReply{nullptr}; + Release mCurrentDownload; + QNetworkReply* mDownloadReply{nullptr}; + QTemporaryFile* mDownloadFile{nullptr}; + QString mDownloadFilePath; + bool mReady{false}; + + QString mOwner; + QString mRepo; + bool mPrerelease{false}; + QString mOs; + QString mArch; + + static QString detectOs(); + static QString detectArch(); + +private slots: + void handleFeedFinished(); + void handleDownloadReadyRead(); + void handleDownloadFinished(); +}; + +} // namespace dblsqd + +#endif // DBLSQD_FEED_H diff --git a/src/updater/Release.cpp b/src/updater/Release.cpp new file mode 100644 index 000000000..6fedd43cc --- /dev/null +++ b/src/updater/Release.cpp @@ -0,0 +1,201 @@ +/*************************************************************************** + * Copyright (C) 2017 by Philipp Medien - hello@dblsqd.com * + * Copyright (C) 2026 by Vadim Peretokin - vperetokin@gmail.com * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +#include "Release.h" + +#include "SemVer.h" + +#include "../utils.h" + +#include +#include +#include +#include + +namespace dblsqd { + +/*! + * \class Release + * \brief This class is used to represent information about a single Release + * from a Feed. + */ + +/*! + * \brief Constructs a new Release from a GitHub Releases API JSON object. + */ +Release::Release(const QJsonObject& releaseInfo, const QString& os, const QString& arch) +{ + const QString tagName = releaseInfo.value(qsl("tag_name")).toString(); + if (tagName.isEmpty()) { + qWarning() << "Release missing tag_name field"; + } + mVersion = tagName.startsWith(qsl("Mudlet-")) ? tagName.mid(7) : tagName; + + mDate = QDateTime::fromString(releaseInfo.value(qsl("published_at")).toString(), Qt::ISODate); + if (!mDate.isValid()) { + qWarning() << "Release" << mVersion << "has invalid or missing published_at date"; + } + + const QString body = releaseInfo.value(qsl("body")).toString(); + if (!body.isEmpty()) { + mChangelog = body; + } + + const QJsonArray assets = releaseInfo.value(qsl("assets")).toArray(); + const QString assetPattern = buildAssetPattern(os, arch); + + for (const auto& assetVal : assets) { + const QJsonObject asset = assetVal.toObject(); + const QString name = asset.value(qsl("name")).toString(); + + if (name == qsl("SHA256SUMS.txt")) { + mChecksumsUrl = QUrl(asset.value(qsl("browser_download_url")).toString()); + continue; + } + + if (mDownloadUrl.isEmpty() && !assetPattern.isEmpty() && name.contains(assetPattern, Qt::CaseInsensitive)) { + mDownloadUrl = QUrl(asset.value(qsl("browser_download_url")).toString()); + mDownloadSize = static_cast(asset.value(qsl("size")).toDouble()); + } + } + + if (mDownloadUrl.isEmpty() && !mVersion.isEmpty() && !os.isEmpty()) { + qWarning() << "No matching asset found for" << os << arch << "in release" << mVersion; + } +} + +/*! + * \brief Constructs a new Release from a version string and a date. + * + * This method is useful when constructing a "virtual" Release for comparing + * it with Releases retrieved from a Feed. + */ +Release::Release(const QString& version, const QDateTime& date) +: mVersion(version) +, mDate(date) +{ +} + +bool operator<(const Release& one, const Release& other) +{ + SemVer v1(one.mVersion); + SemVer v2(other.mVersion); + + // Both valid SemVer: use semantic version comparison + if (v1.isValid() && v2.isValid()) { + return (v1 < v2); + } + + // Mixed validity: valid SemVer sorts after invalid to ensure total ordering + // (required by std::sort's strict weak ordering contract) + if (v1.isValid() != v2.isValid()) { + return v2.isValid(); + } + + // Both invalid SemVer (e.g. PTB releases): fall back to date comparison + return (one.mDate < other.mDate); +} + +bool operator==(const Release& one, const Release& other) +{ + SemVer v1(one.mVersion); + SemVer v2(other.mVersion); + + if (v1.isValid() && v2.isValid()) { + return v1 == v2; + } + + return one.mVersion.compare(other.mVersion, Qt::CaseInsensitive) == 0; +} + +bool operator<=(const Release& one, const Release& other) +{ + return one == other || one < other; +} + +QString Release::getVersion() const +{ + return mVersion; +} + +QString Release::getChangelog() const +{ + return mChangelog; +} + +QDateTime Release::getDate() const +{ + return mDate; +} + +QUrl Release::getDownloadUrl() const +{ + return mDownloadUrl; +} + +QString Release::getDownloadSHA256() const +{ + return mDownloadSHA256; +} + +void Release::setDownloadSHA256(const QString& sha256) +{ + mDownloadSHA256 = sha256; +} + +qint64 Release::getDownloadSize() const +{ + return mDownloadSize; +} + +QUrl Release::getChecksumsUrl() const +{ + return mChecksumsUrl; +} + +dblsqd::Release Release::getCurrentRelease() +{ + // embed build time so public test releases, which cannot be compared via semver, can be compared via datetime + QString buildDateTime = QString(__DATE__) + " " + QString(__TIME__); + // locale-independent datetime parsing (C locale matches __DATE__'s English format) + QDateTime date = QLocale::c().toDateTime(buildDateTime.simplified(), qsl("MMM d yyyy hh:mm:ss")); + + return dblsqd::Release(QCoreApplication::applicationVersion(), date); +} + +QString Release::buildAssetPattern(const QString& os, const QString& arch) +{ + if (os == qsl("linux")) { + return qsl("-linux-x64.AppImage.tar"); + } else if (os == qsl("win")) { + return qsl("-windows-64"); + } else if (os == qsl("mac")) { + if (arch == qsl("arm64") || arch == qsl("aarch64")) { + return qsl("-arm64.dmg"); + } + return qsl("-x86_64.dmg"); + } + if (!os.isEmpty()) { + qWarning() << "No asset pattern defined for OS:" << os << "arch:" << arch; + } + return QString(); +} + +} // namespace dblsqd diff --git a/src/updater/Release.h b/src/updater/Release.h new file mode 100644 index 000000000..a68ed82f5 --- /dev/null +++ b/src/updater/Release.h @@ -0,0 +1,66 @@ +/*************************************************************************** + * Copyright (C) 2017 by Philipp Medien - hello@dblsqd.com * + * Copyright (C) 2026 by Vadim Peretokin - vperetokin@gmail.com * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +#ifndef DBLSQD_RELEASE_H +#define DBLSQD_RELEASE_H + +#include +#include +#include + +class QJsonObject; + +namespace dblsqd { + +class Release +{ +public: + explicit Release(const QJsonObject& releaseInfo, const QString& os = QString(), const QString& arch = QString()); + explicit Release(const QString& version = QString(), const QDateTime& date = QDateTime()); + + friend bool operator<(const Release& one, const Release& other); + friend bool operator==(const Release& one, const Release& other); + friend bool operator<=(const Release& one, const Release& other); + + QString getVersion() const; + QString getChangelog() const; + QDateTime getDate() const; + QUrl getDownloadUrl() const; + QString getDownloadSHA256() const; + qint64 getDownloadSize() const; + QUrl getChecksumsUrl() const; + void setDownloadSHA256(const QString& sha256); + static dblsqd::Release getCurrentRelease(); + +private: + QString mVersion; + QDateTime mDate; + QString mChangelog; + QUrl mDownloadUrl; + qint64 mDownloadSize{0}; + QString mDownloadSHA256; + QUrl mChecksumsUrl; + + static QString buildAssetPattern(const QString& os, const QString& arch); +}; + +} // namespace dblsqd + +#endif // DBLSQD_RELEASE_H diff --git a/src/updater/SemVer.cpp b/src/updater/SemVer.cpp new file mode 100644 index 000000000..a92b76b20 --- /dev/null +++ b/src/updater/SemVer.cpp @@ -0,0 +1,119 @@ +/*************************************************************************** + * Copyright (C) 2017 by Philipp Medien - hello@dblsqd.com * + * Copyright (C) 2026 by Vadim Peretokin - vperetokin@gmail.com * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +#include "SemVer.h" + +#include "../utils.h" + +#include + +namespace dblsqd { + +/*! + * \class SemVer + * \brief SemVer encapsulates a version according to + * Semantic Versioning 2.0. + */ + +/*! + * \brief Constructs a new SemVer object from a string. + */ +SemVer::SemVer(const QString& version) +{ + static const QRegularExpression rx(getRegExp()); + QRegularExpressionMatch match = rx.match(version); + if (match.hasMatch()) { + mMajor = match.captured(1).toInt(); + mMinor = match.captured(2).toInt(); + mPatch = match.captured(3).toInt(); + mPrerelease = match.captured(4); + mValid = true; + } +} + +/*! + * \brief Returns true if this version is valid according to the SemVer + * specification. Otherwise returns false. + */ +bool SemVer::isValid() const +{ + return mValid; +} + +/*! + * \brief Compares two SemVer objects. + * + * Returns true if the left-hand SemVer object represents a lower version + * according to the SemVer 2.0 specification. + * Otherwise returns false. + * Returns false if one of the SemVer objects does not represent a valid + * SemVer. + * \sa isValid() + */ +bool SemVer::operator<(const SemVer& other) const +{ + if (!isValid() || !other.isValid()) { + return false; + } + + if (mMajor != other.mMajor) { + return mMajor < other.mMajor; + } else if (mMinor != other.mMinor) { + return mMinor < other.mMinor; + } else if (mPatch != other.mPatch) { + return mPatch < other.mPatch; + } else if (mPrerelease != other.mPrerelease) { + if (mPrerelease.isEmpty()) { + return false; + } else if (other.mPrerelease.isEmpty()) { + return true; + } + // Simplified: compares the whole prerelease string lexicographically. + // SemVer 2.0 §11.4 specifies splitting on '.' and comparing numeric + // segments as integers, but Mudlet only uses simple prerelease tags. + return (QString::compare(mPrerelease, other.mPrerelease) < 0); + } + return false; +} + +/*! + * \brief Returns true if both SemVer objects represent the same version. + * + * Build metadata is ignored per SemVer 2.0 spec. + * Returns false if either object is invalid. + */ +bool SemVer::operator==(const SemVer& other) const +{ + if (!isValid() || !other.isValid()) { + return false; + } + + return mMajor == other.mMajor && mMinor == other.mMinor && mPatch == other.mPatch && mPrerelease == other.mPrerelease; +} + +QString SemVer::getRegExp() +{ + QString v = qsl("(0|[1-9]\\d*)"); + QString p = qsl("(?:-((?:0|[1-9A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9A-Za-z-][0-9A-Za-z-]*))*))?"); + QString b = qsl("(?:\\+((?:[0-9A-Za-z-]*)(?:\\.(?:[0-9A-Za-z-][0-9A-Za-z-]*))*))?"); + return qsl("^") + v + qsl("\\.") + v + qsl("\\.") + v + p + b + qsl("$"); +} + +} // namespace dblsqd diff --git a/src/updater/SemVer.h b/src/updater/SemVer.h new file mode 100644 index 000000000..07f442f73 --- /dev/null +++ b/src/updater/SemVer.h @@ -0,0 +1,50 @@ +/*************************************************************************** + * Copyright (C) 2017 by Philipp Medien - hello@dblsqd.com * + * Copyright (C) 2026 by Vadim Peretokin - vperetokin@gmail.com * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +#ifndef DBLSQD_SEMVER_H +#define DBLSQD_SEMVER_H + +#include + +namespace dblsqd { + +class SemVer +{ +public: + explicit SemVer(const QString& version); + + bool operator<(const SemVer& other) const; + bool operator==(const SemVer& other) const; + + bool isValid() const; + +private: + int mMajor{0}; + int mMinor{0}; + int mPatch{0}; + QString mPrerelease; + bool mValid{false}; + + static QString getRegExp(); +}; + +} // namespace dblsqd + +#endif // DBLSQD_SEMVER_H diff --git a/src/updater/UpdateDialog.cpp b/src/updater/UpdateDialog.cpp new file mode 100644 index 000000000..065152de9 --- /dev/null +++ b/src/updater/UpdateDialog.cpp @@ -0,0 +1,704 @@ +/*************************************************************************** + * Copyright (C) 2017 by Philipp Medien - hello@dblsqd.com * + * Copyright (C) 2026 by Vadim Peretokin - vperetokin@gmail.com * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +#include "UpdateDialog.h" +#include "Feed.h" +#include "ui_update_dialog.h" + +#include "../utils.h" +#include "../../3rdparty/kdtoolbox/singleshot_connect/singleshot_connect.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dblsqd { + +/*! + * \class UpdateDialog + * \brief A dialog class for displaying and downloading update information. + * + * UpdateDialog displays available updates from the GitHub Releases feed + * and provides download/install functionality. + * + * The update dialog can also display an application icon which can be set with + * setIcon(). + */ + +/*! + * \enum UpdateDialog::Type + * \brief This flag determines the if and when the UpdateDialog is displayed + * automatically. + * + * *OnUpdateAvailable*: Automatically display the dialog as soon as the Feed + * has been downloaded and parsed and if there is a newer version than the + * current version returned by QCoreApplication::applicationVersion(). + * + * *OnLastWindowClosed*: If there is a newer version available than the current + * version returned by QCoreApplication::applicationVersion(), the update + * dialog is displayed when QGuiApplication emits the lastWindowClosed() event. + * Note that when this flag is used, + * QGuiApplication::setQuitOnLastWindowClosed(false) will be called. + * + * *Manual*: The dialog is only displayed when explicitly requested via show() + * or exec(). + * Note that update information might not be available instantly after + * constructing an UpdateDialog. + * + * *ManualChangelog*: The dialog is only displayed when explicitly requested via + * show() or exec(). + * Instead of the full update interface, only the changelog will be shown. + */ + +/*! + * \brief Constructs a new UpdateDialog. + * + * A Feed object needs to be constructed first and passed to this constructor. + * Feed::load() does not need to be called before constructing this dialog -- + * the constructor calls it automatically if needed. + * + * The given UpdateDialog::Type flag determines when/if the dialog is shown + * automatically. + * + * A QSettings object must be provided for persisting user preferences such as + * skipped releases and auto-download settings. + * + */ +UpdateDialog::UpdateDialog(Feed* feed, Type type, QSettings* settings, QWidget* parent) +: QDialog(parent) +, mUi(new Ui::UpdateDialog) +, mFeed(feed) +, mType(type) +, mSettings(settings) +, mAcceptedInstallButton(nullptr) +{ + Q_ASSERT_X(feed, "UpdateDialog", "Feed object is required"); + Q_ASSERT_X(settings, "UpdateDialog", "QSettings object is required"); + mUi->setupUi(this); + + mUi->buttonCancel->addAction(mUi->actionCancel); + mUi->buttonCancel->addAction(mUi->actionSkip); + mUi->buttonCancel->setDefaultAction(mUi->actionCancel); + + connect(mUi->labelChangelog, &QTextBrowser::anchorClicked, this, &UpdateDialog::onLinkActivated); + + switch (mType) { + case OnUpdateAvailable: { + connect(this, &UpdateDialog::ready, this, &UpdateDialog::showIfUpdatesAvailable); + break; + } + case OnLastWindowClosed: { + auto* app = qobject_cast(QApplication::instance()); + app->setQuitOnLastWindowClosed(false); + connect(app, &QGuiApplication::lastWindowClosed, this, &UpdateDialog::showIfUpdatesAvailableOrQuit); + break; + } + case Manual: + case ManualChangelog: + break; + } + + if (mFeed->isReady()) { + handleFeedReady(); + } else { + setupLoadingUi(); + mFeed->load(); + KDToolBox::connectSingleShot(mFeed, &Feed::ready, this, &UpdateDialog::handleFeedReady); + KDToolBox::connectSingleShot(mFeed, &Feed::loadError, this, &UpdateDialog::handleLoadError); + } +} + +UpdateDialog::~UpdateDialog() +{ + delete mUi; +} + +/*! + * \brief Sets the icon displayed in the update window. + */ +void UpdateDialog::setIcon(const QPixmap& pixmap) +{ + mUi->labelIcon->setPixmap(pixmap); + mUi->labelIcon->setHidden(false); +} + +void UpdateDialog::setIcon(const QString& fileName) +{ + mUi->labelIcon->setPixmap(QPixmap(fileName)); + mUi->labelIcon->setHidden(false); +} + +/*! + * \brief Sets the minimum version to be displayed in the changelog. + * Defaults to QApplication::applicationVersion() if not set. + * \param version + */ +void UpdateDialog::setMinVersion(const QString& version) +{ + mMinVersion = version; + setupChangelogUi(); +} + +/*! + * \brief Sets the maximum version to be displayed in the changelog. + */ +void UpdateDialog::setMaxVersion(const QString& version) +{ + mMaxVersion = version; + setupChangelogUi(); +} + +/*! + * \brief Convenience method for setting minimum and maximum version to be + * displayed in the changelog. maximumVersion is set to + * QApplication::applicationVersion(). + */ +void UpdateDialog::setPreviousVersion(const QString& previousVersion) +{ + mPreviousVersion = previousVersion; + mMinVersion = previousVersion; + mMaxVersion = QApplication::applicationVersion(); + setupChangelogUi(); +} + +/*! + * \brief Adds a custom button for handling update installation. + * \param button + * + * When the custom button is clicked after an update has been downloaded or when + * downloading an update that was started by clicking the button has finished, + * installButtonClicked(QAbstractButton* button, QString filePath) is emitted. + */ +void UpdateDialog::addInstallButton(QAbstractButton* button) +{ + mInstallButtons.append(button); + mUi->buttonContainer->layout()->addWidget(button); + if (isVisible() && mUi->buttonCancel->isVisible()) { + setupUpdateUi(); + } +} + +/*! + * \brief Returns whether links in the changelog are opened externally. + * + * Determines if links in the changelog should be opened automatically by + * QDesktopServices::openUrl() when a user clicks on them. + * If set to false, the linkActivated() signal is emitted instead. + * + * The default value is true. + */ +bool UpdateDialog::openExternalLinks() const +{ + return mOpenExternalLinks; +} + +/*! + * \brief Sets whether links in the changelog are opened externally. + */ +void UpdateDialog::setOpenExternalLinks(bool open) +{ + mOpenExternalLinks = open; +} + +/*! + * \brief Default handler for the install button. + * + * Closes the dialog if no other action (such as + * downloading or installing a Release) is required first. + */ +void UpdateDialog::onButtonInstall() +{ + mAccepted = true; + mAcceptedInstallButton = nullptr; + if (mIsDownloadFinished) { + startUpdate(); + } else if (!mLatestRelease.getVersion().isEmpty()) { + startDownload(); + } else { + done(QDialog::Accepted); + } +} + +void UpdateDialog::onButtonCustomInstall() +{ + mAccepted = true; + if (mIsDownloadFinished) { + emit installButtonClicked(qobject_cast(sender()), mUpdateFilePath); + } else if (!mLatestRelease.getVersion().isEmpty()) { + mAcceptedInstallButton = qobject_cast(sender()); + startDownload(); + } else { + done(QDialog::Accepted); + } +} + +/*! + * \brief Skips the latest retrieved Release. + * + * If a release has been skipped, UpdateDialog will not be displayed + * automatically when using Type::OnUpdateAvailable or + * Type::OnLastWindowClosed. + */ +void UpdateDialog::skip() +{ + if (!mUpdateFilePath.isEmpty()) { + if (!QFile::remove(mUpdateFilePath)) { + qWarning() << "Failed to remove update file:" << mUpdateFilePath; + } + } + setSettingsValue(qsl("skipRelease"), mLatestRelease.getVersion(), mSettings); + done(QDialog::Rejected); +} + +/*! + * \brief Shows the dialog if there are available updates. + */ +void UpdateDialog::showIfUpdatesAvailable() +{ + QString latestVersion = mLatestRelease.getVersion(); + bool skipRelease = (settingsValue(qsl("skipRelease"), "", mSettings).toString() == latestVersion); + if (!latestVersion.isEmpty() && !skipRelease) { + show(); + } +} + +/*! + * \brief Shows the dialog if there are updates available or quits the + * application. + */ +void UpdateDialog::showIfUpdatesAvailableOrQuit() +{ + if (mType == OnLastWindowClosed) { + auto* app = qobject_cast(QApplication::instance()); + app->setQuitOnLastWindowClosed(true); + disconnect(app, &QGuiApplication::lastWindowClosed, this, &UpdateDialog::showIfUpdatesAvailableOrQuit); + } + QString latestVersion = mLatestRelease.getVersion(); + bool skipRelease = (settingsValue(qsl("skipRelease"), "", mSettings).toString() == latestVersion); + if (!latestVersion.isEmpty() && !skipRelease) { + show(); + } else { + QCoreApplication::quit(); + } +} + +// "DBLSQD/" prefix retained for backward compatibility with user settings from the previous update system +QVariant UpdateDialog::settingsValue(const QString& key, const QVariant& defaultValue, QSettings* settings) +{ + return settings->value(qsl("DBLSQD/") + key, defaultValue); +} + +void UpdateDialog::setSettingsValue(const QString& key, const QVariant& value, QSettings* settings) +{ + settings->setValue(qsl("DBLSQD/") + key, value); +} + +void UpdateDialog::removeSetting(const QString& key, QSettings* settings) +{ + settings->remove(qsl("DBLSQD/") + key); +} + +void UpdateDialog::setDefaultSettingsValue(const QString& key, const QVariant& value, QSettings* settings) +{ + if (settings->contains(qsl("DBLSQD/") + key)) + return; + setSettingsValue(key, value, settings); +} + +/*! + * \brief Enables or disables automatic downloads. + */ +void UpdateDialog::enableAutoDownload(bool enabled, QSettings* settings) +{ + setSettingsValue(qsl("autoDownload"), enabled, settings); +} + +/*! + * \brief Returns true if automatic downloads are enabled. + * + * If defaultValue is provided, it is stored if no other value has previously + * been set. + */ +bool UpdateDialog::autoDownloadEnabled(QVariant defaultValue, QSettings* settings) +{ + if (defaultValue.isValid()) { + setDefaultSettingsValue(qsl("autoDownload"), defaultValue, settings); + } else { + defaultValue = false; + } + return settingsValue(qsl("autoDownload"), defaultValue, settings).toBool(); +} + +/*! + * \overload + */ +bool UpdateDialog::autoDownloadEnabled(QSettings* settings) +{ + return settingsValue(qsl("autoDownload"), false, settings).toBool(); +} + +void UpdateDialog::adjustDialogSize() +{ + adjustSize(); + +/*HACK: Qt seems to incorrectly calculate window geometry on Windows. + This code avoids warning messages logged by the application + in that case.*/ +#if defined(Q_OS_WINDOWS) + QSize dialogSize = size(); + resize(dialogSize.width(), dialogSize.height() + 3); +#endif +} + +void UpdateDialog::updateWindowTitle() +{ + QString title = windowTitle(); + replaceAppVars(title); + setWindowTitle(title); +} + +void UpdateDialog::resetUi() +{ + QList hiddenWidgets; + for (auto* button : mInstallButtons) { + hiddenWidgets << button; + } + hiddenWidgets << mUi->headerContainer << mUi->labelIcon << mUi->headerContainerLoading << mUi->headerContainerNoUpdates << mUi->headerContainerChangelog << mUi->labelChangelog << mUi->progressBar + << mUi->checkAutoDownload << mUi->buttonCancel << mUi->buttonCancelLoading << mUi->buttonConfirm << mUi->buttonInstall; + for (auto* widget : hiddenWidgets) { + widget->hide(); + widget->disconnect(); + } + // Re-establish the changelog link handler broken by disconnect() above + connect(mUi->labelChangelog, &QTextBrowser::anchorClicked, this, &UpdateDialog::onLinkActivated); + mUi->progressBar->reset(); + adjustDialogSize(); +} + +void UpdateDialog::setupLoadingUi() +{ + resetUi(); + mUi->headerContainerLoading->show(); + mUi->progressBar->show(); + mUi->progressBar->setMaximum(0); + mUi->progressBar->setMinimum(0); + mUi->buttonCancelLoading->show(); + mUi->buttonCancelLoading->setFocus(); + connect(mUi->buttonCancelLoading, &QPushButton::clicked, this, &UpdateDialog::reject); + adjustDialogSize(); +} + +void UpdateDialog::setupUpdateUi() +{ + resetUi(); + + QList showWidgets; + showWidgets << mUi->headerContainer << mUi->labelChangelog << mUi->checkAutoDownload << mUi->buttonCancel << mUi->buttonInstall; + for (auto* widget : showWidgets) { + widget->show(); + } + + for (auto* label : {mUi->labelHeadline, mUi->labelInfo}) { + QString text = label->text(); + replaceAppVars(text); + label->setText(text); + } + mUi->labelChangelog->setMarkdown(generateChangelogDocument()); + + mUi->checkAutoDownload->setChecked(autoDownloadEnabled(mSettings)); + + updateWindowTitle(); + + // Show completed progress bar if release has been downloaded already + if (mIsDownloadFinished) { + mUi->progressBar->show(); + mUi->progressBar->setMaximum(1); + mUi->progressBar->setValue(1); + } + + connect(mFeed, &Feed::downloadFinished, this, &UpdateDialog::handleDownloadFinished, Qt::UniqueConnection); + connect(mFeed, &Feed::downloadError, this, &UpdateDialog::handleDownloadError, Qt::UniqueConnection); + connect(mFeed, &Feed::downloadProgress, this, &UpdateDialog::updateProgressBar, Qt::UniqueConnection); + + connect(mUi->buttonConfirm, &QPushButton::clicked, this, &UpdateDialog::accept); + connect(mUi->actionCancel, &QAction::triggered, this, &UpdateDialog::reject); + connect(mUi->actionSkip, &QAction::triggered, this, &UpdateDialog::skip); + connect(mUi->checkAutoDownload, &QCheckBox::toggled, this, &UpdateDialog::autoDownloadCheckboxToggled); + + if (mInstallButtons.isEmpty()) { + mUi->buttonInstall->setFocus(); + connect(mUi->buttonInstall, &QPushButton::clicked, this, &UpdateDialog::onButtonInstall); + } else { + mUi->buttonInstall->hide(); + for (auto* button : mInstallButtons) { + button->show(); + connect(button, &QAbstractButton::clicked, this, &UpdateDialog::onButtonCustomInstall); + } + mInstallButtons.last()->setFocus(); + } + + adjustDialogSize(); +} + +void UpdateDialog::setupChangelogUi() +{ + resetUi(); + + QList showWidgets; + showWidgets << mUi->headerContainerChangelog << mUi->buttonConfirm << mUi->labelChangelog; + for (auto* widget : showWidgets) { + widget->show(); + } + for (auto* label : {mUi->labelHeadlineChangelog, mUi->labelInfoChangelog}) { + QString text = label->text(); + replaceAppVars(text); + label->setText(text); + } + + updateWindowTitle(); + + mUi->labelChangelog->setMarkdown(generateChangelogDocument()); + connect(mUi->buttonConfirm, &QPushButton::clicked, this, &UpdateDialog::accept); + mUi->buttonConfirm->setFocus(); + adjustDialogSize(); +} + +void UpdateDialog::setupNoUpdatesUi() +{ + resetUi(); + QList showWidgets; + showWidgets << mUi->headerContainerNoUpdates << mUi->buttonConfirm; + for (auto* widget : showWidgets) { + widget->show(); + } + mUi->buttonConfirm->setFocus(); + + QString text = mUi->labelHeadlineNoUpdates->text(); + replaceAppVars(text); + mUi->labelHeadlineNoUpdates->setText(text); + + updateWindowTitle(); + + connect(mUi->buttonConfirm, &QPushButton::clicked, this, &UpdateDialog::accept); + adjustDialogSize(); +} + +void UpdateDialog::disableButtons(bool disable) +{ + for (auto* button : mInstallButtons) { + button->setDisabled(disable); + } + QList buttons; + buttons << mUi->buttonCancel << mUi->buttonCancelLoading << mUi->buttonConfirm << mUi->buttonInstall << mUi->checkAutoDownload; + for (auto* button : buttons) { + button->setDisabled(disable); + } +} + +void UpdateDialog::replaceAppVars(QString& string) +{ + string.replace("%APPNAME%", QCoreApplication::applicationName()); + string.replace("%CURRENT_VERSION%", QCoreApplication::applicationVersion()); + string.replace("%UPDATE_VERSION%", mLatestRelease.getVersion()); +} + +QString UpdateDialog::generateChangelogDocument() +{ + QString changelog; + QList changelogReleases; + if (mMinVersion.isEmpty() && mMaxVersion.isEmpty()) { + changelogReleases = mUpdates; + } else { + Release minRelease(mMinVersion.isEmpty() ? QApplication::applicationVersion() : mMinVersion); + Release maxRelease(mMaxVersion); + for (const auto& release : mReleases) { + if (minRelease < release && (mMaxVersion.isEmpty() || release <= maxRelease)) { + changelogReleases << release; + } + } + } + for (const auto& release : changelogReleases) { + changelog.append(qsl("## ") + release.getVersion() + qsl("\n\n")); + changelog.append(release.getChangelog() + qsl("\n\n")); + } + return changelog; +} + +void UpdateDialog::startDownload() +{ + mFeed->downloadRelease(mLatestRelease, /*requireChecksums=*/true); + disableButtons(true); +} + +void UpdateDialog::startUpdate() +{ + if (QDesktopServices::openUrl(QUrl::fromLocalFile(mUpdateFilePath))) { + done(QDialog::Accepted); + QApplication::quit(); + } else { + qWarning() << "Failed to open update file:" << mUpdateFilePath << "exists:" << QFile::exists(mUpdateFilePath); + //: Error shown when the downloaded update file cannot be opened for installation. %1 is the file path. + handleDownloadError(tr("Could not open the downloaded update. You can try opening it manually:\n%1").arg(mUpdateFilePath)); + } +} + +void UpdateDialog::autoDownloadCheckboxToggled(bool enabled) +{ + enableAutoDownload(enabled, mSettings); +} + +void UpdateDialog::handleFeedReady() +{ + mFeedLoadFailed = false; + mUpdates = mFeed->getUpdates(dblsqd::Release::getCurrentRelease()); + mReleases = mFeed->getReleases(); + if (!mUpdates.isEmpty()) { + mLatestRelease = mUpdates.first(); + } + + if (mType == ManualChangelog) { + setupChangelogUi(); + emit ready(); + return; + } + + mUpdateFilePath = settingsValue(qsl("updateFilePath"), "", mSettings).toString(); + if (!mUpdateFilePath.isEmpty() && QFile::exists(mUpdateFilePath)) { + QString updateFileVersion = settingsValue(qsl("updateFileVersion"), "", mSettings).toString(); + if (updateFileVersion != mLatestRelease.getVersion() || updateFileVersion == QApplication::applicationVersion()) { + if (!QFile::remove(mUpdateFilePath)) { + qWarning() << "Failed to remove stale update file:" << mUpdateFilePath; + } + removeSetting(qsl("updateFilePath"), mSettings); + removeSetting(qsl("updateFileVersion"), mSettings); + mUpdateFilePath.clear(); + } else { + mIsDownloadFinished = true; + } + } + + if (mUpdates.isEmpty()) { + setupNoUpdatesUi(); + return; + } + + QString latestVersion = mLatestRelease.getVersion(); + bool skipRelease = (settingsValue(qsl("skipRelease"), "", mSettings).toString() == latestVersion); + bool autoDownload = autoDownloadEnabled(mSettings) && (!skipRelease); + if (autoDownload && !mIsDownloadFinished) { + startDownload(); + } + + setupUpdateUi(); + emit ready(); + + KDToolBox::connectSingleShot(mFeed, &Feed::ready, this, &UpdateDialog::handleFeedReady); + KDToolBox::connectSingleShot(mFeed, &Feed::loadError, this, &UpdateDialog::handleLoadError); +} + +void UpdateDialog::handleLoadError(const QString& message) +{ + qWarning() << "Update check failed:" << message; + mFeedLoadFailed = true; + if (isVisible()) { + setupNoUpdatesUi(); + //: Label shown in the update dialog when the update check fails due to a network or server error + mUi->labelHeadlineNoUpdates->setText(tr("Could not check for updates")); + mUi->labelChangelog->setHtml(qsl("

%1

").arg(message.toHtmlEscaped())); + mUi->labelChangelog->show(); + adjustDialogSize(); + } + // Re-establish single-shot connections so the next feed load attempt reaches this dialog + KDToolBox::connectSingleShot(mFeed, &Feed::ready, this, &UpdateDialog::handleFeedReady); + KDToolBox::connectSingleShot(mFeed, &Feed::loadError, this, &UpdateDialog::handleLoadError); +} + +void UpdateDialog::handleDownloadFinished() +{ + const QString filePath = mFeed->getDownloadFilePath(); + if (filePath.isEmpty()) { + //: Error shown when the download finished but no file was saved + handleDownloadError(tr("Download failed. Please try again.")); + return; + } + mIsDownloadFinished = true; + mUpdateFilePath = filePath; + setSettingsValue(qsl("updateFilePath"), mUpdateFilePath, mSettings); + setSettingsValue(qsl("updateFileVersion"), mLatestRelease.getVersion(), mSettings); + + if (mAccepted) { + if (mAcceptedInstallButton == nullptr) { + startUpdate(); + } else { + emit installButtonClicked(mAcceptedInstallButton, mUpdateFilePath); + } + + } else { + disableButtons(false); + } +} + +void UpdateDialog::handleDownloadError(const QString& message) +{ + //: Title for the download error warning dialog + const QString errorTitle = tr("Download Error"); + //: Message shown in the download error warning dialog, followed by the specific error details + QMessageBox::warning(this, errorTitle, tr("There was an error while downloading the update.") + qsl("\n\n") + message); + done(QDialog::Rejected); +} + +void UpdateDialog::updateProgressBar(qint64 bytesReceived, qint64 bytesTotal) +{ + mUi->progressBar->show(); + mUi->progressBar->setMaximum(bytesTotal / 1024); + mUi->progressBar->setValue(bytesReceived / 1024); +} + +void UpdateDialog::onLinkActivated(const QUrl& link) +{ + if (mOpenExternalLinks) { + if (!QDesktopServices::openUrl(link)) { + qWarning() << "Failed to open URL:" << link; + } + } else { + emit linkActivated(link.toString()); + } +} + +/*! \fn void UpdateDialog::ready() + * This signal is emitted when the feed has been loaded and the UpdateDialog is + * ready to be shown with show() or exec(). + * For ManualChangelog type, this is emitted regardless of whether updates are + * available. + */ + +/*! \fn void UpdateDialog::installButtonClicked(QAbstractButton* button, const QString& + * filePath) This signal is emitted when a custom install button was clicked. + */ + +} // namespace dblsqd diff --git a/src/updater/UpdateDialog.h b/src/updater/UpdateDialog.h new file mode 100644 index 000000000..ffd9e1bfa --- /dev/null +++ b/src/updater/UpdateDialog.h @@ -0,0 +1,129 @@ +/*************************************************************************** + * Copyright (C) 2017 by Philipp Medien - hello@dblsqd.com * + * Copyright (C) 2026 by Vadim Peretokin - vperetokin@gmail.com * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +#ifndef DBLSQD_UPDATE_DIALOG_H +#define DBLSQD_UPDATE_DIALOG_H + +#include "Release.h" + +#include +#include + +class QAbstractButton; +class QPixmap; +class QSettings; + +namespace Ui { +class UpdateDialog; +} + +namespace dblsqd { + +class Feed; + +class UpdateDialog : public QDialog +{ + Q_OBJECT + +public: + enum Type { OnUpdateAvailable, OnLastWindowClosed, Manual, ManualChangelog }; + explicit UpdateDialog(Feed* feed, Type type, QSettings* settings, QWidget* parent = nullptr); + ~UpdateDialog(); + + void setIcon(const QString& fileName); + void setIcon(const QPixmap& pixmap); + void addInstallButton(QAbstractButton* button); + + void setMinVersion(const QString& version); + void setMaxVersion(const QString& version); + void setPreviousVersion(const QString& version); + + static bool autoDownloadEnabled(QVariant defaultValue, QSettings* settings); + static bool autoDownloadEnabled(QSettings* settings); + static void enableAutoDownload(bool enabled, QSettings* settings); + + void setOpenExternalLinks(bool open); + bool openExternalLinks() const; + +signals: + void ready(); + void installButtonClicked(QAbstractButton* button, const QString& filePath); + void linkActivated(const QString& link); + +public slots: + void onButtonInstall(); + void onButtonCustomInstall(); + void skip(); + void showIfUpdatesAvailable(); + void showIfUpdatesAvailableOrQuit(); + +private: + Ui::UpdateDialog* mUi; + Feed* mFeed; + Type mType; + + QSettings* mSettings; + void replaceAppVars(QString& string); + QString generateChangelogDocument(); + + void disableButtons(bool disable = true); + void resetUi(); + void setupLoadingUi(); + void setupUpdateUi(); + void setupChangelogUi(); + void setupNoUpdatesUi(); + void adjustDialogSize(); + void updateWindowTitle(); + + void startDownload(); + void startUpdate(); + + bool mAccepted{false}; + bool mIsDownloadFinished{false}; + bool mFeedLoadFailed{false}; + QString mUpdateFilePath; + QList mReleases; + QList mUpdates; + Release mLatestRelease; + QList mInstallButtons; + QAbstractButton* mAcceptedInstallButton; + bool mOpenExternalLinks{true}; + QString mMinVersion; + QString mMaxVersion; + QString mPreviousVersion; + + static void setSettingsValue(const QString& key, const QVariant& value, QSettings* settings); + static QVariant settingsValue(const QString& key, const QVariant& defaultValue, QSettings* settings); + static void removeSetting(const QString& key, QSettings* settings); + static void setDefaultSettingsValue(const QString& key, const QVariant& value, QSettings* settings); + +private slots: + void handleFeedReady(); + void handleLoadError(const QString& message); + void handleDownloadFinished(); + void handleDownloadError(const QString& message); + void updateProgressBar(qint64, qint64); + void autoDownloadCheckboxToggled(bool enabled = true); + void onLinkActivated(const QUrl& link); +}; + +} // namespace dblsqd + +#endif // DBLSQD_UPDATE_DIALOG_H diff --git a/src/updater/update_dialog.ui b/src/updater/update_dialog.ui new file mode 100644 index 000000000..e80b5055f --- /dev/null +++ b/src/updater/update_dialog.ui @@ -0,0 +1,372 @@ + + + UpdateDialog + + + + 0 + 0 + 600 + 645 + + + + + 600 + 0 + + + + %APPNAME% update + + + true + + + + + + + 0 + + + 6 + + + 0 + + + 0 + + + + + + 75 + true + + + + Loading update information … + + + + + + + + + + + 0 + + + 6 + + + 0 + + + 18 + + + 24 + + + 6 + + + + + + 75 + true + + + + A new version of %APPNAME% is available! + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse + + + + + + + + 0 + 0 + + + + + + + + + 0 + 0 + + + + %APPNAME% %UPDATE_VERSION% is available (you have %CURRENT_VERSION%). +Would you like to update now? + + + true + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse + + + + + + + + + + + 0 + + + 6 + + + 0 + + + 18 + + + + + + 75 + true + + + + Changelog for %APPNAME% + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse + + + + + + + You are using version %CURRENT_VERSION%. + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse + + + + + + + + + + + 0 + + + 6 + + + 0 + + + 18 + + + 24 + + + + + There are currently no updates available. + + + true + + + + + + + + 75 + true + + + + You are using %APPNAME% %CURRENT_VERSION%. + + + true + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse + + + + + + + + + + + 0 + 0 + + + + + 0 + 150 + + + + Qt::StrongFocus + + + true + + + false + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 20 + + + + + + + + 24 + + + + + + + Automatically download future updates + + + + + + + + 0 + 0 + + + + + 12 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::ActionsContextMenu + + + QToolButton::MenuButtonPopup + + + Qt::ToolButtonFollowStyle + + + true + + + + + + + Cancel + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Install update now + + + + + + + OK + + + + + + + + + + Remind me later + + + + + Skip this version + + + + + + labelChangelog + checkAutoDownload + + + +