/* 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 "UpdateChecker.h" #include #include #include #include #include #include "BuildConfig.h" #include "FileSystem.h" #include "net/Download.h" // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- bool UpdateChecker::isPortableMode() { // portable.txt lives next to the application binary. return QFile::exists(FS::PathCombine(QCoreApplication::applicationDirPath(), "portable.txt")); } bool UpdateChecker::isAppImage() { return !qEnvironmentVariable("APPIMAGE").isEmpty(); } QString UpdateChecker::currentVersion() { return QString("%1.%2.%3") .arg(BuildConfig.VERSION_MAJOR) .arg(BuildConfig.VERSION_MINOR) .arg(BuildConfig.VERSION_HOTFIX); } QString UpdateChecker::normalizeVersion(const QString& v) { QString out = v.trimmed(); if (out.startsWith('v', Qt::CaseInsensitive)) out.remove(0, 1); return out; } 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; } // --------------------------------------------------------------------------- // Public // --------------------------------------------------------------------------- UpdateChecker::UpdateChecker(shared_qobject_ptr nam, QObject* parent) : QObject(parent), m_network(nam) { } 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; } void UpdateChecker::checkForUpdate(bool notifyNoUpdate) { if (!isUpdaterSupported()) { qDebug() << "UpdateChecker: updater not supported on this " "platform/mode. Skipping."; return; } if (m_checking) { qDebug() << "UpdateChecker: check already in progress, ignoring."; return; } qDebug() << "UpdateChecker: starting dual-source update check."; m_checking = true; m_feedData.clear(); m_githubData.clear(); 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)); connect(m_checkJob.get(), &NetJob::succeeded, [this, notifyNoUpdate]() { onDownloadsFinished(notifyNoUpdate); }); connect(m_checkJob.get(), &NetJob::failed, this, &UpdateChecker::onDownloadsFailed); m_checkJob->start(); } // --------------------------------------------------------------------------- // Private slots // --------------------------------------------------------------------------- void UpdateChecker::onDownloadsFinished(bool notifyNoUpdate) { m_checking = false; m_checkJob.reset(); // ---- Parse the RSS feed ----------------------------------------------- // We look for the first whose == "stable". // From that item we read and the whose // name attribute contains BuildConfig.BUILD_ARTIFACT. QString feedVersion; QString downloadUrl; QString releaseNotes; { 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; } } } if (xml.hasError()) { emit checkFailed( tr("Failed to parse update feed: %1").arg(xml.errorString())); return; } } if (feedVersion.isEmpty()) { emit checkFailed( tr("No stable release entry found in the update feed.")); 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. } // ---- Parse the GitHub releases JSON ----------------------------------- // Expect the GitHub REST API format: { "tag_name": "vX.Y.Z", ... } QString githubVersion; { QJsonParseError jsonError; const QJsonDocument doc = QJsonDocument::fromJson(m_githubData, &jsonError); m_githubData.clear(); 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; } githubVersion = normalizeVersion(tag); } qDebug() << "UpdateChecker: feed version =" << feedVersion << "| github version =" << githubVersion << "| current =" << currentVersion(); // ---- Cross-check both sources ----------------------------------------- if (feedVersion != githubVersion) { qDebug() << "UpdateChecker: feed and GitHub disagree on version — no " "update reported."; if (notifyNoUpdate) emit noUpdateFound(); return; } // ---- Compare against the running version ------------------------------ if (compareVersions(feedVersion, currentVersion()) <= 0) { qDebug() << "UpdateChecker: already up to date."; if (notifyNoUpdate) emit noUpdateFound(); return; } qDebug() << "UpdateChecker: update available:" << feedVersion; UpdateAvailableStatus status; status.version = feedVersion; status.downloadUrl = downloadUrl; status.releaseNotes = releaseNotes; emit updateAvailable(status); } void UpdateChecker::onDownloadsFailed(QString reason) { m_checking = false; m_checkJob.reset(); m_feedData.clear(); m_githubData.clear(); qCritical() << "UpdateChecker: download failed:" << reason; emit checkFailed(reason); }