diff options
| author | Mehmet Samet Duman <yongdohyun@projecttick.org> | 2026-04-01 21:59:57 +0300 |
|---|---|---|
| committer | Mehmet Samet Duman <yongdohyun@projecttick.org> | 2026-04-01 21:59:57 +0300 |
| commit | 24c39fc544b75dc9c09f69c9cd4c6fba61bf6aac (patch) | |
| tree | 895daa60d9328e7fc964b0db151af7f415defffa | |
| parent | bf8a721e1d388d3320e32ae9ad4881f3245a2878 (diff) | |
| download | Project-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.txt | 11 | ||||
| -rw-r--r-- | buildconfig/BuildConfig.cpp.in | 14 | ||||
| -rw-r--r-- | buildconfig/BuildConfig.h | 8 | ||||
| -rw-r--r-- | launcher/Application.cpp | 19 | ||||
| -rw-r--r-- | launcher/Application.h | 3 | ||||
| -rw-r--r-- | launcher/CMakeLists.txt | 18 | ||||
| -rw-r--r-- | launcher/UpdateController.cpp | 435 | ||||
| -rw-r--r-- | launcher/UpdateController.h | 51 | ||||
| -rw-r--r-- | launcher/ui/MainWindow.cpp | 77 | ||||
| -rw-r--r-- | launcher/ui/MainWindow.h | 9 | ||||
| -rw-r--r-- | launcher/ui/dialogs/UpdateDialog.cpp | 174 | ||||
| -rw-r--r-- | launcher/ui/dialogs/UpdateDialog.h | 27 | ||||
| -rw-r--r-- | launcher/ui/pages/global/MeshMCPage.cpp | 85 | ||||
| -rw-r--r-- | launcher/ui/pages/global/MeshMCPage.h | 5 | ||||
| -rw-r--r-- | launcher/updater/UpdateChecker.cpp | 413 | ||||
| -rw-r--r-- | launcher/updater/UpdateChecker.h | 160 | ||||
| -rw-r--r-- | updater/CMakeLists.txt | 40 | ||||
| -rw-r--r-- | updater/Installer.cpp | 346 | ||||
| -rw-r--r-- | updater/Installer.h | 78 | ||||
| -rw-r--r-- | updater/main.cpp | 105 |
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(); +} |
