summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMehmet Samet Duman <yongdohyun@projecttick.org>2026-04-01 21:59:57 +0300
committerMehmet Samet Duman <yongdohyun@projecttick.org>2026-04-01 21:59:57 +0300
commit24c39fc544b75dc9c09f69c9cd4c6fba61bf6aac (patch)
tree895daa60d9328e7fc964b0db151af7f415defffa
parentbf8a721e1d388d3320e32ae9ad4881f3245a2878 (diff)
downloadProject-Tick-24c39fc544b75dc9c09f69c9cd4c6fba61bf6aac.tar.gz
Project-Tick-24c39fc544b75dc9c09f69c9cd4c6fba61bf6aac.zip
NOISSUE Refactor UpdateChecker and add standalone updater
- Removed unused member variables and methods from UpdateChecker. - Improved update checking logic by parsing both RSS feed and GitHub releases. - Introduced Installer class for handling update downloads and installations. - Added support for various archive formats (.zip, .tar.gz) in Installer. - Created meshmc-updater executable for standalone update operations. - Updated CMake configuration to include new updater components. Signed-off-by: Mehmet Samet Duman <yongdohyun@projecttick.org>
-rw-r--r--CMakeLists.txt11
-rw-r--r--buildconfig/BuildConfig.cpp.in14
-rw-r--r--buildconfig/BuildConfig.h8
-rw-r--r--launcher/Application.cpp19
-rw-r--r--launcher/Application.h3
-rw-r--r--launcher/CMakeLists.txt18
-rw-r--r--launcher/UpdateController.cpp435
-rw-r--r--launcher/UpdateController.h51
-rw-r--r--launcher/ui/MainWindow.cpp77
-rw-r--r--launcher/ui/MainWindow.h9
-rw-r--r--launcher/ui/dialogs/UpdateDialog.cpp174
-rw-r--r--launcher/ui/dialogs/UpdateDialog.h27
-rw-r--r--launcher/ui/pages/global/MeshMCPage.cpp85
-rw-r--r--launcher/ui/pages/global/MeshMCPage.h5
-rw-r--r--launcher/updater/UpdateChecker.cpp413
-rw-r--r--launcher/updater/UpdateChecker.h160
-rw-r--r--updater/CMakeLists.txt40
-rw-r--r--updater/Installer.cpp346
-rw-r--r--updater/Installer.h78
-rw-r--r--updater/main.cpp105
20 files changed, 1017 insertions, 1061 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt
index cb61eca58c..4abba22dac 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -202,8 +202,14 @@ set(MeshMC_BUILD_ARTIFACT "" CACHE STRING "Artifact name to help the updater ide
# Build platform.
set(MeshMC_BUILD_PLATFORM "" CACHE STRING "A short string identifying the platform that this build was built for. Only used by the notification system and to display in the about dialog.")
-# Channel list URL
-set(MeshMC_UPDATER_BASE "" CACHE STRING "Base URL for the updater.")
+# Channel list URL (legacy - kept for compatibility)
+set(MeshMC_UPDATER_BASE "" CACHE STRING "Base URL for the legacy GoUpdate system (unused).")
+
+# New updater: RSS feed URL (projt: namespace)
+set(MeshMC_UPDATER_FEED_URL "" CACHE STRING "RSS feed URL for the updater (projt: namespace, e.g. https://projecttick.org/product/meshmc/feed.xml).")
+
+# New updater: GitHub releases API URL
+set(MeshMC_UPDATER_GITHUB_API_URL "" CACHE STRING "GitHub Releases API URL for update verification (e.g. https://api.github.com/repos/Project-Tick/MeshMC/releases/latest).")
# Notification URL
set(MeshMC_NOTIFICATION_URL "https://projecttick.org/" CACHE STRING "URL for checking for notifications.")
@@ -401,6 +407,7 @@ add_subdirectory(libraries/katabasis) # An OAuth2 library that tried to do too m
############################### Built Artifacts ###############################
add_subdirectory(buildconfig)
+add_subdirectory(updater)
# NOTE: this must always be last to appease the CMake deity of quirky install command evaluation order.
add_subdirectory(launcher)
diff --git a/buildconfig/BuildConfig.cpp.in b/buildconfig/BuildConfig.cpp.in
index e368dc2ffa..e639997526 100644
--- a/buildconfig/BuildConfig.cpp.in
+++ b/buildconfig/BuildConfig.cpp.in
@@ -52,6 +52,8 @@ Config::Config()
COMPILER_TARGET_SYSTEM_VERSION = "@MeshMC_COMPILER_TARGET_SYSTEM_VERSION@";
COMPILER_TARGET_SYSTEM_PROCESSOR = "@MeshMC_COMPILER_TARGET_PROCESSOR@";
UPDATER_BASE = "@MeshMC_UPDATER_BASE@";
+ UPDATER_FEED_URL = "@MeshMC_UPDATER_FEED_URL@";
+ UPDATER_GITHUB_API_URL = "@MeshMC_UPDATER_GITHUB_API_URL@";
ANALYTICS_ID = "@MeshMC_ANALYTICS_ID@";
NOTIFICATION_URL = "@MeshMC_NOTIFICATION_URL@";
FULL_VERSION_STR = "@MeshMC_VERSION_MAJOR@.@MeshMC_VERSION_MINOR@.@MeshMC_VERSION_BUILD@";
@@ -59,11 +61,19 @@ Config::Config()
GIT_COMMIT = "@MeshMC_GIT_COMMIT@";
GIT_REFSPEC = "@MeshMC_GIT_REFSPEC@";
GIT_TAG = "@MeshMC_GIT_TAG@";
- if(GIT_REFSPEC.startsWith("refs/heads/") && !UPDATER_BASE.isEmpty() && !BUILD_PLATFORM.isEmpty() && VERSION_BUILD >= 0)
+
+ // New updater: enabled when both feed and GitHub API URLs are configured and
+ // a build artifact name is provided for platform-specific asset matching.
+ if (!UPDATER_FEED_URL.isEmpty() && !UPDATER_GITHUB_API_URL.isEmpty() && !BUILD_ARTIFACT.isEmpty())
+ {
+ UPDATER_ENABLED = true;
+ }
+
+ // VERSION_CHANNEL is still populated from the git branch for informational use.
+ if (GIT_REFSPEC.startsWith("refs/heads/"))
{
VERSION_CHANNEL = GIT_REFSPEC;
VERSION_CHANNEL.remove("refs/heads/");
- UPDATER_ENABLED = true;
}
else
{
diff --git a/buildconfig/BuildConfig.h b/buildconfig/BuildConfig.h
index d96ccaab36..535d9a0502 100644
--- a/buildconfig/BuildConfig.h
+++ b/buildconfig/BuildConfig.h
@@ -56,9 +56,15 @@ public:
/// A short string identifying this build's platform. For example, "lin64" or "win32".
QString BUILD_PLATFORM;
- /// URL for the updater's channel
+ /// URL for the updater's channel (legacy, unused)
QString UPDATER_BASE;
+ /// RSS feed URL for the new updater (projt: namespace).
+ QString UPDATER_FEED_URL;
+
+ /// GitHub releases API URL for update verification.
+ QString UPDATER_GITHUB_API_URL;
+
/// A string containing the build timestamp
QString BUILD_DATE;
diff --git a/launcher/Application.cpp b/launcher/Application.cpp
index 892fb2c14c..874b6ae996 100644
--- a/launcher/Application.cpp
+++ b/launcher/Application.cpp
@@ -937,16 +937,15 @@ void Application::initSubsystems()
}
// initialize the updater
- if(BuildConfig.UPDATER_ENABLED)
- {
- auto platform = getIdealPlatform(BuildConfig.BUILD_PLATFORM);
- auto channelUrl = BuildConfig.UPDATER_BASE + platform + "/channels.json";
- qDebug() << "Initializing updater with platform: " << platform << " -- " << channelUrl;
- m_updateChecker.reset(new UpdateChecker(
- m_network, channelUrl,
- BuildConfig.VERSION_CHANNEL,
- BuildConfig.VERSION_BUILD));
- qDebug() << "<> Updater started.";
+ if(BuildConfig.UPDATER_ENABLED && UpdateChecker::isUpdaterSupported())
+ {
+ m_updateChecker.reset(new UpdateChecker(m_network));
+ qDebug() << "<> Updater initialized (feed:" << BuildConfig.UPDATER_FEED_URL
+ << "| github:" << BuildConfig.UPDATER_GITHUB_API_URL << ").";
+ }
+ else if(BuildConfig.UPDATER_ENABLED)
+ {
+ qDebug() << "<> Updater disabled on this platform/mode.";
}
// Instance icons
diff --git a/launcher/Application.h b/launcher/Application.h
index 43cc0c25a9..8cd15c02eb 100644
--- a/launcher/Application.h
+++ b/launcher/Application.h
@@ -29,7 +29,7 @@
#include <QDateTime>
#include <QUrl>
#include <QHash>
-#include <updater/GoUpdate.h>
+#include <updater/UpdateChecker.h>
#include <BaseInstance.h>
@@ -49,7 +49,6 @@ class AccountList;
class IconList;
class QNetworkAccessManager;
class JavaInstallList;
-class UpdateChecker;
class BaseProfilerFactory;
class BaseDetachedToolFactory;
class TranslationsModel;
diff --git a/launcher/CMakeLists.txt b/launcher/CMakeLists.txt
index c1c0a65507..00b5b6f909 100644
--- a/launcher/CMakeLists.txt
+++ b/launcher/CMakeLists.txt
@@ -148,28 +148,12 @@ set(LAUNCH_SOURCES
launch/LogModel.h
)
-# Old update system
+# New updater system (replaces the old GoUpdate-based system)
set(UPDATE_SOURCES
- updater/GoUpdate.h
- updater/GoUpdate.cpp
updater/UpdateChecker.h
updater/UpdateChecker.cpp
- updater/DownloadTask.h
- updater/DownloadTask.cpp
)
-add_unit_test(UpdateChecker
- SOURCES updater/UpdateChecker_test.cpp
- LIBS MeshMC_logic
- DATA updater/testdata
- )
-
-add_unit_test(DownloadTask
- SOURCES updater/DownloadTask_test.cpp
- LIBS MeshMC_logic
- DATA updater/testdata
- )
-
# Rarely used notifications
set(NOTIFICATIONS_SOURCES
# Notifications - short warning messages
diff --git a/launcher/UpdateController.cpp b/launcher/UpdateController.cpp
index bb2bd36176..8ab57c5804 100644
--- a/launcher/UpdateController.cpp
+++ b/launcher/UpdateController.cpp
@@ -19,409 +19,60 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
-#include <QFile>
-#include <QMessageBox>
-#include <FileSystem.h>
-#include <updater/GoUpdate.h>
#include "UpdateController.h"
+
#include <QApplication>
-#include <thread>
-#include <chrono>
-#include <LocalPeer.h>
+#include <QDebug>
+#include <QDir>
+#include <QMessageBox>
+#include <QProcess>
#include "BuildConfig.h"
+#include "FileSystem.h"
-
-// from <sys/stat.h>
-#ifndef S_IRUSR
-#define __S_IREAD 0400 /* Read by owner. */
-#define __S_IWRITE 0200 /* Write by owner. */
-#define __S_IEXEC 0100 /* Execute by owner. */
-#define S_IRUSR __S_IREAD /* Read by owner. */
-#define S_IWUSR __S_IWRITE /* Write by owner. */
-#define S_IXUSR __S_IEXEC /* Execute by owner. */
-
-#define S_IRGRP (S_IRUSR >> 3) /* Read by group. */
-#define S_IWGRP (S_IWUSR >> 3) /* Write by group. */
-#define S_IXGRP (S_IXUSR >> 3) /* Execute by group. */
-
-#define S_IROTH (S_IRGRP >> 3) /* Read by others. */
-#define S_IWOTH (S_IWGRP >> 3) /* Write by others. */
-#define S_IXOTH (S_IXGRP >> 3) /* Execute by others. */
-#endif
-static QFile::Permissions unixModeToPermissions(const int mode)
+UpdateController::UpdateController(QWidget *parent, const QString &root, const QString &downloadUrl)
+ : m_parent(parent), m_root(root), m_downloadUrl(downloadUrl)
{
- QFile::Permissions perms;
-
- if (mode & S_IRUSR)
- {
- perms |= QFile::ReadUser;
- }
- if (mode & S_IWUSR)
- {
- perms |= QFile::WriteUser;
- }
- if (mode & S_IXUSR)
- {
- perms |= QFile::ExeUser;
- }
-
- if (mode & S_IRGRP)
- {
- perms |= QFile::ReadGroup;
- }
- if (mode & S_IWGRP)
- {
- perms |= QFile::WriteGroup;
- }
- if (mode & S_IXGRP)
- {
- perms |= QFile::ExeGroup;
- }
-
- if (mode & S_IROTH)
- {
- perms |= QFile::ReadOther;
- }
- if (mode & S_IWOTH)
- {
- perms |= QFile::WriteOther;
- }
- if (mode & S_IXOTH)
- {
- perms |= QFile::ExeOther;
- }
- return perms;
-}
-
-static const QLatin1String liveCheckFile("live.check");
-
-UpdateController::UpdateController(QWidget * parent, const QString& root, const QString updateFilesDir, GoUpdate::OperationList operations)
-{
- m_parent = parent;
- m_root = root;
- m_updateFilesDir = updateFilesDir;
- m_operations = operations;
}
-
-void UpdateController::installUpdates()
+bool UpdateController::startUpdate()
{
- qint64 pid = -1;
- QStringList args;
- bool started = false;
-
- qDebug() << "Installing updates.";
+ // Locate the updater binary next to ourselves.
+ QString updaterName = BuildConfig.MESHMC_NAME + "-updater";
#ifdef Q_OS_WIN
- QString finishCmd = QApplication::applicationFilePath();
-#elif defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD)
- QString finishCmd = FS::PathCombine(m_root, BuildConfig.MESHMC_NAME);
-#elif defined Q_OS_MAC
- QString finishCmd = QApplication::applicationFilePath();
-#else
-#error Unsupported operating system.
+ updaterName += ".exe";
#endif
-
- QString backupPath = FS::PathCombine(m_root, "update", "backup");
- QDir origin(m_root);
-
- // clean up the backup folder. it should be empty before we start
- if(!FS::deletePath(backupPath))
- {
- qWarning() << "couldn't remove previous backup folder" << backupPath;
- }
- // and it should exist.
- if(!FS::ensureFolderPathExists(backupPath))
- {
- qWarning() << "couldn't create folder" << backupPath;
- return;
- }
-
- // perform the update operations
- for(auto op: m_operations)
- {
- switch(op.type)
- {
- // replace = move original out to backup, if it exists, move the new file in its place
- case GoUpdate::Operation::OP_REPLACE:
- {
-#ifdef Q_OS_WIN32
- QString windowsExeName = BuildConfig.MESHMC_NAME + ".exe";
- // hack for people renaming the .exe because ... reasons :)
- if(op.destination == windowsExeName)
- {
- op.destination = QFileInfo(QApplication::applicationFilePath()).fileName();
- }
-#endif
- QFileInfo destination (FS::PathCombine(m_root, op.destination));
- if(destination.exists())
- {
- QString backupName = op.destination;
- backupName.replace('/', '_');
- QString backupFilePath = FS::PathCombine(backupPath, backupName);
- if(!QFile::rename(destination.absoluteFilePath(), backupFilePath))
- {
- qWarning() << "Couldn't move:" << destination.absoluteFilePath() << "to" << backupFilePath;
- m_failedOperationType = Replace;
- m_failedFile = op.destination;
- fail();
- return;
- }
- BackupEntry be;
- be.original = destination.absoluteFilePath();
- be.backup = backupFilePath;
- be.update = op.source;
- m_replace_backups.append(be);
- }
- // make sure the folder we are putting this into exists
- if(!FS::ensureFilePathExists(destination.absoluteFilePath()))
- {
- qWarning() << "REPLACE: Couldn't create folder:" << destination.absoluteFilePath();
- m_failedOperationType = Replace;
- m_failedFile = op.destination;
- fail();
- return;
- }
- // now move the new file in
- if(!QFile::rename(op.source, destination.absoluteFilePath()))
- {
- qWarning() << "REPLACE: Couldn't move:" << op.source << "to" << destination.absoluteFilePath();
- m_failedOperationType = Replace;
- m_failedFile = op.destination;
- fail();
- return;
- }
- QFile::setPermissions(destination.absoluteFilePath(), unixModeToPermissions(op.destinationMode));
- }
- break;
- // delete = move original to backup
- case GoUpdate::Operation::OP_DELETE:
- {
- QString destFilePath = FS::PathCombine(m_root, op.destination);
- if(QFile::exists(destFilePath))
- {
- QString backupName = op.destination;
- backupName.replace('/', '_');
- QString trashFilePath = FS::PathCombine(backupPath, backupName);
-
- if(!QFile::rename(destFilePath, trashFilePath))
- {
- qWarning() << "DELETE: Couldn't move:" << op.destination << "to" << trashFilePath;
- m_failedFile = op.destination;
- m_failedOperationType = Delete;
- fail();
- return;
- }
- BackupEntry be;
- be.original = destFilePath;
- be.backup = trashFilePath;
- m_delete_backups.append(be);
- }
- }
- break;
- }
- }
-
- // try to start the new binary
- args = qApp->arguments();
- args.removeFirst();
-
- bool doLiveCheck = true;
- bool startFailed = false;
-
- // remove live check file, if any
- if(QFile::exists(liveCheckFile))
- {
- if(!QFile::remove(liveCheckFile))
- {
- qWarning() << "Couldn't remove the" << liveCheckFile << "file! We will proceed without :(";
- doLiveCheck = false;
- }
- }
-
- if(doLiveCheck)
- {
- if(!args.contains("--alive"))
- {
- args.append("--alive");
- }
- }
-
- // FIXME: reparse args and construct a safe variant from scratch. This is a workaround for GH-1874:
- QStringList realargs;
- int skip = 0;
- for(auto & arg: args)
- {
- if(skip)
- {
- skip--;
- continue;
- }
- if(arg == "-l")
- {
- skip = 1;
- continue;
- }
- realargs.append(arg);
- }
-
- // start the updated application
- started = QProcess::startDetached(finishCmd, realargs, QDir::currentPath(), &pid);
- // much dumber check - just find out if the call
- if(!started || pid == -1)
- {
- qWarning() << "Couldn't start new process properly!";
- startFailed = true;
- }
- if(!startFailed && doLiveCheck)
- {
- int attempts = 0;
- while(attempts < 10)
- {
- attempts++;
- QString key;
- std::this_thread::sleep_for(std::chrono::milliseconds(250));
- if(!QFile::exists(liveCheckFile))
- {
- qWarning() << "Couldn't find the" << liveCheckFile << "file!";
- startFailed = true;
- continue;
- }
- try
- {
- key = QString::fromUtf8(FS::read(liveCheckFile));
- auto id = ApplicationId::fromRawString(key);
- LocalPeer peer(nullptr, id);
- if(peer.isClient())
- {
- startFailed = false;
- qDebug() << "Found process started with key " << key;
- break;
- }
- else
- {
- startFailed = true;
- qDebug() << "Process started with key " << key << "apparently died or is not reponding...";
- break;
- }
- }
- catch (const Exception &e)
- {
- qWarning() << "Couldn't read the" << liveCheckFile << "file!";
- startFailed = true;
- continue;
- }
- }
- }
- if(startFailed)
- {
- m_failedOperationType = Start;
- fail();
- return;
- }
- else
- {
- origin.rmdir(m_updateFilesDir);
- qApp->quit();
- return;
- }
-}
-
-void UpdateController::fail()
-{
- qWarning() << "Update failed!";
-
- QString msg;
- bool doRollback = false;
- QString failTitle = QObject::tr("Update failed!");
- QString rollFailTitle = QObject::tr("Rollback failed!");
- switch (m_failedOperationType)
- {
- case Replace:
- {
- msg = QObject::tr(
- "Couldn't replace file %1. Changes will be reverted.\n"
- "See the %2 log file for details."
- ).arg(m_failedFile, BuildConfig.MESHMC_NAME);
- doRollback = true;
- QMessageBox::critical(m_parent, failTitle, msg);
- break;
- }
- case Delete:
- {
- msg = QObject::tr(
- "Couldn't remove file %1. Changes will be reverted.\n"
- "See the %2 log file for details."
- ).arg(m_failedFile, BuildConfig.MESHMC_NAME);
- doRollback = true;
- QMessageBox::critical(m_parent, failTitle, msg);
- break;
- }
- case Start:
- {
- msg = QObject::tr("The new version didn't start or is too old and doesn't respond to startup checks.\n"
- "\n"
- "Roll back to previous version?");
- auto result = QMessageBox::critical(
- m_parent,
- failTitle,
- msg,
- QMessageBox::Yes | QMessageBox::No,
- QMessageBox::Yes
- );
- doRollback = (result == QMessageBox::Yes);
- break;
- }
- case Nothing:
- default:
- return;
- }
- if(doRollback)
- {
- auto rollbackOK = rollback();
- if(!rollbackOK)
- {
- msg = QObject::tr("The rollback failed too.\n"
- "You will have to repair %1 manually.\n"
- "Please let us know why and how this happened.").arg(BuildConfig.MESHMC_NAME);
- QMessageBox::critical(m_parent, rollFailTitle, msg);
- qApp->quit();
- }
- }
- else
- {
- qApp->quit();
- }
+ const QString updaterPath = FS::PathCombine(m_root, updaterName);
+
+ if (!QFile::exists(updaterPath)) {
+ qCritical() << "UpdateController: updater binary not found at" << updaterPath;
+ QMessageBox::critical(
+ m_parent,
+ QCoreApplication::translate("UpdateController", "Updater Not Found"),
+ QCoreApplication::translate("UpdateController", "The updater binary could not be found at:\n%1\n\nPlease reinstall %2.")
+ .arg(updaterPath, BuildConfig.MESHMC_DISPLAYNAME));
+ return false;
+ }
+
+ const QStringList args = {
+ "--url", m_downloadUrl,
+ "--root", m_root,
+ "--exec", QApplication::applicationFilePath()
+ };
+
+ qDebug() << "UpdateController: launching" << updaterPath << "with args" << args;
+ const bool ok = QProcess::startDetached(updaterPath, args);
+ if (!ok) {
+ qCritical() << "UpdateController: failed to start updater binary.";
+ QMessageBox::critical(
+ m_parent,
+ QCoreApplication::translate("UpdateController", "Update Failed"),
+ QCoreApplication::translate("UpdateController", "Could not launch the updater binary.\nPlease update %1 manually.")
+ .arg(BuildConfig.MESHMC_DISPLAYNAME));
+ return false;
+ }
+
+ return true;
}
-bool UpdateController::rollback()
-{
- bool revertOK = true;
- // if the above failed, roll back changes
- for(auto backup:m_replace_backups)
- {
- qWarning() << "restoring" << backup.original << "from" << backup.backup;
- if(!QFile::rename(backup.original, backup.update))
- {
- revertOK = false;
- qWarning() << "moving new" << backup.original << "back to" << backup.update << "failed!";
- continue;
- }
- if(!QFile::rename(backup.backup, backup.original))
- {
- revertOK = false;
- qWarning() << "restoring" << backup.original << "failed!";
- }
- }
- for(auto backup:m_delete_backups)
- {
- qWarning() << "restoring" << backup.original << "from" << backup.backup;
- if(!QFile::rename(backup.backup, backup.original))
- {
- revertOK = false;
- qWarning() << "restoring" << backup.original << "failed!";
- }
- }
- return revertOK;
-}
diff --git a/launcher/UpdateController.h b/launcher/UpdateController.h
index 0188df32ab..a57636c746 100644
--- a/launcher/UpdateController.h
+++ b/launcher/UpdateController.h
@@ -22,44 +22,33 @@
#pragma once
#include <QString>
-#include <QList>
-#include <updater/GoUpdate.h>
class QWidget;
+/*!
+ * UpdateController launches the separate meshmc-updater binary and then
+ * requests the main application to quit so the updater can proceed.
+ *
+ * The updater binary receives:
+ * --url <download_url> - artifact to download and install
+ * --root <root_path> - installation root (directory of the binary)
+ * --exec <app_binary> - path to re-launch after the update completes
+ */
class UpdateController
{
public:
- UpdateController(QWidget * parent, const QString &root, const QString updateFilesDir, GoUpdate::OperationList operations);
- void installUpdates();
+ UpdateController(QWidget *parent, const QString &root, const QString &downloadUrl);
-private:
- void fail();
- bool rollback();
+ /*!
+ * Locates the meshmc-updater binary next to the running executable,
+ * launches it with the required arguments, and returns true on success.
+ * The caller is responsible for quitting the main application afterwards.
+ */
+ bool startUpdate();
private:
- QString m_root;
- QString m_updateFilesDir;
- GoUpdate::OperationList m_operations;
- QWidget * m_parent;
-
- struct BackupEntry
- {
- // path where we got the new file from
- QString update;
- // path of what is being actually updated
- QString original;
- // path where the backup of the updated file was placed
- QString backup;
- };
- QList <BackupEntry> m_replace_backups;
- QList <BackupEntry> m_delete_backups;
- enum Failure
- {
- Replace,
- Delete,
- Start,
- Nothing
- } m_failedOperationType = Nothing;
- QString m_failedFile;
+ QWidget *m_parent;
+ QString m_root;
+ QString m_downloadUrl;
};
+
diff --git a/launcher/ui/MainWindow.cpp b/launcher/ui/MainWindow.cpp
index c69b0bab08..e8d0e3414d 100644
--- a/launcher/ui/MainWindow.cpp
+++ b/launcher/ui/MainWindow.cpp
@@ -842,7 +842,7 @@ MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent), ui(new MainWindow
}
- if(BuildConfig.UPDATER_ENABLED)
+ if(BuildConfig.UPDATER_ENABLED && UpdateChecker::isUpdaterSupported())
{
bool updatesAllowed = APPLICATION->updatesAreAllowed();
updatesAllowedChanged(updatesAllowed);
@@ -857,7 +857,7 @@ MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent), ui(new MainWindow
// if automatic update checks are allowed, start one.
if (APPLICATION->settings()->get("AutoUpdate").toBool() && updatesAllowed)
{
- updater->checkForUpdate(APPLICATION->settings()->get("UpdateChannel").toString(), false);
+ updater->checkForUpdate(false);
}
}
@@ -1127,7 +1127,7 @@ void MainWindow::repopulateAccountsMenu()
void MainWindow::updatesAllowedChanged(bool allowed)
{
- if(!BuildConfig.UPDATER_ENABLED)
+ if(!BuildConfig.UPDATER_ENABLED || !UpdateChecker::isUpdaterSupported())
{
return;
}
@@ -1238,14 +1238,14 @@ void MainWindow::updateNewsLabel()
}
}
-void MainWindow::updateAvailable(GoUpdate::Status status)
+void MainWindow::updateAvailable(UpdateAvailableStatus status)
{
if(!APPLICATION->updatesAreAllowed())
{
updateNotAvailable();
return;
}
- UpdateDialog dlg(true, this);
+ UpdateDialog dlg(true, status, this);
UpdateAction action = (UpdateAction)dlg.exec();
switch (action)
{
@@ -1253,14 +1253,36 @@ void MainWindow::updateAvailable(GoUpdate::Status status)
qDebug() << "Update will be installed later.";
break;
case UPDATE_NOW:
- downloadUpdates(status);
+ if(!status.downloadUrl.isEmpty())
+ {
+ APPLICATION->updateIsRunning(true);
+ UpdateController controller(this, APPLICATION->root(), status.downloadUrl);
+ if(controller.startUpdate())
+ {
+ // The updater binary has been launched; quit the main app so
+ // the updater can overwrite its files.
+ QCoreApplication::quit();
+ }
+ APPLICATION->updateIsRunning(false);
+ }
+ else
+ {
+ CustomMessageBox::selectable(
+ this,
+ tr("No Download URL"),
+ tr("An update to version %1 is available, but no download URL "
+ "was found for your platform (%2).\n"
+ "Please visit the project website to download it manually.")
+ .arg(status.version, BuildConfig.BUILD_ARTIFACT),
+ QMessageBox::Information)->show();
+ }
break;
}
}
void MainWindow::updateNotAvailable()
{
- UpdateDialog dlg(false, this);
+ UpdateDialog dlg(false, {}, this);
dlg.exec();
}
@@ -1302,38 +1324,11 @@ void MainWindow::notificationsChanged()
APPLICATION->settings()->set("ShownNotifications", intListToString(shownNotifications));
}
-void MainWindow::downloadUpdates(GoUpdate::Status status)
+void MainWindow::downloadUpdates(UpdateAvailableStatus status)
{
- if(!APPLICATION->updatesAreAllowed())
- {
- return;
- }
- qDebug() << "Downloading updates.";
- ProgressDialog updateDlg(this);
- status.rootPath = APPLICATION->root();
-
- auto dlPath = FS::PathCombine(APPLICATION->root(), "update", "XXXXXX");
- if (!FS::ensureFilePathExists(dlPath))
- {
- CustomMessageBox::selectable(this, tr("Error"), tr("Couldn't create folder for update downloads:\n%1").arg(dlPath), QMessageBox::Warning)->show();
- }
- GoUpdate::DownloadTask updateTask(APPLICATION->network(), status, dlPath, &updateDlg);
- // If the task succeeds, install the updates.
- if (updateDlg.execWithTask(&updateTask))
- {
- /**
- * NOTE: This disables launching instances until the update either succeeds (and this process exits)
- * or the update fails (and the control leaves this scope).
- */
- APPLICATION->updateIsRunning(true);
- UpdateController update(this, APPLICATION->root(), updateTask.updateFilesDir(), updateTask.operations());
- update.installUpdates();
- APPLICATION->updateIsRunning(false);
- }
- else
- {
- CustomMessageBox::selectable(this, tr("Error"), updateTask.failReason(), QMessageBox::Warning)->show();
- }
+ // Kept as a stub — actual update installation is now done by the separate
+ // meshmc-updater binary launched from updateAvailable().
+ Q_UNUSED(status)
}
void MainWindow::onCatToggled(bool state)
@@ -1622,14 +1617,14 @@ void MainWindow::on_actionConfig_Folder_triggered()
void MainWindow::checkForUpdates()
{
- if(BuildConfig.UPDATER_ENABLED)
+ if(BuildConfig.UPDATER_ENABLED && UpdateChecker::isUpdaterSupported())
{
auto updater = APPLICATION->updateChecker();
- updater->checkForUpdate(APPLICATION->settings()->get("UpdateChannel").toString(), true);
+ updater->checkForUpdate(true);
}
else
{
- qWarning() << "Updater not set up. Cannot check for updates.";
+ qWarning() << "Updater not set up or not supported on this platform. Cannot check for updates.";
}
}
diff --git a/launcher/ui/MainWindow.h b/launcher/ui/MainWindow.h
index b8005d3243..51026560cd 100644
--- a/launcher/ui/MainWindow.h
+++ b/launcher/ui/MainWindow.h
@@ -47,7 +47,7 @@
#include "BaseInstance.h"
#include "minecraft/auth/MinecraftAccount.h"
#include "net/NetJob.h"
-#include "updater/GoUpdate.h"
+#include "updater/UpdateChecker.h"
class LaunchController;
class NewsChecker;
@@ -185,7 +185,7 @@ private slots:
void startTask(Task *task);
- void updateAvailable(GoUpdate::Status status);
+ void updateAvailable(UpdateAvailableStatus status);
void updateNotAvailable();
@@ -200,9 +200,10 @@ private slots:
void updateNewsLabel();
/*!
- * Runs the DownloadTask and installs updates.
+ * Stub kept for source compatibility; actual installation is delegated to
+ * the meshmc-updater binary via UpdateController.
*/
- void downloadUpdates(GoUpdate::Status status);
+ void downloadUpdates(UpdateAvailableStatus status);
void konamiTriggered();
diff --git a/launcher/ui/dialogs/UpdateDialog.cpp b/launcher/ui/dialogs/UpdateDialog.cpp
index d8a2df670c..75f3c395c8 100644
--- a/launcher/ui/dialogs/UpdateDialog.cpp
+++ b/launcher/ui/dialogs/UpdateDialog.cpp
@@ -21,170 +21,40 @@
#include "UpdateDialog.h"
#include "ui_UpdateDialog.h"
-#include <QDebug>
-#include <QRegularExpression>
#include "Application.h"
-#include <settings/SettingsObject.h>
-#include <Json.h>
-
#include "BuildConfig.h"
-#include "HoeDown.h"
-UpdateDialog::UpdateDialog(bool hasUpdate, QWidget *parent) : QDialog(parent), ui(new Ui::UpdateDialog)
+UpdateDialog::UpdateDialog(bool hasUpdate, const UpdateAvailableStatus &status, QWidget *parent)
+ : QDialog(parent), ui(new Ui::UpdateDialog)
{
ui->setupUi(this);
- auto channel = APPLICATION->settings()->get("UpdateChannel").toString();
- if(hasUpdate)
- {
- ui->label->setText(tr("A new %1 update is available!").arg(channel));
- }
- else
- {
- ui->label->setText(tr("No %1 updates found. You are running the latest version.").arg(channel));
- ui->btnUpdateNow->setHidden(true);
- ui->btnUpdateLater->setText(tr("Close"));
- }
- ui->changelogBrowser->setHtml(tr("<center><h1>Loading changelog...</h1></center>"));
- loadChangelog();
- restoreGeometry(QByteArray::fromBase64(APPLICATION->settings()->get("UpdateDialogGeometry").toByteArray()));
-}
-
-UpdateDialog::~UpdateDialog()
-{
-}
-
-void UpdateDialog::loadChangelog()
-{
- auto channel = APPLICATION->settings()->get("UpdateChannel").toString();
- dljob = new NetJob("Changelog", APPLICATION->network());
- QString url;
- if(channel == "stable")
- {
- url = QString("https://raw.githubusercontent.com/Project-Tick/MeshMC/%1/changelog.md").arg(channel);
- m_changelogType = CHANGELOG_MARKDOWN;
- }
- else
- {
- url = QString("https://api.github.com/repos/Project-Tick/MeshMC/compare/%1...%2").arg(BuildConfig.GIT_COMMIT, channel);
- m_changelogType = CHANGELOG_COMMITS;
- }
- dljob->addNetAction(Net::Download::makeByteArray(QUrl(url), &changelogData));
- connect(dljob.get(), &NetJob::succeeded, this, &UpdateDialog::changelogLoaded);
- connect(dljob.get(), &NetJob::failed, this, &UpdateDialog::changelogFailed);
- dljob->start();
-}
-QString reprocessMarkdown(QByteArray markdown)
-{
- HoeDown hoedown;
- QString output = hoedown.process(markdown);
-
- // HACK: easier than customizing hoedown
- output.replace(QRegularExpression("GH-([0-9]+)"), "<a href=\"https://github.com/Project-Tick/MeshMC/issues/\\1\">GH-\\1</a>");
- qDebug() << output;
- return output;
-}
-
-QString reprocessCommits(QByteArray json)
-{
- auto channel = APPLICATION->settings()->get("UpdateChannel").toString();
- try
- {
- QString result;
- auto document = Json::requireDocument(json);
- auto rootobject = Json::requireObject(document);
- auto status = Json::requireString(rootobject, "status");
- auto diff_url = Json::requireString(rootobject, "html_url");
-
- auto print_commits = [&]()
- {
- result += "<table cellspacing=0 cellpadding=2 style='border-width: 1px; border-style: solid'>";
- auto commitarray = Json::requireArray(rootobject, "commits");
- for(int i = commitarray.size() - 1; i >= 0; i--)
- {
- const auto & commitval = commitarray[i];
- auto commitobj = Json::requireObject(commitval);
- auto parents_info = Json::ensureArray(commitobj, "parents");
- // NOTE: this ignores merge commits, because they have more than one parent
- if(parents_info.size() > 1)
- {
- continue;
- }
- auto commit_url = Json::requireString(commitobj, "html_url");
- auto commit_info = Json::requireObject(commitobj, "commit");
- auto commit_message = Json::requireString(commit_info, "message");
- auto lines = commit_message.split('\n');
- QRegularExpression regexp("(?<prefix>(GH-(?<issuenr>[0-9]+))|(NOISSUE)|(SCRATCH))? *(?<rest>.*) *");
- auto match = regexp.match(lines.takeFirst(), 0, QRegularExpression::NormalMatch);
- auto issuenr = match.captured("issuenr");
- auto prefix = match.captured("prefix");
- auto rest = match.captured("rest");
- result += "<tr><td>";
- if(issuenr.length())
- {
- result += QString("<a href=\"https://github.com/Project-Tick/MeshMC/issues/%1\">GH-%2</a>").arg(issuenr, issuenr);
- }
- else if(prefix.length())
- {
- result += QString("<a href=\"%1\">%2</a>").arg(commit_url, prefix);
- }
- else
- {
- result += QString("<a href=\"%1\">NOISSUE</a>").arg(commit_url);
- }
- result += "</td>";
- lines.prepend(rest);
- result += "<td><p>" + lines.join("<br />") + "</p></td></tr>";
- }
- result += "</table>";
- };
+ if (hasUpdate) {
+ ui->label->setText(tr("<b>%1 %2</b> is available!").arg(
+ BuildConfig.MESHMC_DISPLAYNAME, status.version));
- if(status == "identical")
- {
- return QObject::tr("<p>There are no code changes between your current version and latest %1.</p>").arg(channel);
- }
- else if(status == "ahead")
- {
- result += QObject::tr("<p>Following commits were added since last update:</p>");
- print_commits();
- }
- else if(status == "diverged")
- {
- auto commit_ahead = Json::requireInteger(rootobject, "ahead_by");
- auto commit_behind = Json::requireInteger(rootobject, "behind_by");
- result += QObject::tr("<p>The update removes %1 commits and adds the following %2:</p>").arg(commit_behind).arg(commit_ahead);
- print_commits();
+ if (!status.releaseNotes.isEmpty()) {
+ ui->changelogBrowser->setHtml(status.releaseNotes);
+ } else {
+ ui->changelogBrowser->setHtml(
+ tr("<center><p>No release notes available.</p></center>"));
}
- result += QObject::tr("<p>You can <a href=\"%1\">look at the changes on github</a>.</p>").arg(diff_url);
- return result;
- }
- catch (const JSONValidationError &e)
- {
- qWarning() << "Got an unparseable commit log from github:" << e.what();
- qDebug() << json;
+ } else {
+ ui->label->setText(tr("You are running the latest version of %1.").arg(
+ BuildConfig.MESHMC_DISPLAYNAME));
+ ui->changelogBrowser->setHtml(
+ tr("<center><p>No updates found.</p></center>"));
+ ui->btnUpdateNow->setHidden(true);
+ ui->btnUpdateLater->setText(tr("Close"));
}
- return QString();
-}
-void UpdateDialog::changelogLoaded()
-{
- QString result;
- switch(m_changelogType)
- {
- case CHANGELOG_COMMITS:
- result = reprocessCommits(changelogData);
- break;
- case CHANGELOG_MARKDOWN:
- result = reprocessMarkdown(changelogData);
- break;
- }
- changelogData.clear();
- ui->changelogBrowser->setHtml(result);
+ restoreGeometry(QByteArray::fromBase64(
+ APPLICATION->settings()->get("UpdateDialogGeometry").toByteArray()));
}
-void UpdateDialog::changelogFailed(QString reason)
+UpdateDialog::~UpdateDialog()
{
- ui->changelogBrowser->setHtml(tr("<p align=\"center\" <span style=\"font-size:22pt;\">Failed to fetch changelog... Error: %1</span></p>").arg(reason));
+ delete ui;
}
void UpdateDialog::on_btnUpdateLater_clicked()
@@ -197,7 +67,7 @@ void UpdateDialog::on_btnUpdateNow_clicked()
done(UPDATE_NOW);
}
-void UpdateDialog::closeEvent(QCloseEvent* evt)
+void UpdateDialog::closeEvent(QCloseEvent *evt)
{
APPLICATION->settings()->set("UpdateDialogGeometry", saveGeometry().toBase64());
QDialog::closeEvent(evt);
diff --git a/launcher/ui/dialogs/UpdateDialog.h b/launcher/ui/dialogs/UpdateDialog.h
index a4b846242a..ca3011a184 100644
--- a/launcher/ui/dialogs/UpdateDialog.h
+++ b/launcher/ui/dialogs/UpdateDialog.h
@@ -39,7 +39,7 @@
#pragma once
#include <QDialog>
-#include "net/NetJob.h"
+#include "updater/UpdateChecker.h"
namespace Ui
{
@@ -52,39 +52,26 @@ enum UpdateAction
UPDATE_NOW = QDialog::Accepted,
};
-enum ChangelogType
-{
- CHANGELOG_MARKDOWN,
- CHANGELOG_COMMITS
-};
-
class UpdateDialog : public QDialog
{
Q_OBJECT
public:
- explicit UpdateDialog(bool hasUpdate = true, QWidget *parent = 0);
+ /*!
+ * Constructs the update dialog.
+ * \a hasUpdate - true when an update is available (shows "Update now" button).
+ * \a status - update information (version, release notes); ignored when hasUpdate is false.
+ */
+ explicit UpdateDialog(bool hasUpdate, const UpdateAvailableStatus &status = {}, QWidget *parent = nullptr);
~UpdateDialog();
public slots:
void on_btnUpdateNow_clicked();
void on_btnUpdateLater_clicked();
- /// Starts loading the changelog
- void loadChangelog();
-
- /// Slot for when the chengelog loads successfully.
- void changelogLoaded();
-
- /// Slot for when the chengelog fails to load...
- void changelogFailed(QString reason);
-
protected:
void closeEvent(QCloseEvent * ) override;
private:
Ui::UpdateDialog *ui;
- QByteArray changelogData;
- NetJob::Ptr dljob;
- ChangelogType m_changelogType = CHANGELOG_MARKDOWN;
};
diff --git a/launcher/ui/pages/global/MeshMCPage.cpp b/launcher/ui/pages/global/MeshMCPage.cpp
index 159d8cec40..032d631d61 100644
--- a/launcher/ui/pages/global/MeshMCPage.cpp
+++ b/launcher/ui/pages/global/MeshMCPage.cpp
@@ -78,18 +78,12 @@ MeshMCPage::MeshMCPage(QWidget *parent) : QWidget(parent), ui(new Ui::MeshMCPage
m_languageModel = APPLICATION->translations();
loadSettings();
- if(BuildConfig.UPDATER_ENABLED)
+ if(BuildConfig.UPDATER_ENABLED && UpdateChecker::isUpdaterSupported())
{
- QObject::connect(APPLICATION->updateChecker().get(), &UpdateChecker::channelListLoaded, this, &MeshMCPage::refreshUpdateChannelList);
-
- if (APPLICATION->updateChecker()->hasChannels())
- {
- refreshUpdateChannelList();
- }
- else
- {
- APPLICATION->updateChecker()->updateChanList(false);
- }
+ // New updater: hide the legacy channel selector (no channel selection in the new system).
+ ui->updateChannelComboBox->setVisible(false);
+ ui->updateChannelLabel->setVisible(false);
+ ui->updateChannelDescLabel->setVisible(false);
}
else
{
@@ -187,74 +181,17 @@ void MeshMCPage::on_migrateDataFolderMacBtn_clicked()
void MeshMCPage::refreshUpdateChannelList()
{
- // Stop listening for selection changes. It's going to change a lot while we update it and
- // we don't need to update the
- // description label constantly.
- QObject::disconnect(ui->updateChannelComboBox, SIGNAL(currentIndexChanged(int)), this,
- SLOT(updateChannelSelectionChanged(int)));
-
- QList<UpdateChecker::ChannelListEntry> channelList = APPLICATION->updateChecker()->getChannelList();
- ui->updateChannelComboBox->clear();
- int selection = -1;
- for (int i = 0; i < channelList.count(); i++)
- {
- UpdateChecker::ChannelListEntry entry = channelList.at(i);
-
- // When it comes to selection, we'll rely on the indexes of a channel entry being the
- // same in the
- // combo box as it is in the update checker's channel list.
- // This probably isn't very safe, but the channel list doesn't change often enough (or
- // at all) for
- // this to be a big deal. Hope it doesn't break...
- ui->updateChannelComboBox->addItem(entry.name);
-
- // If the update channel we just added was the selected one, set the current index in
- // the combo box to it.
- if (entry.id == m_currentUpdateChannel)
- {
- qDebug() << "Selected index" << i << "channel id" << m_currentUpdateChannel;
- selection = i;
- }
- }
-
- ui->updateChannelComboBox->setCurrentIndex(selection);
-
- // Start listening for selection changes again and update the description label.
- QObject::connect(ui->updateChannelComboBox, SIGNAL(currentIndexChanged(int)), this,
- SLOT(updateChannelSelectionChanged(int)));
- refreshUpdateChannelDesc();
-
- // Now that we've updated the channel list, we can enable the combo box.
- // It starts off disabled so that if the channel list hasn't been loaded, it will be
- // disabled.
- ui->updateChannelComboBox->setEnabled(true);
+ // No-op: the new updater does not use named channels.
}
-void MeshMCPage::updateChannelSelectionChanged(int index)
+void MeshMCPage::updateChannelSelectionChanged(int)
{
- refreshUpdateChannelDesc();
+ // No-op.
}
void MeshMCPage::refreshUpdateChannelDesc()
{
- // Get the channel list.
- QList<UpdateChecker::ChannelListEntry> channelList = APPLICATION->updateChecker()->getChannelList();
- int selectedIndex = ui->updateChannelComboBox->currentIndex();
- if (selectedIndex < 0)
- {
- return;
- }
- if (selectedIndex < channelList.count())
- {
- // Find the channel list entry with the given index.
- UpdateChecker::ChannelListEntry selected = channelList.at(selectedIndex);
-
- // Set the description text.
- ui->updateChannelDescLabel->setText(selected.description);
-
- // Set the currently selected channel ID.
- m_currentUpdateChannel = selected.id;
- }
+ // No-op.
}
void MeshMCPage::applySettings()
@@ -268,7 +205,7 @@ void MeshMCPage::applySettings()
// Updates
s->set("AutoUpdate", ui->autoUpdateCheckBox->isChecked());
- s->set("UpdateChannel", m_currentUpdateChannel);
+ // (UpdateChannel setting removed - the new updater always checks the stable feed)
// Console settings
s->set("ShowConsole", ui->showConsoleCheck->isChecked());
@@ -309,7 +246,7 @@ void MeshMCPage::loadSettings()
auto s = APPLICATION->settings();
// Updates
ui->autoUpdateCheckBox->setChecked(s->get("AutoUpdate").toBool());
- m_currentUpdateChannel = s->get("UpdateChannel").toString();
+ // (no channel to read in the new updater system)
// Console settings
ui->showConsoleCheck->setChecked(s->get("ShowConsole").toBool());
diff --git a/launcher/ui/pages/global/MeshMCPage.h b/launcher/ui/pages/global/MeshMCPage.h
index c35a838385..255b200d1c 100644
--- a/launcher/ui/pages/global/MeshMCPage.h
+++ b/launcher/ui/pages/global/MeshMCPage.h
@@ -112,11 +112,6 @@ slots:
private:
Ui::MeshMCPage *ui;
- /*!
- * Stores the currently selected update channel.
- */
- QString m_currentUpdateChannel;
-
// default format for the font preview...
QTextCharFormat *defaultFormat;
diff --git a/launcher/updater/UpdateChecker.cpp b/launcher/updater/UpdateChecker.cpp
index 00d0d71c83..f218ca29c3 100644
--- a/launcher/updater/UpdateChecker.cpp
+++ b/launcher/updater/UpdateChecker.cpp
@@ -17,277 +17,266 @@
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
- *
- * This file incorporates work covered by the following copyright and
- * permission notice:
- *
- * Copyright 2013-2021 MultiMC Contributors
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
*/
#include "UpdateChecker.h"
-#include <QJsonObject>
-#include <QJsonArray>
-#include <QJsonValue>
#include <QDebug>
-
-#define API_VERSION 0
-#define CHANLIST_FORMAT 0
+#include <QDir>
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <QXmlStreamReader>
#include "BuildConfig.h"
-#include "sys.h"
+#include "FileSystem.h"
+#include "net/Download.h"
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
-UpdateChecker::UpdateChecker(shared_qobject_ptr<QNetworkAccessManager> nam, QString channelUrl, QString currentChannel, int currentBuild)
+bool UpdateChecker::isPortableMode()
{
- m_network = nam;
- m_channelUrl = channelUrl;
- m_currentChannel = currentChannel;
- m_currentBuild = currentBuild;
+ // portable.txt lives next to the application binary.
+ return QFile::exists(FS::PathCombine(QCoreApplication::applicationDirPath(), "portable.txt"));
}
-QList<UpdateChecker::ChannelListEntry> UpdateChecker::getChannelList() const
+bool UpdateChecker::isAppImage()
{
- return m_channels;
+ return !qEnvironmentVariable("APPIMAGE").isEmpty();
}
-bool UpdateChecker::hasChannels() const
+QString UpdateChecker::currentVersion()
{
- return !m_channels.isEmpty();
+ return QString("%1.%2.%3")
+ .arg(BuildConfig.VERSION_MAJOR)
+ .arg(BuildConfig.VERSION_MINOR)
+ .arg(BuildConfig.VERSION_HOTFIX);
}
-void UpdateChecker::checkForUpdate(QString updateChannel, bool notifyNoUpdate)
+QString UpdateChecker::normalizeVersion(const QString &v)
{
- qDebug() << "Checking for updates.";
+ QString out = v.trimmed();
+ if (out.startsWith('v', Qt::CaseInsensitive))
+ out.remove(0, 1);
+ return out;
+}
- // If the channel list hasn't loaded yet, load it and defer checking for updates until
- // later.
- if (!m_chanListLoaded)
- {
- qDebug() << "Channel list isn't loaded yet. Loading channel list and deferring update check.";
- m_checkUpdateWaiting = true;
- m_deferredUpdateChannel = updateChannel;
- updateChanList(notifyNoUpdate);
- return;
+int UpdateChecker::compareVersions(const QString &v1, const QString &v2)
+{
+ const QStringList parts1 = v1.split('.');
+ const QStringList parts2 = v2.split('.');
+ const int len = std::max(parts1.size(), parts2.size());
+ for (int i = 0; i < len; ++i) {
+ const int a = (i < parts1.size()) ? parts1.at(i).toInt() : 0;
+ const int b = (i < parts2.size()) ? parts2.at(i).toInt() : 0;
+ if (a != b)
+ return a - b;
}
+ return 0;
+}
- if (m_updateChecking)
- {
- qDebug() << "Ignoring update check request. Already checking for updates.";
- return;
- }
+// ---------------------------------------------------------------------------
+// Public
+// ---------------------------------------------------------------------------
- // Find the desired channel within the channel list and get its repo URL. If if cannot be
- // found, error.
- QString stableUrl;
- m_newRepoUrl = "";
- for (ChannelListEntry entry : m_channels)
- {
- qDebug() << "channelEntry = " << entry.id;
- if(entry.id == "stable") {
- stableUrl = entry.url;
- }
- if (entry.id == updateChannel) {
- m_newRepoUrl = entry.url;
- qDebug() << "is intended update channel: " << entry.id;
- }
- if (entry.id == m_currentChannel) {
- m_currentRepoUrl = entry.url;
- qDebug() << "is current update channel: " << entry.id;
- }
- }
+UpdateChecker::UpdateChecker(shared_qobject_ptr<QNetworkAccessManager> nam, QObject *parent)
+ : QObject(parent), m_network(nam)
+{
+}
- qDebug() << "m_repoUrl = " << m_newRepoUrl;
+bool UpdateChecker::isUpdaterSupported()
+{
+ if (!BuildConfig.UPDATER_ENABLED)
+ return false;
+
+#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD)
+ // On Linux/BSD: disable unless this is a portable install and not an AppImage.
+ if (isAppImage())
+ return false;
+ if (!isPortableMode())
+ return false;
+#endif
+
+ return true;
+}
- if (m_newRepoUrl.isEmpty()) {
- qWarning() << "m_repoUrl was empty. defaulting to 'stable': " << stableUrl;
- m_newRepoUrl = stableUrl;
+void UpdateChecker::checkForUpdate(bool notifyNoUpdate)
+{
+ if (!isUpdaterSupported()) {
+ qDebug() << "UpdateChecker: updater not supported on this platform/mode. Skipping.";
+ return;
}
- // If nothing applies, error
- if (m_newRepoUrl.isEmpty())
- {
- qCritical() << "failed to select any update repository for: " << updateChannel;
- emit updateCheckFailed();
+ if (m_checking) {
+ qDebug() << "UpdateChecker: check already in progress, ignoring.";
return;
}
- m_updateChecking = true;
+ qDebug() << "UpdateChecker: starting dual-source update check.";
+ m_checking = true;
+ m_feedData.clear();
+ m_githubData.clear();
- QUrl indexUrl = QUrl(m_newRepoUrl).resolved(QUrl("index.json"));
+ m_checkJob.reset(new NetJob("Update Check", m_network));
+ m_checkJob->addNetAction(Net::Download::makeByteArray(QUrl(BuildConfig.UPDATER_FEED_URL), &m_feedData));
+ m_checkJob->addNetAction(Net::Download::makeByteArray(QUrl(BuildConfig.UPDATER_GITHUB_API_URL), &m_githubData));
- indexJob = new NetJob("GoUpdate Repository Index", m_network);
- indexJob->addNetAction(Net::Download::makeByteArray(indexUrl, &indexData));
- connect(indexJob.get(), &NetJob::succeeded, [this, notifyNoUpdate](){ updateCheckFinished(notifyNoUpdate); });
- connect(indexJob.get(), &NetJob::failed, this, &UpdateChecker::updateCheckFailed);
- indexJob->start();
-}
+ connect(m_checkJob.get(), &NetJob::succeeded,
+ [this, notifyNoUpdate]() { onDownloadsFinished(notifyNoUpdate); });
+ connect(m_checkJob.get(), &NetJob::failed,
+ this, &UpdateChecker::onDownloadsFailed);
-void UpdateChecker::updateCheckFinished(bool notifyNoUpdate)
-{
- qDebug() << "Finished downloading repo index. Checking for new versions.";
+ m_checkJob->start();
+}
- QJsonParseError jsonError;
- indexJob.reset();
+// ---------------------------------------------------------------------------
+// Private slots
+// ---------------------------------------------------------------------------
- QJsonDocument jsonDoc = QJsonDocument::fromJson(indexData, &jsonError);
- indexData.clear();
- if (jsonError.error != QJsonParseError::NoError || !jsonDoc.isObject())
- {
- qCritical() << "Failed to parse GoUpdate repository index. JSON error"
- << jsonError.errorString() << "at offset" << jsonError.offset;
- m_updateChecking = false;
- return;
- }
+void UpdateChecker::onDownloadsFinished(bool notifyNoUpdate)
+{
+ m_checking = false;
+ m_checkJob.reset();
- QJsonObject object = jsonDoc.object();
+ // ---- Parse the RSS feed -----------------------------------------------
+ // We look for the first <item> whose <projt:channel> == "stable".
+ // From that item we read <projt:version> and the <projt:asset> whose
+ // name attribute contains BuildConfig.BUILD_ARTIFACT.
- bool success = false;
- int apiVersion = object.value("ApiVersion").toVariant().toInt(&success);
- if (apiVersion != API_VERSION || !success)
- {
- qCritical() << "Failed to check for updates. API version mismatch. We're using"
- << API_VERSION << "server has" << apiVersion;
- m_updateChecking = false;
- return;
- }
+ QString feedVersion;
+ QString downloadUrl;
+ QString releaseNotes;
- qDebug() << "Processing repository version list.";
- QJsonObject newestVersion;
- QJsonArray versions = object.value("Versions").toArray();
- for (QJsonValue versionVal : versions)
{
- QJsonObject version = versionVal.toObject();
- if (newestVersion.value("Id").toVariant().toInt() <
- version.value("Id").toVariant().toInt())
- {
- newestVersion = version;
+ QXmlStreamReader xml(m_feedData);
+ m_feedData.clear();
+
+ bool insideItem = false;
+ bool isStable = false;
+ QString itemVersion;
+ QString itemUrl;
+ QString itemNotes;
+
+ // We iterate forward and take the FIRST stable item we encounter
+ // (the feed lists newest first).
+ while (!xml.atEnd() && !xml.hasError()) {
+ xml.readNext();
+
+ if (xml.isStartElement()) {
+ const QStringView name = xml.name();
+
+ if (name == u"item") {
+ insideItem = true;
+ isStable = false;
+ itemVersion.clear();
+ itemUrl.clear();
+ itemNotes.clear();
+ } else if (insideItem) {
+ if (xml.namespaceUri() == u"https://projecttick.org/ns/projt-launcher/feed") {
+ if (name == u"version") {
+ itemVersion = xml.readElementText().trimmed();
+ } else if (name == u"channel") {
+ isStable = (xml.readElementText().trimmed() == "stable");
+ } else if (name == u"asset") {
+ const QString assetName = xml.attributes().value("name").toString();
+ const QString assetUrl = xml.attributes().value("url").toString();
+ if (!BuildConfig.BUILD_ARTIFACT.isEmpty()
+ && assetName.contains(BuildConfig.BUILD_ARTIFACT, Qt::CaseInsensitive)) {
+ itemUrl = assetUrl;
+ }
+ }
+ } else if (name == u"description" && xml.namespaceUri().isEmpty()) {
+ itemNotes = xml.readElementText(QXmlStreamReader::IncludeChildElements).trimmed();
+ }
+ }
+ } else if (xml.isEndElement() && xml.name() == u"item" && insideItem) {
+ insideItem = false;
+ if (isStable && !itemVersion.isEmpty()) {
+ // First stable item wins.
+ feedVersion = normalizeVersion(itemVersion);
+ downloadUrl = itemUrl;
+ releaseNotes = itemNotes;
+ break;
+ }
+ }
}
- }
- // We've got the version with the greatest ID number. Now compare it to our current build
- // number and update if they're different.
- int newBuildNumber = newestVersion.value("Id").toVariant().toInt();
- if (newBuildNumber != m_currentBuild)
- {
- qDebug() << "Found newer version with ID" << newBuildNumber;
- // Update!
- GoUpdate::Status updateStatus;
- updateStatus.updateAvailable = true;
- updateStatus.currentVersionId = m_currentBuild;
- updateStatus.currentRepoUrl = m_currentRepoUrl;
- updateStatus.newVersionId = newBuildNumber;
- updateStatus.newRepoUrl = m_newRepoUrl;
- emit updateAvailable(updateStatus);
- }
- else if (notifyNoUpdate)
- {
- emit noUpdateFound();
+ if (xml.hasError()) {
+ emit checkFailed(tr("Failed to parse update feed: %1").arg(xml.errorString()));
+ return;
+ }
}
- m_updateChecking = false;
-}
-
-void UpdateChecker::updateCheckFailed()
-{
- qCritical() << "Update check failed for reasons unknown.";
-}
-
-void UpdateChecker::updateChanList(bool notifyNoUpdate)
-{
- qDebug() << "Loading the channel list.";
- if (m_chanListLoading)
- {
- qDebug() << "Ignoring channel list update request. Already grabbing channel list.";
+ if (feedVersion.isEmpty()) {
+ emit checkFailed(tr("No stable release entry found in the update feed."));
return;
}
- m_chanListLoading = true;
- chanListJob = new NetJob("Update System Channel List", m_network);
- chanListJob->addNetAction(Net::Download::makeByteArray(QUrl(m_channelUrl), &chanlistData));
- connect(chanListJob.get(), &NetJob::succeeded, [this, notifyNoUpdate]() { chanListDownloadFinished(notifyNoUpdate); });
- connect(chanListJob.get(), &NetJob::failed, this, &UpdateChecker::chanListDownloadFailed);
- chanListJob->start();
-}
-
-void UpdateChecker::chanListDownloadFinished(bool notifyNoUpdate)
-{
- chanListJob.reset();
-
- QJsonParseError jsonError;
- QJsonDocument jsonDoc = QJsonDocument::fromJson(chanlistData, &jsonError);
- chanlistData.clear();
- if (jsonError.error != QJsonParseError::NoError)
- {
- // TODO: Report errors to the user.
- qCritical() << "Failed to parse channel list JSON:" << jsonError.errorString() << "at" << jsonError.offset;
- m_chanListLoading = false;
- return;
+ if (downloadUrl.isEmpty()) {
+ qWarning() << "UpdateChecker: feed has version" << feedVersion
+ << "but no asset matching BUILD_ARTIFACT '" << BuildConfig.BUILD_ARTIFACT << "'";
+ // We can still report an update even without a direct URL —
+ // the UpdateDialog will inform the user.
}
- QJsonObject object = jsonDoc.object();
+ // ---- Parse the GitHub releases JSON -----------------------------------
+ // Expect the GitHub REST API format: { "tag_name": "vX.Y.Z", ... }
- bool success = false;
- int formatVersion = object.value("format_version").toVariant().toInt(&success);
- if (formatVersion != CHANLIST_FORMAT || !success)
+ QString githubVersion;
{
- qCritical()
- << "Failed to check for updates. Channel list format version mismatch. We're using"
- << CHANLIST_FORMAT << "server has" << formatVersion;
- m_chanListLoading = false;
- return;
- }
+ QJsonParseError jsonError;
+ const QJsonDocument doc = QJsonDocument::fromJson(m_githubData, &jsonError);
+ m_githubData.clear();
- // Load channels into a temporary array.
- QList<ChannelListEntry> loadedChannels;
- QJsonArray channelArray = object.value("channels").toArray();
- for (QJsonValue chanVal : channelArray)
- {
- QJsonObject channelObj = chanVal.toObject();
- ChannelListEntry entry {
- channelObj.value("id").toVariant().toString(),
- channelObj.value("name").toVariant().toString(),
- channelObj.value("description").toVariant().toString(),
- channelObj.value("url").toVariant().toString()
- };
- if (entry.id.isEmpty() || entry.name.isEmpty() || entry.url.isEmpty())
- {
- qCritical() << "Channel list entry with empty ID, name, or URL. Skipping.";
- continue;
+ if (jsonError.error != QJsonParseError::NoError || !doc.isObject()) {
+ emit checkFailed(tr("Failed to parse GitHub releases response: %1").arg(jsonError.errorString()));
+ return;
+ }
+
+ const QString tag = doc.object().value("tag_name").toString().trimmed();
+ if (tag.isEmpty()) {
+ emit checkFailed(tr("GitHub releases response contained no tag_name field."));
+ return;
}
- loadedChannels.append(entry);
+ githubVersion = normalizeVersion(tag);
}
- // Swap the channel list we just loaded into the object's channel list.
- m_channels.swap(loadedChannels);
+ qDebug() << "UpdateChecker: feed version =" << feedVersion
+ << "| github version =" << githubVersion
+ << "| current =" << currentVersion();
- m_chanListLoading = false;
- m_chanListLoaded = true;
- qDebug() << "Successfully loaded UpdateChecker channel list.";
+ // ---- Cross-check both sources -----------------------------------------
+ if (feedVersion != githubVersion) {
+ qDebug() << "UpdateChecker: feed and GitHub disagree on version — no update reported.";
+ if (notifyNoUpdate)
+ emit noUpdateFound();
+ return;
+ }
- // If we're waiting to check for updates, do that now.
- if (m_checkUpdateWaiting) {
- checkForUpdate(m_deferredUpdateChannel, notifyNoUpdate);
+ // ---- Compare against the running version ------------------------------
+ if (compareVersions(feedVersion, currentVersion()) <= 0) {
+ qDebug() << "UpdateChecker: already up to date.";
+ if (notifyNoUpdate)
+ emit noUpdateFound();
+ return;
}
- emit channelListLoaded();
+ qDebug() << "UpdateChecker: update available:" << feedVersion;
+ UpdateAvailableStatus status;
+ status.version = feedVersion;
+ status.downloadUrl = downloadUrl;
+ status.releaseNotes = releaseNotes;
+ emit updateAvailable(status);
}
-void UpdateChecker::chanListDownloadFailed(QString reason)
+void UpdateChecker::onDownloadsFailed(QString reason)
{
- m_chanListLoading = false;
- qCritical() << QString("Failed to download channel list: %1").arg(reason);
- emit channelListLoaded();
+ m_checking = false;
+ m_checkJob.reset();
+ m_feedData.clear();
+ m_githubData.clear();
+ qCritical() << "UpdateChecker: download failed:" << reason;
+ emit checkFailed(reason);
}
diff --git a/launcher/updater/UpdateChecker.h b/launcher/updater/UpdateChecker.h
index 19b020d262..d253d2fbbc 100644
--- a/launcher/updater/UpdateChecker.h
+++ b/launcher/updater/UpdateChecker.h
@@ -17,128 +17,96 @@
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
- *
- * This file incorporates work covered by the following copyright and
- * permission notice:
- *
- * Copyright 2013-2021 MultiMC Contributors
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
*/
#pragma once
+#include <QObject>
+#include <QString>
#include "net/NetJob.h"
-#include "GoUpdate.h"
+/*!
+ * Carries all information about an available update that is needed by the
+ * UpdateController and the UpdateDialog.
+ */
+struct UpdateAvailableStatus
+{
+ /// Normalized version string, e.g. "7.1.0"
+ QString version;
+ /// Direct download URL for this platform's artifact (from the feed).
+ QString downloadUrl;
+ /// HTML release notes extracted from the feed's <description> element.
+ QString releaseNotes;
+};
+Q_DECLARE_METATYPE(UpdateAvailableStatus)
+
+/*!
+ * UpdateChecker performs the two-source update check used by MeshMC.
+ *
+ * Algorithm:
+ * 1. Download the RSS feed at BuildConfig.UPDATER_FEED_URL in parallel with
+ * the GitHub releases JSON at BuildConfig.UPDATER_GITHUB_API_URL.
+ * 2. Parse the latest stable <item> from the feed → extract <projt:version>
+ * and the <projt:asset> URL whose name contains BuildConfig.BUILD_ARTIFACT.
+ * 3. Parse the GitHub JSON → strip leading "v" from tag_name.
+ * 4. Both versions must match and be greater than the running version;
+ * otherwise the check is considered a no-update or failure.
+ *
+ * Platform / mode gating (runtime):
+ * - Linux + APPIMAGE env variable set → updater disabled.
+ * - Linux + no portable.txt in app dir → updater disabled.
+ * - Windows / macOS / Linux-portable → updater active.
+ */
class UpdateChecker : public QObject
{
Q_OBJECT
public:
- UpdateChecker(shared_qobject_ptr<QNetworkAccessManager> nam, QString channelUrl, QString currentChannel, int currentBuild);
- void checkForUpdate(QString updateChannel, bool notifyNoUpdate);
-
- /*!
- * Causes the update checker to download the channel list from the URL specified in config.h (generated by CMake).
- * If this isn't called before checkForUpdate(), it will automatically be called.
- */
- void updateChanList(bool notifyNoUpdate);
-
- /*!
- * An entry in the channel list.
- */
- struct ChannelListEntry
- {
- QString id;
- QString name;
- QString description;
- QString url;
- };
+ explicit UpdateChecker(shared_qobject_ptr<QNetworkAccessManager> nam, QObject* parent = nullptr);
/*!
- * Returns a the current channel list.
- * If the channel list hasn't been loaded, this list will be empty.
+ * Starts an asynchronous two-source update check.
+ * If \a notifyNoUpdate is true, noUpdateFound() is emitted when the running
+ * version is already the latest; otherwise the signal is suppressed.
+ * Repeated calls while a check is in progress are silently ignored.
*/
- QList<ChannelListEntry> getChannelList() const;
+ void checkForUpdate(bool notifyNoUpdate);
/*!
- * Returns false if the channel list is empty.
+ * Returns true if the updater may run on this platform / installation mode.
+ * Evaluated at runtime: AppImage detection, portable.txt presence, OS.
+ * Also checks that BuildConfig.UPDATER_ENABLED is true.
*/
- bool hasChannels() const;
+ static bool isUpdaterSupported();
signals:
- //! Signal emitted when an update is available. Passes the URL for the repo and the ID and name for the version.
- void updateAvailable(GoUpdate::Status status);
-
- //! Signal emitted when the channel list finishes loading or fails to load.
- void channelListLoaded();
+ //! Emitted when both sources agree that a newer version is available.
+ void updateAvailable(UpdateAvailableStatus status);
+ //! Emitted when the check completes but the running version is current.
void noUpdateFound();
-private slots:
- void updateCheckFinished(bool notifyNoUpdate);
- void updateCheckFailed();
+ //! Emitted on any network or parse failure.
+ void checkFailed(QString reason);
- void chanListDownloadFinished(bool notifyNoUpdate);
- void chanListDownloadFailed(QString reason);
+private slots:
+ void onDownloadsFinished(bool notifyNoUpdate);
+ void onDownloadsFailed(QString reason);
private:
- friend class UpdateCheckerTest;
+ static bool isPortableMode();
+ static bool isAppImage();
+ /// Returns current version as "MAJOR.MINOR.HOTFIX".
+ static QString currentVersion();
+ /// Strips a leading 'v' and returns a clean X.Y.Z string.
+ static QString normalizeVersion(const QString &v);
+ /// Compares two "X.Y.Z" strings numerically. Returns >0 if v1 > v2.
+ static int compareVersions(const QString &v1, const QString &v2);
shared_qobject_ptr<QNetworkAccessManager> m_network;
-
- NetJob::Ptr indexJob;
- QByteArray indexData;
- NetJob::Ptr chanListJob;
- QByteArray chanlistData;
-
- QString m_channelUrl;
-
- QList<ChannelListEntry> m_channels;
-
- /*!
- * True while the system is checking for updates.
- * If checkForUpdate is called while this is true, it will be ignored.
- */
- bool m_updateChecking = false;
-
- /*!
- * True if the channel list has loaded.
- * If this is false, trying to check for updates will call updateChanList first.
- */
- bool m_chanListLoaded = false;
-
- /*!
- * Set to true while the channel list is currently loading.
- */
- bool m_chanListLoading = false;
-
- /*!
- * Set to true when checkForUpdate is called while the channel list isn't loaded.
- * When the channel list finishes loading, if this is true, the update checker will check for updates.
- */
- bool m_checkUpdateWaiting = false;
-
- /*!
- * if m_checkUpdateWaiting, this is the last used update channel
- */
- QString m_deferredUpdateChannel;
-
- int m_currentBuild = -1;
- QString m_currentChannel;
- QString m_currentRepoUrl;
-
- QString m_newRepoUrl;
+ NetJob::Ptr m_checkJob;
+ QByteArray m_feedData;
+ QByteArray m_githubData;
+ bool m_checking = false;
};
diff --git a/updater/CMakeLists.txt b/updater/CMakeLists.txt
new file mode 100644
index 0000000000..b65709fa90
--- /dev/null
+++ b/updater/CMakeLists.txt
@@ -0,0 +1,40 @@
+# SPDX-FileCopyrightText: 2026 Project Tick
+# SPDX-FileContributor: Project Tick
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+cmake_minimum_required(VERSION 3.28)
+
+project(meshmc-updater)
+
+set(CMAKE_CXX_STANDARD 23)
+set(CMAKE_CXX_STANDARD_REQUIRED ON)
+set(CMAKE_AUTOMOC ON)
+set(CMAKE_INCLUDE_CURRENT_DIR ON)
+
+find_package(Qt6 REQUIRED COMPONENTS Core Network)
+find_package(LibArchive REQUIRED)
+
+# On Windows: use Win32 subsystem so no console window appears.
+if(WIN32)
+ set(UPDATER_WIN32_FLAG WIN32)
+else()
+ set(UPDATER_WIN32_FLAG "")
+endif()
+
+add_executable(meshmc-updater ${UPDATER_WIN32_FLAG}
+ main.cpp
+ Installer.h
+ Installer.cpp
+)
+
+target_link_libraries(meshmc-updater
+ PRIVATE
+ Qt6::Core
+ Qt6::Network
+ LibArchive::LibArchive
+)
+
+# Install alongside the main binary so UpdateController can find it.
+install(TARGETS meshmc-updater
+ RUNTIME DESTINATION "${BINARY_DEST_DIR}"
+)
diff --git a/updater/Installer.cpp b/updater/Installer.cpp
new file mode 100644
index 0000000000..4f4b249dad
--- /dev/null
+++ b/updater/Installer.cpp
@@ -0,0 +1,346 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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 3 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, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "Installer.h"
+
+#include <QCoreApplication>
+#include <QDebug>
+#include <QDir>
+#include <QDirIterator>
+#include <QFileInfo>
+#include <QNetworkReply>
+#include <QNetworkRequest>
+#include <QProcess>
+#include <QSaveFile>
+#include <QTemporaryDir>
+#include <QThread>
+
+// LibArchive is used for zip and tar.gz extraction.
+#include <archive.h>
+#include <archive_entry.h>
+
+// ---------------------------------------------------------------------------
+// Construction
+// ---------------------------------------------------------------------------
+
+Installer::Installer(QObject *parent) : QObject(parent)
+{
+ m_nam = new QNetworkAccessManager(this);
+}
+
+// ---------------------------------------------------------------------------
+// Public
+// ---------------------------------------------------------------------------
+
+void Installer::start()
+{
+ emit progressMessage(tr("Downloading update from %1 …").arg(m_url));
+ qDebug() << "Installer: downloading" << m_url;
+
+ // Determine a temp file name from the URL.
+ const QFileInfo urlInfo(QUrl(m_url).path());
+ m_tempFile = QDir::tempPath() + "/meshmc-update-" + urlInfo.fileName();
+
+ QNetworkRequest req{QUrl{m_url}};
+ req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
+ QNetworkReply *reply = m_nam->get(req);
+ connect(reply, &QNetworkReply::downloadProgress, this, &Installer::onDownloadProgress);
+ connect(reply, &QNetworkReply::finished, this, &Installer::onDownloadFinished);
+}
+
+// ---------------------------------------------------------------------------
+// Private slots
+// ---------------------------------------------------------------------------
+
+void Installer::onDownloadProgress(qint64 received, qint64 total)
+{
+ if (total > 0) {
+ const int pct = static_cast<int>((received * 100) / total);
+ emit progressMessage(tr("Downloading … %1%").arg(pct));
+ }
+}
+
+void Installer::onDownloadFinished()
+{
+ auto *reply = qobject_cast<QNetworkReply *>(sender());
+ if (!reply) return;
+ reply->deleteLater();
+
+ if (reply->error() != QNetworkReply::NoError) {
+ emit finished(false, tr("Download failed: %1").arg(reply->errorString()));
+ return;
+ }
+
+ // Write to temp file.
+ QSaveFile file(m_tempFile);
+ if (!file.open(QIODevice::WriteOnly)) {
+ emit finished(false, tr("Cannot write temp file: %1").arg(file.errorString()));
+ return;
+ }
+ file.write(reply->readAll());
+ if (!file.commit()) {
+ emit finished(false, tr("Cannot commit temp file: %1").arg(file.errorString()));
+ return;
+ }
+
+ emit progressMessage(tr("Download complete. Installing …"));
+ qDebug() << "Installer: saved to" << m_tempFile;
+
+ const bool ok = installArchive(m_tempFile);
+
+ if (!ok) {
+ QFile::remove(m_tempFile);
+ return; // finished() already emitted inside installArchive / installExe
+ }
+
+ QFile::remove(m_tempFile);
+ relaunch();
+ emit finished(true, QString());
+}
+
+// ---------------------------------------------------------------------------
+// Private helpers
+// ---------------------------------------------------------------------------
+
+bool Installer::installArchive(const QString &filePath)
+{
+ const QString lower = filePath.toLower();
+
+ if (lower.endsWith(".exe")) {
+ return installExe(filePath);
+ }
+
+ if (lower.endsWith(".zip") || lower.endsWith(".tar.gz") || lower.endsWith(".tgz")) {
+ // Extract into a temp directory, then copy over root.
+ QTemporaryDir tempDir;
+ if (!tempDir.isValid()) {
+ emit finished(false, tr("Cannot create temporary directory for extraction."));
+ return false;
+ }
+
+ emit progressMessage(tr("Extracting archive …"));
+
+ bool extractOk = false;
+ if (lower.endsWith(".zip")) {
+ extractOk = extractZip(filePath, tempDir.path());
+ } else {
+ extractOk = extractTarGz(filePath, tempDir.path());
+ }
+
+ if (!extractOk) {
+ return false; // finished() already emitted
+ }
+
+ // Copy all extracted files into the root, skipping the updater binary itself.
+ emit progressMessage(tr("Installing files …"));
+
+ const QString updaterName =
+#ifdef Q_OS_WIN
+ "meshmc-updater.exe";
+#else
+ "meshmc-updater";
+#endif
+
+ QDir src(tempDir.path());
+ // If the archive has a single top-level directory, descend into it.
+ const QStringList topEntries = src.entryList(QDir::Dirs | QDir::NoDotAndDotDot);
+ if (topEntries.size() == 1 &&
+ src.entryList(QDir::Files | QDir::NoDotAndDotDot).isEmpty()) {
+ src.cd(topEntries.first());
+ }
+
+ const QDir dest(m_root);
+ QDirIterator it(src.absolutePath(), QDir::Files | QDir::NoDotAndDotDot, QDirIterator::Subdirectories);
+ while (it.hasNext()) {
+ it.next();
+ const QFileInfo &fi = it.fileInfo();
+
+ const QString relPath = src.relativeFilePath(fi.absoluteFilePath());
+ // Don't replace ourselves while running.
+ if (relPath == updaterName)
+ continue;
+
+ const QString destPath = dest.filePath(relPath);
+ QFileInfo destInfo(destPath);
+ if (!QDir().mkpath(destInfo.absolutePath())) {
+ emit finished(false, tr("Cannot create directory: %1").arg(destInfo.absolutePath()));
+ return false;
+ }
+ if (destInfo.exists())
+ QFile::remove(destPath);
+
+ if (!QFile::copy(fi.absoluteFilePath(), destPath)) {
+ emit finished(false, tr("Cannot copy %1 to %2.").arg(relPath, destPath));
+ return false;
+ }
+
+ // Preserve executable bit on Unix.
+#ifndef Q_OS_WIN
+ if (fi.isExecutable()) {
+ QFile(destPath).setPermissions(
+ QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner |
+ QFile::ReadGroup | QFile::ExeGroup |
+ QFile::ReadOther | QFile::ExeOther);
+ }
+#endif
+ }
+
+ return true;
+ }
+
+ emit finished(false, tr("Unknown archive format: %1").arg(QFileInfo(filePath).suffix()));
+ return false;
+}
+
+bool Installer::installExe(const QString &filePath)
+{
+ // For Windows NSIS / Inno Setup installers, just run the installer directly.
+ // It handles file replacement and relaunching.
+#ifdef Q_OS_WIN
+ emit progressMessage(tr("Launching installer …"));
+ const bool ok = QProcess::startDetached(filePath, QStringList());
+ if (!ok) {
+ emit finished(false, tr("Failed to launch installer: %1").arg(filePath));
+ return false;
+ }
+ // The installer will take care of everything; exit after launching.
+ QCoreApplication::quit();
+ return true;
+#else
+ Q_UNUSED(filePath)
+ emit finished(false, tr(".exe installers are only supported on Windows."));
+ return false;
+#endif
+}
+
+bool Installer::extractZip(const QString &zipPath, const QString &destDir)
+{
+ archive *a = archive_read_new();
+ archive_read_support_format_zip(a);
+ archive_read_support_filter_all(a);
+
+ if (archive_read_open_filename(a, zipPath.toLocal8Bit().constData(), 10240) != ARCHIVE_OK) {
+ const QString err = QString::fromLocal8Bit(archive_error_string(a));
+ archive_read_free(a);
+ emit finished(false, tr("Cannot open zip archive: %1").arg(err));
+ return false;
+ }
+
+ archive *ext = archive_write_disk_new();
+ archive_write_disk_set_options(ext,
+ ARCHIVE_EXTRACT_TIME | ARCHIVE_EXTRACT_PERM | ARCHIVE_EXTRACT_ACL | ARCHIVE_EXTRACT_FFLAGS);
+ archive_write_disk_set_standard_lookup(ext);
+
+ archive_entry *entry = nullptr;
+ bool ok = true;
+ while (archive_read_next_header(a, &entry) == ARCHIVE_OK) {
+ const QString entryPath = destDir + "/" + QString::fromLocal8Bit(archive_entry_pathname(entry));
+ archive_entry_set_pathname(entry, entryPath.toLocal8Bit().constData());
+
+ if (archive_write_header(ext, entry) != ARCHIVE_OK) {
+ ok = false;
+ break;
+ }
+ if (archive_entry_size(entry) > 0) {
+ const void *buf;
+ size_t size;
+ la_int64_t offset;
+ while (archive_read_data_block(a, &buf, &size, &offset) == ARCHIVE_OK) {
+ archive_write_data_block(ext, buf, size, offset);
+ }
+ }
+ archive_write_finish_entry(ext);
+ }
+
+ archive_read_close(a);
+ archive_read_free(a);
+ archive_write_close(ext);
+ archive_write_free(ext);
+
+ if (!ok) {
+ emit finished(false, tr("Failed to extract zip archive."));
+ return false;
+ }
+ return true;
+}
+
+bool Installer::extractTarGz(const QString &tarPath, const QString &destDir)
+{
+ archive *a = archive_read_new();
+ archive_read_support_format_tar(a);
+ archive_read_support_format_gnutar(a);
+ archive_read_support_filter_gzip(a);
+ archive_read_support_filter_bzip2(a);
+ archive_read_support_filter_xz(a);
+
+ if (archive_read_open_filename(a, tarPath.toLocal8Bit().constData(), 10240) != ARCHIVE_OK) {
+ const QString err = QString::fromLocal8Bit(archive_error_string(a));
+ archive_read_free(a);
+ emit finished(false, tr("Cannot open tar archive: %1").arg(err));
+ return false;
+ }
+
+ archive *ext = archive_write_disk_new();
+ archive_write_disk_set_options(ext,
+ ARCHIVE_EXTRACT_TIME | ARCHIVE_EXTRACT_PERM | ARCHIVE_EXTRACT_ACL | ARCHIVE_EXTRACT_FFLAGS);
+ archive_write_disk_set_standard_lookup(ext);
+
+ archive_entry *entry = nullptr;
+ bool ok = true;
+ while (archive_read_next_header(a, &entry) == ARCHIVE_OK) {
+ const QString entryPath = destDir + "/" + QString::fromLocal8Bit(archive_entry_pathname(entry));
+ archive_entry_set_pathname(entry, entryPath.toLocal8Bit().constData());
+
+ if (archive_write_header(ext, entry) != ARCHIVE_OK) {
+ ok = false;
+ break;
+ }
+ if (archive_entry_size(entry) > 0) {
+ const void *buf;
+ size_t size;
+ la_int64_t offset;
+ while (archive_read_data_block(a, &buf, &size, &offset) == ARCHIVE_OK) {
+ archive_write_data_block(ext, buf, size, offset);
+ }
+ }
+ archive_write_finish_entry(ext);
+ }
+
+ archive_read_close(a);
+ archive_read_free(a);
+ archive_write_close(ext);
+ archive_write_free(ext);
+
+ if (!ok) {
+ emit finished(false, tr("Failed to extract tar archive."));
+ return false;
+ }
+ return true;
+}
+
+void Installer::relaunch()
+{
+ if (m_exec.isEmpty())
+ return;
+
+ qDebug() << "Installer: relaunching" << m_exec;
+ QProcess::startDetached(m_exec, QStringList());
+}
diff --git a/updater/Installer.h b/updater/Installer.h
new file mode 100644
index 0000000000..2f56315a07
--- /dev/null
+++ b/updater/Installer.h
@@ -0,0 +1,78 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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 3 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, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include <QObject>
+#include <QString>
+#include <QNetworkAccessManager>
+
+/*!
+ * Installer downloads an update artifact and installs it.
+ *
+ * Supported artifact types (determined by file extension):
+ * .exe - Windows NSIS/Inno installer: run directly, installer handles everything.
+ * .zip - Portable zip archive: extract over the root directory.
+ * .tar.gz / .tgz - Portable tarball: extract over the root directory.
+ * .dmg - macOS disk image: mount, copy .app bundle, unmount. (planned)
+ *
+ * After a successful archive extraction the application binary given by
+ * \c setRelaunchPath() is started and the updater exits.
+ */
+class Installer : public QObject
+{
+ Q_OBJECT
+
+public:
+ explicit Installer(QObject *parent = nullptr);
+
+ void setDownloadUrl(const QString &url) { m_url = url; }
+ void setRootPath(const QString &root) { m_root = root; }
+ void setRelaunchPath(const QString &exec) { m_exec = exec; }
+
+ /*!
+ * Start the install process asynchronously.
+ * The object emits \c finished() with a success flag when done.
+ */
+ void start();
+
+signals:
+ void progressMessage(const QString &msg);
+ void finished(bool success, const QString &errorMessage);
+
+private slots:
+ void onDownloadProgress(qint64 received, qint64 total);
+ void onDownloadFinished();
+
+private:
+ bool installArchive(const QString &filePath);
+ bool installExe(const QString &filePath);
+ bool extractZip(const QString &zipPath, const QString &destDir);
+ bool extractTarGz(const QString &tarPath, const QString &destDir);
+ void relaunch();
+
+ QString m_url;
+ QString m_root;
+ QString m_exec;
+ QString m_tempFile;
+
+ QNetworkAccessManager *m_nam = nullptr;
+};
diff --git a/updater/main.cpp b/updater/main.cpp
new file mode 100644
index 0000000000..158cbed597
--- /dev/null
+++ b/updater/main.cpp
@@ -0,0 +1,105 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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 3 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, see <https://www.gnu.org/licenses/>.
+ */
+
+/*
+ * meshmc-updater — standalone updater binary for MeshMC.
+ *
+ * Usage:
+ * meshmc-updater --url <download_url>
+ * --root <install_root>
+ * --exec <relaunch_path>
+ *
+ * --url : Full URL of the artifact to download and install.
+ * --root : Installation root directory (where meshmc binary lives).
+ * --exec : Path to the main MeshMC binary to relaunch after update.
+ *
+ * The binary waits a short time after launch to let the main application
+ * exit cleanly before overwriting its files.
+ */
+
+#include <QCoreApplication>
+#include <QCommandLineOption>
+#include <QCommandLineParser>
+#include <QDebug>
+#include <QThread>
+#include <QTimer>
+
+#include "Installer.h"
+
+int main(int argc, char *argv[])
+{
+ QCoreApplication app(argc, argv);
+ app.setApplicationName("meshmc-updater");
+ app.setOrganizationName("Project Tick");
+
+ QCommandLineParser parser;
+ parser.setApplicationDescription("MeshMC Updater");
+ parser.addHelpOption();
+
+ QCommandLineOption urlOpt("url", "URL of the update artifact to download.", "url");
+ QCommandLineOption rootOpt("root", "Installation root directory.", "root");
+ QCommandLineOption execOpt("exec", "Path to relaunch after update.", "exec");
+
+ parser.addOption(urlOpt);
+ parser.addOption(rootOpt);
+ parser.addOption(execOpt);
+ parser.process(app);
+
+ const QString url = parser.value(urlOpt).trimmed();
+ const QString root = parser.value(rootOpt).trimmed();
+ const QString exec = parser.value(execOpt).trimmed();
+
+ if (url.isEmpty() || root.isEmpty()) {
+ fprintf(stderr, "meshmc-updater: --url and --root are required.\n");
+ return 1;
+ }
+
+ qDebug() << "meshmc-updater: url =" << url
+ << "| root =" << root
+ << "| exec =" << exec;
+
+ // Give the main application 2 seconds to exit before we start overwriting files.
+ auto *installer = new Installer(&app);
+ installer->setDownloadUrl(url);
+ installer->setRootPath(root);
+ installer->setRelaunchPath(exec);
+
+ QObject::connect(installer, &Installer::progressMessage,
+ [](const QString &msg) { qDebug() << "updater:" << msg; });
+
+ QObject::connect(installer, &Installer::finished,
+ [](bool success, const QString &errorMsg) {
+ if (!success) {
+ qCritical() << "meshmc-updater: installation failed:" << errorMsg;
+ QCoreApplication::exit(1);
+ } else {
+ qDebug() << "meshmc-updater: update installed successfully.";
+ QCoreApplication::exit(0);
+ }
+ });
+
+ // Delay start to give the parent process time to close.
+ QTimer::singleShot(2000, &app, [installer]() {
+ installer->start();
+ });
+
+ return app.exec();
+}