/* 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 .
*/
#include "Installer.h"
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
// LibArchive is used for zip and tar.gz extraction.
#include
#include
// ---------------------------------------------------------------------------
// 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((received * 100) / total);
emit progressMessage(tr("Downloading … %1%").arg(pct));
}
}
void Installer::onDownloadFinished()
{
auto* reply = qobject_cast(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());
}