Fix: updater to work with github releases (#9125)

<!-- Keep the title short & concise so anyone non-technical can
understand it,
     the title appears in PTB changelogs -->
#### 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 <slysven@virginmedia.com>
Co-authored-by: Vadim Peretokin <vadi2@users.noreply.github.com>
This commit is contained in:
Vadim Peretokin 2026-04-08 20:19:41 +02:00 committed by GitHub
parent bc9ac84841
commit d2ab992c47
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 2474 additions and 241 deletions

3
.gitmodules vendored
View file

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

1
3rdparty/dblsqd vendored

@ -1 +0,0 @@
Subproject commit 692697328a8312c951df12f07f8c8068d8ae24e7

View file

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

View file

@ -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' '$<TARGET_BUNDLE_DIR:${EXE_MUDLET_TARGET}>/Contents/Info.plist' 2>/dev/null || true && /usr/libexec/PlistBuddy -c 'Add :SUFeedURL string ${SPARKLE_FEED_URL}' '$<TARGET_BUNDLE_DIR:${EXE_MUDLET_TARGET}>/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")

View file

@ -885,7 +885,7 @@ void dlgAboutDialog::setThirdPartyTab(const QString& htmlHead) const
"All rights reserved.</h3>"));
#if defined(INCLUDE_UPDATER) || defined(DEBUG_SHOWALL)
QString DblsqdHeader(tr("<h2><u>Dblsqd</u></h2>"
QString DblsqdHeader(tr("<h2><u>Dblsqd (derived work)</u></h2>"
"<h3>Copyright © 2017 Philipp Medien</h3>"));
#if defined(Q_OS_MACOS) || defined(DEBUG_SHOWALL)
QString SparkleHeader(tr("<h2><u>Sparkle - macOS updater</u></h2>"

View file

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

View file

@ -31,7 +31,7 @@ class SparkleUpdater : public QObject
Q_OBJECT
public:
SparkleUpdater();
explicit SparkleUpdater(QObject* parent = nullptr);
~SparkleUpdater();

View file

@ -37,7 +37,8 @@
@end
SparkleUpdater::SparkleUpdater()
SparkleUpdater::SparkleUpdater(QObject* parent)
: QObject(parent)
{
@autoreleasepool {
_updaterDelegate = [[SparkleUpdaterDelegate alloc] init];

View file

@ -19,6 +19,8 @@
#include "updater.h"
#include "mudlet.h"
#include "updater/Feed.h"
#include "updater/UpdateDialog.h"
#include <QDateTime>
#include <QMessageBox>
@ -27,15 +29,10 @@
#include <chrono>
#include "../3rdparty/kdtoolbox/singleshot_connect/singleshot_connect.h"
#if defined(Q_OS_WINDOWS)
#include <windows.h>
#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<QTimer>();
}
mFeed = new dblsqd::Feed(this);
mFeed->setRepo(qsl("Mudlet"), qsl("Mudlet"), testVersion);
mPeriodicCheck = std::make_unique<QTimer>();
}
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<void> future = QtConcurrent::run([=, this]() {
prepareSetupOnWindows(fileName);
});
// replace current binary with the unzipped one
auto watcher = new QFutureWatcher<void>;
connect(watcher, &QFutureWatcher<void>::finished, this, &Updater::finishSetup);
connect(watcher, &QFutureWatcher<void>::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<void> future = QtConcurrent::run([=, this]() {
untarOnLinux(fileName);
});
// replace current binary with the unzipped one
auto watcher = new QFutureWatcher<void>;
connect(watcher, &QFutureWatcher<void>::finished, this, &Updater::slot_updateLinuxBinary);
connect(watcher, &QFutureWatcher<void>::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<void> future = QtConcurrent::run([&, filePath]() {
QFuture<void> future = QtConcurrent::run([this, filePath]() {
untarOnLinux(filePath);
});
#elif defined(Q_OS_WINDOWS)
QFuture<void> future = QtConcurrent::run([&, filePath]() {
QFuture<void> future = QtConcurrent::run([this, filePath]() {
prepareSetupOnWindows(filePath);
});
#endif
// replace current binary with the unzipped one
auto watcher = new QFutureWatcher<void>;
connect(watcher, &QFutureWatcher<void>::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

View file

@ -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 <QObject>
// 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 <QObject>
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<QTimer> mDailyCheck;
QSettings* mSettings;
std::unique_ptr<QTimer> 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
};

434
src/updater/Feed.cpp Normal file
View file

@ -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 <QCryptographicHash>
#include <QDir>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QRegularExpression>
#include <QTemporaryFile>
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<Release> Feed::getReleases() const
{
return mReleases;
}
QList<Release> Feed::getUpdates(const Release& currentRelease) const
{
QList<Release> 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

100
src/updater/Feed.h Normal file
View file

@ -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 <QList>
#include <QNetworkAccessManager>
#include <QObject>
#include <QUrl>
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<Release> getUpdates(const Release& currentRelease) const;
QList<Release> 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<Release> 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

201
src/updater/Release.cpp Normal file
View file

@ -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 <QCoreApplication>
#include <QDebug>
#include <QJsonArray>
#include <QJsonObject>
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<qint64>(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

66
src/updater/Release.h Normal file
View file

@ -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 <QDateTime>
#include <QString>
#include <QUrl>
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

119
src/updater/SemVer.cpp Normal file
View file

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

50
src/updater/SemVer.h Normal file
View file

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

View file

@ -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 <QAbstractButton>
#include <QAction>
#include <QApplication>
#include <QDesktopServices>
#include <QFile>
#include <QGuiApplication>
#include <QMessageBox>
#include <QPixmap>
#include <QSettings>
#include <QTextBrowser>
#include <QToolButton>
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<QGuiApplication*>(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<QAbstractButton*>(sender()), mUpdateFilePath);
} else if (!mLatestRelease.getVersion().isEmpty()) {
mAcceptedInstallButton = qobject_cast<QAbstractButton*>(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<QGuiApplication*>(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<QWidget*> 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<QWidget*> 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<QWidget*> 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<QWidget*> 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<QWidget*> 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<Release> 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("<p>%1</p>").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

129
src/updater/UpdateDialog.h Normal file
View file

@ -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 <QDialog>
#include <QVariant>
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<Release> mReleases;
QList<Release> mUpdates;
Release mLatestRelease;
QList<QAbstractButton*> 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

View file

@ -0,0 +1,372 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>UpdateDialog</class>
<widget class="QDialog" name="UpdateDialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>600</width>
<height>645</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>600</width>
<height>0</height>
</size>
</property>
<property name="windowTitle">
<string>%APPNAME% update</string>
</property>
<property name="modal">
<bool>true</bool>
</property>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="QWidget" name="headerContainerLoading" native="true">
<layout class="QGridLayout" name="gridLayout_4">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>6</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item row="0" column="0">
<widget class="QLabel" name="labelHeadlineLoading">
<property name="font">
<font>
<weight>75</weight>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>Loading update information …</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QWidget" name="headerContainer" native="true">
<layout class="QGridLayout" name="gridLayout">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>6</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>18</number>
</property>
<property name="horizontalSpacing">
<number>24</number>
</property>
<property name="verticalSpacing">
<number>6</number>
</property>
<item row="0" column="2">
<widget class="QLabel" name="labelHeadline">
<property name="font">
<font>
<weight>75</weight>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>A new version of %APPNAME% is available!</string>
</property>
<property name="textInteractionFlags">
<set>Qt::LinksAccessibleByMouse|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse</set>
</property>
</widget>
</item>
<item row="0" column="0" rowspan="3">
<widget class="QLabel" name="labelIcon">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
</widget>
</item>
<item row="1" column="2" rowspan="2">
<widget class="QLabel" name="labelInfo">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>%APPNAME% %UPDATE_VERSION% is available (you have %CURRENT_VERSION%).
Would you like to update now?</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
<property name="textInteractionFlags">
<set>Qt::LinksAccessibleByMouse|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse</set>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QWidget" name="headerContainerChangelog" native="true">
<layout class="QGridLayout" name="gridLayout_3">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>6</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>18</number>
</property>
<item row="0" column="0">
<widget class="QLabel" name="labelHeadlineChangelog">
<property name="font">
<font>
<weight>75</weight>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>Changelog for %APPNAME%</string>
</property>
<property name="textInteractionFlags">
<set>Qt::LinksAccessibleByMouse|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse</set>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="labelInfoChangelog">
<property name="text">
<string>You are using version %CURRENT_VERSION%.</string>
</property>
<property name="textInteractionFlags">
<set>Qt::LinksAccessibleByMouse|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse</set>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QWidget" name="headerContainerNoUpdates" native="true">
<layout class="QGridLayout" name="gridLayout_2">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>6</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>18</number>
</property>
<property name="horizontalSpacing">
<number>24</number>
</property>
<item row="1" column="0">
<widget class="QLabel" name="labelInfoNoUpdates">
<property name="text">
<string>There are currently no updates available.</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="labelHeadlineNoUpdates">
<property name="font">
<font>
<weight>75</weight>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>You are using %APPNAME% %CURRENT_VERSION%.</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
<property name="textInteractionFlags">
<set>Qt::LinksAccessibleByMouse|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse</set>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QTextBrowser" name="labelChangelog">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="MinimumExpanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>150</height>
</size>
</property>
<property name="focusPolicy">
<enum>Qt::StrongFocus</enum>
</property>
<property name="readOnly">
<bool>true</bool>
</property>
<property name="openExternalLinks">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<spacer name="verticalSpacer_2">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Fixed</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QProgressBar" name="progressBar">
<property name="value">
<number>24</number>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="checkAutoDownload">
<property name="text">
<string>Automatically download future updates</string>
</property>
</widget>
</item>
<item>
<widget class="QWidget" name="buttonContainer" native="true">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Minimum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<layout class="QHBoxLayout" name="horizontalLayout">
<property name="spacing">
<number>12</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QToolButton" name="buttonCancel">
<property name="contextMenuPolicy">
<enum>Qt::ActionsContextMenu</enum>
</property>
<property name="popupMode">
<enum>QToolButton::MenuButtonPopup</enum>
</property>
<property name="toolButtonStyle">
<enum>Qt::ToolButtonFollowStyle</enum>
</property>
<property name="autoRaise">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="buttonCancelLoading">
<property name="text">
<string>Cancel</string>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QPushButton" name="buttonInstall">
<property name="text">
<string>Install update now</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="buttonConfirm">
<property name="text">
<string>OK</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
<action name="actionCancel">
<property name="text">
<string>Remind me later</string>
</property>
</action>
<action name="actionSkip">
<property name="text">
<string>Skip this version</string>
</property>
</action>
</widget>
<layoutdefault spacing="6" margin="11"/>
<tabstops>
<tabstop>labelChangelog</tabstop>
<tabstop>checkAutoDownload</tabstop>
</tabstops>
<resources/>
<connections/>
</ui>