summaryrefslogtreecommitdiff
path: root/meshmc/updater
diff options
context:
space:
mode:
Diffstat (limited to 'meshmc/updater')
-rw-r--r--meshmc/updater/CMakeLists.txt40
-rw-r--r--meshmc/updater/Installer.cpp373
-rw-r--r--meshmc/updater/Installer.h87
-rw-r--r--meshmc/updater/main.cpp107
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();
+}