diff options
Diffstat (limited to 'meshmc/updater')
| -rw-r--r-- | meshmc/updater/CMakeLists.txt | 40 | ||||
| -rw-r--r-- | meshmc/updater/Installer.cpp | 373 | ||||
| -rw-r--r-- | meshmc/updater/Installer.h | 87 | ||||
| -rw-r--r-- | meshmc/updater/main.cpp | 107 |
4 files changed, 607 insertions, 0 deletions
diff --git a/meshmc/updater/CMakeLists.txt b/meshmc/updater/CMakeLists.txt new file mode 100644 index 0000000000..b65709fa90 --- /dev/null +++ b/meshmc/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/meshmc/updater/Installer.cpp b/meshmc/updater/Installer.cpp new file mode 100644 index 0000000000..058b261b9a --- /dev/null +++ b/meshmc/updater/Installer.cpp @@ -0,0 +1,373 @@ +/* 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/meshmc/updater/Installer.h b/meshmc/updater/Installer.h new file mode 100644 index 0000000000..be9fed92e4 --- /dev/null +++ b/meshmc/updater/Installer.h @@ -0,0 +1,87 @@ +/* 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/meshmc/updater/main.cpp b/meshmc/updater/main.cpp new file mode 100644 index 0000000000..6e1bd2d506 --- /dev/null +++ b/meshmc/updater/main.cpp @@ -0,0 +1,107 @@ +/* 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(); +} |
