summaryrefslogtreecommitdiff
path: root/archived/projt-launcher/launcher/updater/projtupdater/ProjTUpdater.cpp
diff options
context:
space:
mode:
Diffstat (limited to 'archived/projt-launcher/launcher/updater/projtupdater/ProjTUpdater.cpp')
-rw-r--r--archived/projt-launcher/launcher/updater/projtupdater/ProjTUpdater.cpp1784
1 files changed, 1784 insertions, 0 deletions
diff --git a/archived/projt-launcher/launcher/updater/projtupdater/ProjTUpdater.cpp b/archived/projt-launcher/launcher/updater/projtupdater/ProjTUpdater.cpp
new file mode 100644
index 0000000000..84f626ba3c
--- /dev/null
+++ b/archived/projt-launcher/launcher/updater/projtupdater/ProjTUpdater.cpp
@@ -0,0 +1,1784 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// SPDX-FileCopyrightText: 2026 Project Tick
+// SPDX-FileContributor: Project Tick Team
+/*
+ * ProjT Launcher - Minecraft Launcher
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ *
+ * === Upstream License Block (Do Not Modify) ==============================
+ *
+ *
+ *
+ *
+ *
+ *
+ * Prism Launcher - Minecraft Launcher
+ * Copyright (C) 2022 Rachel Powers <508861+Ryex@users.noreply.github.com>
+ *
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ *
+ *
+ *
+ * ======================================================================== */
+
+#include "ProjTUpdater.h"
+#include "BuildConfig.h"
+#include "ui/dialogs/ProgressDialog.h"
+
+#include <cstdlib>
+#include <iostream>
+
+#include <QDebug>
+
+#include <QAccessible>
+#include <QCommandLineParser>
+#include <QFileInfo>
+#include <QMessageBox>
+#include <QNetworkProxy>
+#include <QNetworkReply>
+#include <QNetworkRequest>
+#include <QProcess>
+#include <QProgressDialog>
+#include <memory>
+
+#include <sys.h>
+
+#if defined Q_OS_WIN32
+#ifndef WIN32_LEAN_AND_MEAN
+#define WIN32_LEAN_AND_MEAN
+#endif
+#include <windows.h>
+#include "console/WindowsConsole.hpp"
+#endif
+
+#include <filesystem>
+namespace fs = std::filesystem;
+
+#include "DesktopServices.h"
+
+#include "updater/projtupdater/UpdaterDialogs.h"
+
+#include "FileSystem.h"
+#include "Json.h"
+#include "StringUtils.h"
+
+#include "net/Download.h"
+#include "net/RawHeaderProxy.h"
+
+#include "MMCZip.h"
+
+/** output to the log file */
+void appDebugOutput(QtMsgType type, const QMessageLogContext& context, const QString& msg)
+{
+ static std::mutex loggerMutex;
+ const std::lock_guard<std::mutex> lock(loggerMutex); // synchronized, QFile logFile is not thread-safe
+
+ QString out = qFormatLogMessage(type, context, msg);
+ out += QChar::LineFeed;
+
+ ProjTUpdaterApp* app = static_cast<ProjTUpdaterApp*>(QCoreApplication::instance());
+ app->logFile->write(out.toUtf8());
+ app->logFile->flush();
+ if (app->logToConsole)
+ {
+ QTextStream(stderr) << out.toLocal8Bit();
+ fflush(stderr);
+ }
+}
+
+ProjTUpdaterApp::ProjTUpdaterApp(int& argc, char** argv) : QApplication(argc, argv)
+{
+#if defined Q_OS_WIN32
+ // attach the parent console if stdout not already captured
+ if (projt::console::AttachWindowsConsole())
+ {
+ consoleAttached = true;
+ }
+#endif
+ setOrganizationName(BuildConfig.LAUNCHER_NAME);
+ setOrganizationDomain(BuildConfig.LAUNCHER_DOMAIN);
+ setApplicationName(BuildConfig.LAUNCHER_NAME + "Updater");
+ setApplicationVersion(BuildConfig.printableVersionString() + "\n" + BuildConfig.GIT_COMMIT);
+
+ // Command line parsing
+ QCommandLineParser parser;
+ parser.setApplicationDescription(QObject::tr("An auto-updater for ProjT Launcher"));
+
+ parser.addOptions(
+ { { { "d", "dir" },
+ tr("Use a custom path as application root (use '.' for current directory)."),
+ tr("directory") },
+ { { "V", "projt-version" },
+ tr("Use this version as the installed launcher version. (provided because stdout can not be "
+ "reliably captured on windows)"),
+ tr("installed launcher version") },
+ { { "I", "install-version" }, "Install a specific version.", tr("version name") },
+ { { "U", "update-url" }, tr("Update from the specified release feed."), tr("release feed url") },
+ { { "c", "check-only" },
+ tr("Only check if an update is needed. Exit status 100 if true, 0 if false (or non 0 if "
+ "there was an error).") },
+ { { "p", "pre-release" }, tr("Allow updating to pre-release releases") },
+ { { "F", "force" }, tr("Force an update, even if one is not needed.") },
+ { { "l", "list" }, tr("List available releases.") },
+ { "debug", tr("Log debug to console.") },
+ { { "S", "select-ui" }, tr("Select the version to install with a GUI.") },
+ { { "D", "allow-downgrade" }, tr("Allow the updater to downgrade to previous versions.") } });
+
+ parser.addHelpOption();
+ parser.addVersionOption();
+ parser.process(arguments());
+
+ logToConsole = parser.isSet("debug");
+
+ auto updater_executable = QCoreApplication::applicationFilePath();
+
+#ifdef Q_OS_MACOS
+ showFatalErrorMessage(tr("MacOS Not Supported"), tr("The updater does not support installations on MacOS"));
+#endif
+
+ if (updater_executable.startsWith("/tmp/.mount_"))
+ {
+ m_isAppimage = true;
+ m_appimagePath = QProcessEnvironment::systemEnvironment().value(QStringLiteral("APPIMAGE"));
+ if (m_appimagePath.isEmpty())
+ {
+ showFatalErrorMessage(
+ tr("Unsupported Installation"),
+ tr("Updater is running as misconfigured AppImage? ($APPIMAGE environment variable is missing)"));
+ }
+ }
+
+ m_isFlatpak = DesktopServices::isFlatpak();
+
+ QString projt_executable = FS::PathCombine(applicationDirPath(), BuildConfig.LAUNCHER_APP_BINARY_NAME);
+#if defined Q_OS_WIN32
+ projt_executable.append(".exe");
+#endif
+
+ if (!QFileInfo(projt_executable).isFile())
+ {
+ showFatalErrorMessage(tr("Unsupported Installation"), tr("The updater can not find the main executable."));
+ }
+
+ m_projtExecutable = projt_executable;
+
+ auto projt_update_url = parser.value("update-url");
+ if (projt_update_url.isEmpty())
+ projt_update_url = BuildConfig.UPDATER_RELEASES_URL;
+
+ m_releaseFeedUrl = QUrl::fromUserInput(projt_update_url);
+
+ m_checkOnly = parser.isSet("check-only");
+ m_forceUpdate = parser.isSet("force");
+ m_printOnly = parser.isSet("list");
+ auto user_version = parser.value("install-version");
+ if (!user_version.isEmpty())
+ {
+ m_userSelectedVersion = Version(user_version);
+ }
+ m_selectUI = parser.isSet("select-ui");
+ m_allowDowngrade = parser.isSet("allow-downgrade");
+
+ auto version = parser.value("projt-version");
+ if (!version.isEmpty())
+ {
+ if (version.contains('-'))
+ {
+ auto index = version.indexOf('-');
+ m_prsimVersionChannel = version.mid(index + 1);
+ version = version.left(index);
+ }
+ else
+ {
+ m_prsimVersionChannel = "stable";
+ }
+ auto version_parts = version.split('.');
+ m_projtVersionMajor = version_parts.takeFirst().toInt();
+ m_projtVersionMinor = version_parts.takeFirst().toInt();
+ if (!version_parts.isEmpty())
+ m_projtVersionPatch = version_parts.takeFirst().toInt();
+ else
+ m_projtVersionPatch = 0;
+ }
+
+ m_allowPreRelease = parser.isSet("pre-release");
+
+ QString origCwdPath = QDir::currentPath();
+ QString binPath = applicationDirPath();
+
+ { // find data director
+ // Root path is used for updates and portable data
+#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) || defined(Q_OS_OPENBSD)
+ QDir foo(FS::PathCombine(binPath, "..")); // typically portable-root or /usr
+ m_rootPath = foo.absolutePath();
+#elif defined(Q_OS_WIN32)
+ m_rootPath = binPath;
+#elif defined(Q_OS_MAC)
+ QDir foo(FS::PathCombine(binPath, "../.."));
+ m_rootPath = foo.absolutePath();
+ // on macOS, touch the root to force Finder to reload the .app metadata (and fix any icon change issues)
+ FS::updateTimestamp(m_rootPath);
+#endif
+ }
+
+ QString adjustedBy;
+ // change folder
+ QString dirParam = parser.value("dir");
+ if (!dirParam.isEmpty())
+ {
+ // the dir param. it makes projt launcher data path point to whatever the user specified
+ // on command line
+ adjustedBy = "Command line";
+ m_dataPath = dirParam;
+#ifndef Q_OS_MACOS
+ if (QDir(FS::PathCombine(m_rootPath, "UserData")).exists())
+ {
+ m_isPortable = true;
+ }
+ if (QFile::exists(FS::PathCombine(m_rootPath, "portable.txt")))
+ {
+ m_isPortable = true;
+ }
+#endif
+ }
+ else if (auto dataDirEnv = QProcessEnvironment::systemEnvironment().value(
+ QString("%1_DATA_DIR").arg(BuildConfig.LAUNCHER_NAME.toUpper()));
+ !dataDirEnv.isEmpty())
+ {
+ adjustedBy = "System environment";
+ m_dataPath = dataDirEnv;
+#ifndef Q_OS_MACOS
+ if (QFile::exists(FS::PathCombine(m_rootPath, "portable.txt")))
+ {
+ m_isPortable = true;
+ }
+#endif
+ }
+ else
+ {
+ QDir foo(FS::PathCombine(QStandardPaths::writableLocation(QStandardPaths::AppDataLocation), ".."));
+ m_dataPath = foo.absolutePath();
+ adjustedBy = "Persistent data path";
+
+#ifndef Q_OS_MACOS
+ if (auto portableUserData = FS::PathCombine(m_rootPath, "UserData"); QDir(portableUserData).exists())
+ {
+ m_dataPath = portableUserData;
+ adjustedBy = "Portable user data path";
+ m_isPortable = true;
+ }
+ else if (QFile::exists(FS::PathCombine(m_rootPath, "portable.txt")))
+ {
+ m_dataPath = m_rootPath;
+ adjustedBy = "Portable data path";
+ m_isPortable = true;
+ }
+#endif
+ }
+
+ m_updateLogPath = FS::PathCombine(m_dataPath, "logs", "projt_launcher_update.log");
+
+ { // setup logging
+ FS::ensureFolderPathExists(FS::PathCombine(m_dataPath, "logs"));
+ static const QString baseLogFile =
+ BuildConfig.LAUNCHER_NAME + "Updater" + (m_checkOnly ? "-CheckOnly" : "") + "-%0.log";
+ static const QString logBase = FS::PathCombine(m_dataPath, "logs", baseLogFile);
+
+ if (FS::ensureFolderPathExists("logs"))
+ { // enough history to track both launches of the updater during a portable install
+ FS::move(logBase.arg(1), logBase.arg(2));
+ FS::move(logBase.arg(0), logBase.arg(1));
+ }
+
+ logFile = std::unique_ptr<QFile>(new QFile(logBase.arg(0)));
+ if (!logFile->open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Truncate))
+ {
+ showFatalErrorMessage(tr("The launcher data folder is not writable!"),
+ tr("The updater couldn't create a log file - the data folder is not writable.\n"
+ "\n"
+ "Make sure you have write permissions to the data folder.\n"
+ "(%1)\n"
+ "\n"
+ "The updater cannot continue until you fix this problem.")
+ .arg(m_dataPath));
+ return;
+ }
+ qInstallMessageHandler(appDebugOutput);
+
+ qSetMessagePattern("%{time process}"
+ " "
+ "%{if-debug}D%{endif}"
+ "%{if-info}I%{endif}"
+ "%{if-warning}W%{endif}"
+ "%{if-critical}C%{endif}"
+ "%{if-fatal}F%{endif}"
+ " "
+ "|"
+ " "
+ "%{if-category}[%{category}]: %{endif}"
+ "%{message}");
+
+ bool foundLoggingRules = false;
+
+ auto logRulesFile = QStringLiteral("qtlogging.ini");
+ auto logRulesPath = FS::PathCombine(m_dataPath, logRulesFile);
+
+ qDebug() << "Testing" << logRulesPath << "...";
+ foundLoggingRules = QFile::exists(logRulesPath);
+
+ // search the dataPath()
+ // seach app data standard path
+ if (!foundLoggingRules && !isPortable() && dirParam.isEmpty())
+ {
+ logRulesPath = QStandardPaths::locate(QStandardPaths::AppDataLocation, FS::PathCombine("..", logRulesFile));
+ if (!logRulesPath.isEmpty())
+ {
+ qDebug() << "Found" << logRulesPath << "...";
+ foundLoggingRules = true;
+ }
+ }
+ // seach root path
+ if (!foundLoggingRules)
+ {
+ logRulesPath = FS::PathCombine(m_rootPath, logRulesFile);
+ qDebug() << "Testing" << logRulesPath << "...";
+ foundLoggingRules = QFile::exists(logRulesPath);
+ }
+
+ if (foundLoggingRules)
+ {
+ // load and set logging rules
+ qDebug() << "Loading logging rules from:" << logRulesPath;
+ QSettings loggingRules(logRulesPath, QSettings::IniFormat);
+ loggingRules.beginGroup("Rules");
+ QStringList rule_names = loggingRules.childKeys();
+ QStringList rules;
+ qDebug() << "Setting log rules:";
+ for (auto rule_name : rule_names)
+ {
+ auto rule = QString("%1=%2").arg(rule_name).arg(loggingRules.value(rule_name).toString());
+ rules.append(rule);
+ qDebug() << " " << rule;
+ }
+ auto rules_str = rules.join("\n");
+ QLoggingCategory::setFilterRules(rules_str);
+ }
+
+ qDebug() << "<> Log initialized.";
+ }
+
+ { // log debug program info
+ qDebug() << qPrintable(BuildConfig.LAUNCHER_DISPLAYNAME + " Updater, "
+ + QString(BuildConfig.LAUNCHER_COPYRIGHT).replace("\n", ", "));
+ qDebug() << "Version : " << BuildConfig.printableVersionString();
+ qDebug() << "Git commit : " << BuildConfig.GIT_COMMIT;
+ qDebug() << "Git refspec : " << BuildConfig.GIT_REFSPEC;
+ qDebug() << "Compiled for : " << BuildConfig.systemID();
+ qDebug() << "Compiled by : " << BuildConfig.compilerID();
+ qDebug() << "Build Artifact : " << BuildConfig.BUILD_ARTIFACT;
+ if (adjustedBy.size())
+ {
+ qDebug() << "Data dir before adjustment : " << origCwdPath;
+ qDebug() << "Data dir after adjustment : " << m_dataPath;
+ qDebug() << "Adjusted by : " << adjustedBy;
+ }
+ else
+ {
+ qDebug() << "Data dir : " << QDir::currentPath();
+ }
+ qDebug() << "Work dir : " << QDir::currentPath();
+ qDebug() << "Binary path : " << binPath;
+ qDebug() << "Application root path : " << m_rootPath;
+ qDebug() << "Portable install : " << m_isPortable;
+ qDebug() << "<> Paths set.";
+ }
+
+ { // network
+ m_network = makeShared<QNetworkAccessManager>(new QNetworkAccessManager());
+ qDebug() << "Detecting proxy settings...";
+ QNetworkProxy proxy = QNetworkProxy::applicationProxy();
+ m_network->setProxy(proxy);
+ }
+
+ auto marker_file_path = QDir(m_rootPath).absoluteFilePath(".projt_launcher_updater_unpack.marker");
+ auto marker_file = QFileInfo(marker_file_path);
+ if (marker_file.exists())
+ {
+ auto target_dir = QString(FS::read(marker_file_path)).trimmed();
+ if (target_dir.isEmpty())
+ {
+ qWarning() << "Empty updater marker file contains no install target. making best guess of parent dir";
+ target_dir = QDir(m_rootPath).absoluteFilePath("..");
+ }
+
+ QMetaObject::invokeMethod(
+ this,
+ [this, target_dir]() { moveAndFinishUpdate(target_dir); },
+ Qt::QueuedConnection);
+ }
+ else
+ {
+ QMetaObject::invokeMethod(this, &ProjTUpdaterApp::loadReleaseFeed, Qt::QueuedConnection);
+ }
+}
+
+ProjTUpdaterApp::~ProjTUpdaterApp()
+{
+ qDebug() << "updater shutting down";
+ // Shut down logger by setting the logger function to nothing
+ qInstallMessageHandler(nullptr);
+
+#if defined Q_OS_WIN32
+ // Detach from Windows console
+ if (consoleAttached)
+ {
+ fclose(stdout);
+ fclose(stdin);
+ fclose(stderr);
+ FreeConsole();
+ }
+#endif
+}
+
+void ProjTUpdaterApp::fail(const QString& reason)
+{
+ qCritical() << qPrintable(reason);
+ m_status = Failed;
+ exit(1);
+}
+
+void ProjTUpdaterApp::abort(const QString& reason)
+{
+ qCritical() << qPrintable(reason);
+ m_status = Aborted;
+ exit(2);
+}
+
+void ProjTUpdaterApp::showFatalErrorMessage(const QString& title, const QString& content)
+{
+ m_status = Failed;
+ auto msgBox = new QMessageBox();
+ msgBox->setWindowTitle(title);
+ msgBox->setText(content);
+ msgBox->setStandardButtons(QMessageBox::Ok);
+ msgBox->setDefaultButton(QMessageBox::Ok);
+ msgBox->setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::TextBrowserInteraction);
+ msgBox->setIcon(QMessageBox::Critical);
+ msgBox->setMinimumWidth(460);
+ msgBox->adjustSize();
+ msgBox->exec();
+ exit(1);
+}
+
+void ProjTUpdaterApp::run()
+{
+ qDebug() << "found" << m_releases.length() << "releases in update feed";
+ qDebug() << "loading exe at " << m_projtExecutable;
+
+ if (m_printOnly)
+ {
+ printReleases();
+ m_status = Succeeded;
+ return exit(0);
+ }
+
+ if (!loadProjTVersionFromExe(m_projtExecutable))
+ {
+ m_projtVersion = BuildConfig.printableVersionString();
+ m_projtVersionMajor = BuildConfig.VERSION_MAJOR;
+ m_projtVersionMinor = BuildConfig.VERSION_MINOR;
+ m_projtVersionPatch = BuildConfig.VERSION_PATCH;
+ m_prsimVersionChannel = BuildConfig.VERSION_CHANNEL;
+ m_projtGitCommit = BuildConfig.GIT_COMMIT;
+ }
+ m_status = Succeeded;
+
+ qDebug() << "Executable reports as:" << m_projtBinaryName << "version:" << m_projtVersion;
+ qDebug() << "Version major:" << m_projtVersionMajor;
+ qDebug() << "Version minor:" << m_projtVersionMinor;
+ qDebug() << "Version minor:" << m_projtVersionPatch;
+ qDebug() << "Version channel:" << m_prsimVersionChannel;
+ qDebug() << "Git Commit:" << m_projtGitCommit;
+
+ auto update_candidate = findUpdateCandidate();
+ if (update_candidate.release.isValid())
+ qDebug() << "Latest release" << update_candidate.release.version;
+ auto need_update = update_candidate.kind == UpdateKind::Update;
+ auto has_migration = update_candidate.kind == UpdateKind::Migration;
+
+ if (m_checkOnly)
+ {
+ if (need_update || has_migration)
+ {
+ QTextStream stdOutStream(stdout);
+ stdOutStream << "Name: " << update_candidate.release.name << "\n";
+ stdOutStream << "Version: " << update_candidate.release.tag_name << "\n";
+ stdOutStream << "TimeStamp: " << update_candidate.release.created_at.toString(Qt::ISODate) << "\n";
+ stdOutStream << update_candidate.release.body << "\n";
+ stdOutStream.flush();
+
+ return exit(need_update ? 100 : 101);
+ }
+ else
+ {
+ return exit(0);
+ }
+ }
+
+ if (m_isFlatpak)
+ {
+ showFatalErrorMessage(tr("Updating flatpack not supported"),
+ tr("Actions outside of checking if an update is available are not "
+ "supported when running the flatpak version of ProjT Launcher."));
+ return;
+ }
+ if (m_isAppimage)
+ {
+ bool result = true;
+ if (need_update)
+ result = callAppImageUpdate();
+ return exit(result ? 0 : 1);
+ }
+
+ if (need_update || m_forceUpdate || !m_userSelectedVersion.isEmpty())
+ {
+ ReleaseInfo update_release = update_candidate.release.isValid() ? update_candidate.release : getLatestRelease();
+ if (!m_userSelectedVersion.isEmpty())
+ {
+ bool found = false;
+ for (auto rls : m_releases)
+ {
+ if (rls.version == m_userSelectedVersion)
+ {
+ found = true;
+ update_release = rls;
+ break;
+ }
+ }
+ if (!found)
+ {
+ showFatalErrorMessage("No release for version!",
+ QString("Can not find a release entry for specified version %1")
+ .arg(m_userSelectedVersion.toString()));
+ return;
+ }
+ }
+ else if (m_selectUI)
+ {
+ update_release = selectRelease();
+ if (!update_release.isValid())
+ {
+ showFatalErrorMessage("No version selected.", "No version was selected.");
+ return;
+ }
+ }
+
+ performUpdate(update_release);
+ }
+
+ exit(0);
+}
+
+void ProjTUpdaterApp::moveAndFinishUpdate(QDir target)
+{
+ logUpdate("Finishing update process");
+
+ logUpdate("Waiting 2 seconds for resources to free");
+ this->thread()->sleep(2);
+
+ auto manifest_path = FS::PathCombine(m_rootPath, "manifest.txt");
+ QFileInfo manifest(manifest_path);
+
+ auto app_dir = QDir(m_rootPath);
+
+ QStringList file_list;
+ if (manifest.isFile())
+ {
+ // load manifest from file
+ logUpdate(tr("Reading manifest from %1").arg(manifest.absoluteFilePath()));
+ try
+ {
+ auto contents = QString::fromUtf8(FS::read(manifest.absoluteFilePath()));
+ auto files = contents.split('\n');
+ for (auto file : files)
+ {
+ file_list.append(file.trimmed());
+ }
+ }
+ catch (FS::FileSystemException&)
+ {}
+ }
+
+ if (file_list.isEmpty())
+ {
+ logUpdate(tr("Manifest empty, making best guess of the directory contents of %1").arg(m_rootPath));
+ auto entries = target.entryInfoList(QDir::NoDotAndDotDot | QDir::Files | QDir::Dirs);
+ for (auto entry : entries)
+ {
+ file_list.append(entry.fileName());
+ }
+ }
+ logUpdate(tr("Installing the following to %1 :\n %2").arg(target.absolutePath()).arg(file_list.join(",\n ")));
+
+ bool error = false;
+
+ QProgressDialog progress(tr("Installing from %1").arg(m_rootPath), "", 0, file_list.length());
+ progress.setCancelButton(nullptr);
+ progress.setMinimumWidth(400);
+ progress.adjustSize();
+ progress.show();
+ QCoreApplication::processEvents();
+
+ logUpdate(tr("Installing from %1").arg(m_rootPath));
+
+ auto copy = [this, app_dir, target](QString to_install_file)
+ {
+ auto rel_path = app_dir.relativeFilePath(to_install_file);
+ auto install_path = FS::PathCombine(target.absolutePath(), rel_path);
+ logUpdate(tr("Installing %1 from %2").arg(install_path).arg(to_install_file));
+ FS::ensureFilePathExists(install_path);
+ auto result = FS::copy(to_install_file, install_path).overwrite(true)();
+ if (!result)
+ {
+ logUpdate(tr("Failed copy %1 to %2").arg(to_install_file).arg(install_path));
+ return true;
+ }
+ return false;
+ };
+
+ int i = 0;
+ for (auto glob : file_list)
+ {
+ QDirIterator iter(m_rootPath, QStringList({ glob }), QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot);
+ progress.setValue(i);
+ QCoreApplication::processEvents();
+ if (!iter.hasNext() && !glob.isEmpty())
+ {
+ if (auto file_info = QFileInfo(FS::PathCombine(m_rootPath, glob)); file_info.exists())
+ {
+ error |= copy(file_info.absoluteFilePath());
+ }
+ else
+ {
+ logUpdate(tr("File doesn't exist, ignoring: %1").arg(FS::PathCombine(m_rootPath, glob)));
+ }
+ }
+ else
+ {
+ while (iter.hasNext())
+ {
+ error |= copy(iter.next());
+ }
+ }
+ i++;
+ }
+ progress.setValue(i);
+ QCoreApplication::processEvents();
+
+ if (error)
+ {
+ logUpdate(tr("There were errors installing the update."));
+ auto fail_marker = FS::PathCombine(m_dataPath, ".projt_launcher_update.fail");
+ FS::copy(m_updateLogPath, fail_marker).overwrite(true)();
+ }
+ else
+ {
+ logUpdate(tr("Update succeed."));
+ auto success_marker = FS::PathCombine(m_dataPath, ".projt_launcher_update.success");
+ FS::copy(m_updateLogPath, success_marker).overwrite(true)();
+ }
+ auto update_lock_path = FS::PathCombine(m_dataPath, ".projt_launcher_update.lock");
+ FS::deletePath(update_lock_path);
+
+ QProcess proc;
+ auto app_exe_name = BuildConfig.LAUNCHER_APP_BINARY_NAME;
+#if defined Q_OS_WIN32
+ app_exe_name.append(".exe");
+
+ auto env = QProcessEnvironment::systemEnvironment();
+ env.insert("__COMPAT_LAYER", "RUNASINVOKER");
+ proc.setProcessEnvironment(env);
+#else
+ app_exe_name.prepend("bin/");
+#endif
+
+ auto app_exe_path = target.absoluteFilePath(app_exe_name);
+ proc.startDetached(app_exe_path);
+
+ exit(error ? 1 : 0);
+}
+
+void ProjTUpdaterApp::printReleases()
+{
+ for (auto release : m_releases)
+ {
+ std::cout << release.name.toStdString() << " Version: " << release.tag_name.toStdString() << std::endl;
+ }
+}
+
+QList<ReleaseInfo> ProjTUpdaterApp::nonDraftReleases()
+{
+ QList<ReleaseInfo> nonDraft;
+ for (auto rls : m_releases)
+ {
+ if (rls.isValid() && !rls.draft)
+ nonDraft.append(rls);
+ }
+ return nonDraft;
+}
+
+QList<ReleaseInfo> ProjTUpdaterApp::newerReleases()
+{
+ QList<ReleaseInfo> newer;
+ for (auto rls : nonDraftReleases())
+ {
+ if (rls.version > m_projtVersion)
+ newer.append(rls);
+ }
+ return newer;
+}
+
+ReleaseInfo ProjTUpdaterApp::selectRelease()
+{
+ QList<ReleaseInfo> releases;
+
+ if (m_allowDowngrade)
+ {
+ releases = nonDraftReleases();
+ }
+ else
+ {
+ releases = newerReleases();
+ }
+
+ if (releases.isEmpty())
+ return {};
+
+ SelectReleaseDialog dlg(Version(m_projtVersion), releases);
+ auto result = dlg.exec();
+
+ if (result == QDialog::Rejected)
+ {
+ return {};
+ }
+ ReleaseInfo release = dlg.selectedRelease();
+
+ return release;
+}
+
+QList<ReleaseAsset> ProjTUpdaterApp::validReleaseArtifacts(const ReleaseInfo& release)
+{
+ QList<ReleaseAsset> valid;
+
+ qDebug() << "Selecting best asset from" << release.tag_name << "for platform" << BuildConfig.BUILD_ARTIFACT
+ << "portable:" << m_isPortable;
+ if (BuildConfig.BUILD_ARTIFACT.isEmpty())
+ qWarning() << "Build platform is not set!";
+ for (auto asset : release.assets)
+ {
+ if (asset.name.endsWith(".zsync"))
+ {
+ qDebug() << "Rejecting zsync file" << asset.name;
+ continue;
+ }
+ if (!m_isAppimage && asset.name.toLower().endsWith("appimage"))
+ {
+ qDebug() << "Rejecting" << asset.name << "because it is an AppImage";
+ continue;
+ }
+ else if (m_isAppimage && !asset.name.toLower().endsWith("appimage"))
+ {
+ qDebug() << "Rejecting" << asset.name << "because it is not an AppImage";
+ continue;
+ }
+ auto asset_name = asset.name.toLower();
+ auto [platform, platform_qt_ver] = StringUtils::splitFirst(BuildConfig.BUILD_ARTIFACT.toLower(), "-qt");
+ auto system_is_arm = QSysInfo::buildCpuArchitecture().contains("arm64");
+ auto asset_is_arm = asset_name.contains("arm64");
+ auto asset_is_archive = asset_name.endsWith(".zip") || asset_name.endsWith(".tar.gz");
+
+ bool for_platform = !platform.isEmpty() && asset_name.contains(platform);
+ if (!for_platform)
+ {
+ qDebug() << "Rejecting" << asset.name << "because platforms do not match";
+ }
+ bool for_portable = asset_name.contains("portable");
+ if (for_platform && asset_name.contains("legacy") && !platform.contains("legacy"))
+ {
+ qDebug() << "Rejecting" << asset.name << "because platforms do not match";
+ for_platform = false;
+ }
+ if (for_platform && ((asset_is_arm && !system_is_arm) || (!asset_is_arm && system_is_arm)))
+ {
+ qDebug() << "Rejecting" << asset.name << "because architecture does not match";
+ for_platform = false;
+ }
+ if (for_platform && platform.contains("windows") && !m_isPortable && asset_is_archive)
+ {
+ qDebug() << "Rejecting" << asset.name << "because it is not an installer";
+ for_platform = false;
+ }
+
+ static const QRegularExpression s_qtPattern("-qt(\\d+)");
+ auto qt_match = s_qtPattern.match(asset_name);
+ if (for_platform && qt_match.hasMatch())
+ {
+ if (platform_qt_ver.isEmpty() || platform_qt_ver.toInt() != qt_match.captured(1).toInt())
+ {
+ qDebug() << "Rejecting" << asset.name << "because it is not for the correct qt version"
+ << platform_qt_ver.toInt() << "vs" << qt_match.captured(1).toInt();
+ for_platform = false;
+ }
+ }
+
+ if (((m_isPortable && for_portable) || (!m_isPortable && !for_portable)) && for_platform)
+ {
+ qDebug() << "Accepting" << asset.name;
+ valid.append(asset);
+ }
+ }
+ return valid;
+}
+
+ReleaseAsset ProjTUpdaterApp::selectAsset(const QList<ReleaseAsset>& assets)
+{
+ SelectReleaseAssetDialog dlg(assets);
+ auto result = dlg.exec();
+
+ if (result == QDialog::Rejected)
+ {
+ return {};
+ }
+
+ ReleaseAsset asset = dlg.selectedAsset();
+ return asset;
+}
+
+void ProjTUpdaterApp::performUpdate(const ReleaseInfo& release)
+{
+ m_install_release = release;
+ qDebug() << "Updating to" << release.tag_name;
+ auto valid_assets = validReleaseArtifacts(release);
+ qDebug() << "valid release assets:" << valid_assets;
+
+ ReleaseAsset selected_asset;
+ if (valid_assets.isEmpty())
+ {
+ return showFatalErrorMessage(
+ tr("No Valid Release Assets"),
+ tr("Release %1 has no valid assets for this platform: %2")
+ .arg(release.tag_name)
+ .arg(tr("%1 portable: %2").arg(BuildConfig.BUILD_ARTIFACT).arg(m_isPortable ? tr("yes") : tr("no"))));
+ }
+ else if (valid_assets.length() > 1)
+ {
+ selected_asset = selectAsset(valid_assets);
+ }
+ else
+ {
+ selected_asset = valid_assets.takeFirst();
+ }
+
+ if (!selected_asset.isValid())
+ {
+ return showFatalErrorMessage(tr("No version selected."), tr("No version was selected."));
+ }
+
+ qDebug() << "will install" << selected_asset;
+ auto file = downloadAsset(selected_asset);
+
+ if (!file.exists())
+ {
+ return showFatalErrorMessage(tr("Failed to Download"), tr("Failed to download the selected asset."));
+ }
+
+ performInstall(file);
+}
+
+QFileInfo ProjTUpdaterApp::downloadAsset(const ReleaseAsset& asset)
+{
+ auto temp_dir = QDir::tempPath();
+ auto file_url = asset.download_url;
+ auto out_file_path = FS::PathCombine(temp_dir, file_url.fileName());
+
+ qDebug() << "downloading" << file_url << "to" << out_file_path;
+ auto download = Net::Download::makeFile(file_url, out_file_path);
+ download->setNetwork(m_network);
+ auto progress_dialog = ProgressDialog();
+ progress_dialog.adjustSize();
+
+ progress_dialog.execWithTask(*download);
+
+ qDebug() << "download complete";
+
+ QFileInfo out_file(out_file_path);
+ return out_file;
+}
+
+bool ProjTUpdaterApp::callAppImageUpdate()
+{
+ auto appimage_path = QProcessEnvironment::systemEnvironment().value(QStringLiteral("APPIMAGE"));
+ QProcess proc = QProcess();
+ qDebug() << "Calling: AppImageUpdate" << appimage_path;
+ proc.setProgram(FS::PathCombine(m_rootPath, "bin", "AppImageUpdate-x86_64.AppImage"));
+ proc.setArguments({ appimage_path });
+ auto result = proc.startDetached();
+ if (!result)
+ qDebug() << "Failed to start AppImageUpdate reason:" << proc.errorString();
+ return result;
+}
+
+void ProjTUpdaterApp::clearUpdateLog()
+{
+ FS::deletePath(m_updateLogPath);
+}
+
+void ProjTUpdaterApp::logUpdate(const QString& msg)
+{
+ qDebug() << qUtf8Printable(msg);
+ FS::append(m_updateLogPath, QStringLiteral("%1\n").arg(msg).toUtf8());
+}
+
+std::tuple<QDateTime, QString, QString, QString, QString> read_lock_File(const QString& path)
+{
+ auto contents = QString(FS::read(path));
+ auto lines = contents.split('\n');
+
+ QDateTime timestamp;
+ QString from, to, target, data_path;
+ for (auto line : lines)
+ {
+ auto index = line.indexOf("=");
+ if (index < 0)
+ continue;
+ auto left = line.left(index);
+ auto right = line.mid(index + 1);
+ if (left.toLower() == "timestamp")
+ {
+ timestamp = QDateTime::fromString(right, Qt::ISODate);
+ }
+ else if (left.toLower() == "from")
+ {
+ from = right;
+ }
+ else if (left.toLower() == "to")
+ {
+ to = right;
+ }
+ else if (left.toLower() == "target")
+ {
+ target = right;
+ }
+ else if (left.toLower() == "data_path")
+ {
+ data_path = right;
+ }
+ }
+ return std::make_tuple(timestamp, from, to, target, data_path);
+}
+
+bool write_lock_file(const QString& path,
+ QDateTime timestamp,
+ QString from,
+ QString to,
+ QString target,
+ QString data_path)
+{
+ try
+ {
+ FS::write(path,
+ QStringLiteral("TIMESTAMP=%1\nFROM=%2\nTO=%3\nTARGET=%4\nDATA_PATH=%5\n")
+ .arg(timestamp.toString(Qt::ISODate))
+ .arg(from)
+ .arg(to)
+ .arg(target)
+ .arg(data_path)
+ .toUtf8());
+ }
+ catch (FS::FileSystemException& err)
+ {
+ qWarning() << "Error writing lockfile:" << err.what() << "\n" << err.cause();
+ return false;
+ }
+ return true;
+}
+
+void ProjTUpdaterApp::performInstall(QFileInfo file)
+{
+ qDebug() << "starting install";
+ auto update_lock_path = FS::PathCombine(m_dataPath, ".projt_launcher_update.lock");
+ QFileInfo update_lock(update_lock_path);
+ if (update_lock.exists())
+ {
+ auto [timestamp, from, to, target, data_path] = read_lock_File(update_lock_path);
+ auto msg = tr("Update already in progress\n");
+ auto infoMsg = tr("This installation has a update lock file present at: %1\n"
+ "\n"
+ "Timestamp: %2\n"
+ "Updating from version %3 to %4\n"
+ "Target install path: %5\n"
+ "Data Path: %6"
+ "\n"
+ "This likely means that a previous update attempt failed. Please ensure your installation is "
+ "in working order before "
+ "proceeding.\n"
+ "Check the ProjT Launcher updater log at: \n"
+ "%7\n"
+ "for details on the last update attempt.\n"
+ "\n"
+ "To overwrite this lock and proceed with this update anyway, select \"Ignore\" below.")
+ .arg(update_lock_path)
+ .arg(timestamp.toString(Qt::ISODate), from, to, target, data_path)
+ .arg(m_updateLogPath);
+ QMessageBox msgBox;
+ msgBox.setText(msg);
+ msgBox.setInformativeText(infoMsg);
+ msgBox.setStandardButtons(QMessageBox::Ignore | QMessageBox::Cancel);
+ msgBox.setDefaultButton(QMessageBox::Cancel);
+ msgBox.setMinimumWidth(460);
+ msgBox.adjustSize();
+ switch (msgBox.exec())
+ {
+ case QMessageBox::AcceptRole: break;
+ case QMessageBox::RejectRole: [[fallthrough]];
+ default: return showFatalErrorMessage(tr("Update Aborted"), tr("The update attempt was aborted"));
+ }
+ }
+ clearUpdateLog();
+
+ auto changelog_path = FS::PathCombine(m_dataPath, ".projt_launcher_update.changelog");
+ FS::write(changelog_path, m_install_release.body.toUtf8());
+
+ logUpdate(tr("Updating from %1 to %2").arg(m_projtVersion).arg(m_install_release.tag_name));
+ if (m_isPortable || file.fileName().endsWith(".zip") || file.fileName().endsWith(".tar.gz"))
+ {
+ write_lock_file(update_lock_path,
+ QDateTime::currentDateTime(),
+ m_projtVersion,
+ m_install_release.tag_name,
+ m_rootPath,
+ m_dataPath);
+ logUpdate(tr("Updating portable install at %1").arg(m_rootPath));
+ unpackAndInstall(file);
+ }
+ else
+ {
+ logUpdate(tr("Running installer file at %1").arg(file.absoluteFilePath()));
+ QProcess proc = QProcess();
+#if defined Q_OS_WIN
+ auto env = QProcessEnvironment::systemEnvironment();
+ env.insert("__COMPAT_LAYER", "RUNASINVOKER");
+ proc.setProcessEnvironment(env);
+#endif
+ proc.setProgram(file.absoluteFilePath());
+ bool result = proc.startDetached();
+ logUpdate(tr("Process start result: %1").arg(result ? tr("yes") : tr("no")));
+ exit(result ? 0 : 1);
+ }
+}
+
+void ProjTUpdaterApp::unpackAndInstall(QFileInfo archive)
+{
+ logUpdate(tr("Backing up install"));
+ backupAppDir();
+
+ if (auto loc = unpackArchive(archive))
+ {
+ auto marker_file_path = loc.value().absoluteFilePath(".projt_launcher_updater_unpack.marker");
+ FS::write(marker_file_path, m_rootPath.toUtf8());
+
+ QProcess proc = QProcess();
+
+ auto exe_name = QStringLiteral("%1_updater").arg(BuildConfig.LAUNCHER_APP_BINARY_NAME);
+#if defined Q_OS_WIN32
+ exe_name.append(".exe");
+
+ auto env = QProcessEnvironment::systemEnvironment();
+ env.insert("__COMPAT_LAYER", "RUNASINVOKER");
+ proc.setProcessEnvironment(env);
+#else
+ exe_name.prepend("bin/");
+#endif
+
+ auto new_updater_path = loc.value().absoluteFilePath(exe_name);
+ logUpdate(tr("Starting new updater at '%1'").arg(new_updater_path));
+ if (!proc.startDetached(new_updater_path, { "-d", m_dataPath }, loc.value().absolutePath()))
+ {
+ logUpdate(tr("Failed to launch '%1' %2").arg(new_updater_path).arg(proc.errorString()));
+ return exit(10);
+ }
+ return exit(); // up to the new updater now
+ }
+ return exit(1); // unpack failure
+}
+
+void ProjTUpdaterApp::backupAppDir()
+{
+ auto manifest_path = FS::PathCombine(m_rootPath, "manifest.txt");
+ QFileInfo manifest(manifest_path);
+
+ QStringList file_list;
+ if (manifest.isFile())
+ {
+ // load manifest from file
+
+ logUpdate(tr("Reading manifest from %1").arg(manifest.absoluteFilePath()));
+ try
+ {
+ auto contents = QString::fromUtf8(FS::read(manifest.absoluteFilePath()));
+ auto files = contents.split('\n');
+ for (auto file : files)
+ {
+ file_list.append(file.trimmed());
+ }
+ }
+ catch (FS::FileSystemException&)
+ {}
+ }
+
+ if (file_list.isEmpty())
+ {
+ // best guess
+ if (BuildConfig.BUILD_ARTIFACT.toLower().contains("linux"))
+ {
+ file_list.append({ "ProjTLauncher", "bin", "share", "lib" });
+ }
+ else
+ { // windows by process of elimination
+ file_list.append({
+ "jars",
+ "projtlauncher.exe",
+ "projtlauncher_filelink.exe",
+ "projtlauncher_updater.exe",
+ "qtlogging.ini",
+ "imageformats",
+ "iconengines",
+ "platforms",
+ "styles",
+ "tls",
+ "qt.conf",
+ "Qt*.dll",
+ });
+ }
+ logUpdate("manifest.txt empty or missing. making best guess at files to back up.");
+ }
+ logUpdate(tr("Backing up:\n %1").arg(file_list.join(",\n ")));
+ static const QRegularExpression s_replaceRegex("[" + QRegularExpression::escape("\\/:*?\"<>|") + "]");
+ auto app_dir = QDir(m_rootPath);
+ auto backup_dir =
+ FS::PathCombine(app_dir.absolutePath(),
+ QStringLiteral("backup_") + QString(m_projtVersion).replace(s_replaceRegex, QString("_")) + "-"
+ + m_projtGitCommit);
+ FS::ensureFolderPathExists(backup_dir);
+ auto backup_marker_path = FS::PathCombine(m_dataPath, ".projt_launcher_update_backup_path.txt");
+ FS::write(backup_marker_path, backup_dir.toUtf8());
+
+ QProgressDialog progress(tr("Backing up install at %1").arg(m_rootPath), "", 0, file_list.length());
+ progress.setCancelButton(nullptr);
+ progress.setMinimumWidth(400);
+ progress.adjustSize();
+ progress.show();
+ QCoreApplication::processEvents();
+
+ logUpdate(tr("Backing up install at %1").arg(m_rootPath));
+
+ auto copy = [this, app_dir, backup_dir](QString to_bak_file)
+ {
+ auto rel_path = app_dir.relativeFilePath(to_bak_file);
+ auto bak_path = FS::PathCombine(backup_dir, rel_path);
+ logUpdate(tr("Backing up and then removing %1").arg(to_bak_file));
+ FS::ensureFilePathExists(bak_path);
+ auto result = FS::copy(to_bak_file, bak_path).overwrite(true)();
+ if (!result)
+ {
+ logUpdate(tr("Failed to backup %1 to %2").arg(to_bak_file).arg(bak_path));
+ }
+ else
+ {
+ if (!FS::deletePath(to_bak_file))
+ logUpdate(tr("Failed to remove %1").arg(to_bak_file));
+ }
+ };
+
+ int i = 0;
+ for (auto glob : file_list)
+ {
+ QDirIterator iter(app_dir.absolutePath(),
+ QStringList({ glob }),
+ QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot);
+ progress.setValue(i);
+ QCoreApplication::processEvents();
+ if (!iter.hasNext() && !glob.isEmpty())
+ {
+ if (auto file_info = QFileInfo(FS::PathCombine(app_dir.absolutePath(), glob)); file_info.exists())
+ {
+ copy(file_info.absoluteFilePath());
+ }
+ else
+ {
+ logUpdate(tr("File doesn't exist, ignoring: %1").arg(FS::PathCombine(app_dir.absolutePath(), glob)));
+ }
+ }
+ else
+ {
+ while (iter.hasNext())
+ {
+ copy(iter.next());
+ }
+ }
+ i++;
+ }
+ progress.setValue(i);
+ QCoreApplication::processEvents();
+}
+
+std::optional<QDir> ProjTUpdaterApp::unpackArchive(QFileInfo archive)
+{
+ auto temp_extract_path = FS::PathCombine(m_dataPath, "projt_launcher_update_release");
+ FS::ensureFolderPathExists(temp_extract_path);
+ auto tmp_extract_dir = QDir(temp_extract_path);
+
+ if (archive.fileName().endsWith(".zip"))
+ {
+ auto result = MMCZip::extractDir(archive.absoluteFilePath(), tmp_extract_dir.absolutePath());
+ if (result)
+ {
+ logUpdate(tr("Extracted the following to \"%1\":\n %2")
+ .arg(tmp_extract_dir.absolutePath())
+ .arg(result->join("\n ")));
+ }
+ else
+ {
+ logUpdate(
+ tr("Failed to extract %1 to %2").arg(archive.absoluteFilePath()).arg(tmp_extract_dir.absolutePath()));
+ showFatalErrorMessage(
+ "Failed to extract archive",
+ tr("Failed to extract %1 to %2").arg(archive.absoluteFilePath()).arg(tmp_extract_dir.absolutePath()));
+ return std::nullopt;
+ }
+ }
+ else if (archive.fileName().endsWith(".tar.gz"))
+ {
+ QString cmd = "tar";
+ QStringList args = { "-xvf", archive.absoluteFilePath(), "-C", tmp_extract_dir.absolutePath() };
+ logUpdate(tr("Running: `%1 %2`").arg(cmd).arg(args.join(" ")));
+ QProcess proc = QProcess();
+ proc.start(cmd, args);
+ if (!proc.waitForStarted(5000))
+ { // wait 5 seconds to start
+ auto msg = tr("Failed to launch child process \"%1 %2\".").arg(cmd).arg(args.join(" "));
+ logUpdate(msg);
+ showFatalErrorMessage(tr("Failed extract archive"), msg);
+ return std::nullopt;
+ }
+ auto result = proc.waitForFinished(5000);
+ auto out = proc.readAll();
+ logUpdate(out);
+ if (!result)
+ {
+ auto msg = tr("Child process \"%1 %2\" failed.").arg(cmd).arg(args.join(" "));
+ logUpdate(msg);
+ showFatalErrorMessage(tr("Failed to extract archive"), msg);
+ return std::nullopt;
+ }
+ }
+ else
+ {
+ logUpdate(tr("Unknown archive format for %1").arg(archive.absoluteFilePath()));
+ showFatalErrorMessage("Can not extract",
+ QStringLiteral("Unknown archive format %1").arg(archive.absoluteFilePath()));
+ return std::nullopt;
+ }
+
+ return tmp_extract_dir;
+}
+
+bool ProjTUpdaterApp::loadProjTVersionFromExe(const QString& exe_path)
+{
+ QProcess proc = QProcess();
+ proc.setProcessChannelMode(QProcess::MergedChannels);
+ proc.setReadChannel(QProcess::StandardOutput);
+ proc.start(exe_path, { "--version" });
+ if (!proc.waitForStarted(5000))
+ {
+ showFatalErrorMessage(tr("Failed to Check Version"), tr("Failed to launch child process to read version."));
+ return false;
+ } // wait 5 seconds to start
+ if (!proc.waitForFinished(5000))
+ {
+ showFatalErrorMessage(tr("Failed to Check Version"), tr("Child launcher process failed."));
+ return false;
+ }
+ auto out = proc.readAllStandardOutput();
+ auto lines = out.split('\n');
+ lines.removeAll("");
+ if (lines.length() < 2)
+ return false;
+ else if (lines.length() > 2)
+ {
+ auto line1 = lines.takeLast();
+ auto line2 = lines.takeLast();
+ lines = { line2, line1 };
+ }
+ auto first = lines.takeFirst();
+ auto first_parts = first.split(' ');
+ if (first_parts.length() < 2)
+ return false;
+ m_projtBinaryName = first_parts.takeFirst();
+ auto version = first_parts.takeFirst().trimmed();
+ m_projtVersion = version;
+ if (version.contains('-'))
+ {
+ auto index = version.indexOf('-');
+ m_prsimVersionChannel = version.mid(index + 1);
+ version = version.left(index);
+ }
+ else
+ {
+ m_prsimVersionChannel = "stable";
+ }
+ auto version_parts = version.split('.');
+ if (version_parts.length() < 2)
+ return false;
+ m_projtVersionMajor = version_parts.takeFirst().toInt();
+ m_projtVersionMinor = version_parts.takeFirst().toInt();
+ if (!version_parts.isEmpty())
+ m_projtVersionPatch = version_parts.takeFirst().toInt();
+ else
+ m_projtVersionPatch = 0;
+ m_projtGitCommit = lines.takeFirst().simplified();
+ return true;
+}
+
+namespace
+{
+ QDateTime parseOptionalDateTime(const QJsonObject& object, const QString& key)
+ {
+ auto value = Json::ensureString(object, key);
+ if (value.isEmpty())
+ return {};
+ auto parsed = QDateTime::fromString(value, Qt::ISODate);
+ if (!parsed.isValid())
+ {
+ throw Json::JsonException(QString("'%1' is not a ISO formatted date/time value").arg(key));
+ }
+ return parsed;
+ }
+
+ QUrl parseAssetUrl(const QJsonObject& object)
+ {
+ for (const auto& key :
+ { QStringLiteral("url"), QStringLiteral("download_url"), QStringLiteral("browser_download_url") })
+ {
+ auto value = Json::ensureString(object, key);
+ if (!value.isEmpty())
+ {
+ return QUrl::fromUserInput(value);
+ }
+ }
+ return {};
+ }
+} // namespace
+
+void ProjTUpdaterApp::loadReleaseFeed()
+{
+ if (!m_releaseFeedUrl.isValid() || m_releaseFeedUrl.isEmpty())
+ return fail("release feed url is missing or invalid");
+
+ qDebug() << "Fetching release feed from" << m_releaseFeedUrl;
+ downloadReleaseFeed();
+}
+
+void ProjTUpdaterApp::downloadReleaseFeed()
+{
+ auto response = std::make_shared<QByteArray>();
+ auto download = Net::Download::makeByteArray(m_releaseFeedUrl, response);
+ download->setNetwork(m_network);
+ m_current_url = m_releaseFeedUrl.toString();
+
+ auto feed_headers = new Net::RawHeaderProxy();
+ feed_headers->addHeaders({ { "Accept", "application/json" } });
+ download->addHeaderProxy(feed_headers);
+
+ connect(download.get(),
+ &Net::Download::succeeded,
+ this,
+ [this, response]()
+ {
+ parseReleaseFeed(*response);
+ run();
+ });
+ connect(download.get(), &Net::Download::failed, this, &ProjTUpdaterApp::downloadError);
+
+ m_current_task.reset(download);
+ connect(download.get(),
+ &Net::Download::finished,
+ this,
+ [this]()
+ {
+ qDebug() << "Download" << m_current_task->getUid().toString() << "finished";
+ m_current_task.reset();
+ m_current_url = "";
+ });
+
+ QCoreApplication::processEvents();
+
+ QMetaObject::invokeMethod(download.get(), &Task::start, Qt::QueuedConnection);
+}
+
+int ProjTUpdaterApp::parseReleaseFeed(const QByteArray& response)
+{
+ if (response.isEmpty())
+ return 0;
+
+ int num_releases = 0;
+ try
+ {
+ auto doc = Json::requireDocument(response, "Release feed");
+ QJsonArray release_list;
+ if (doc.isArray())
+ {
+ release_list = Json::requireArray(doc, "Release feed");
+ }
+ else
+ {
+ auto root = Json::requireObject(doc, "Release feed");
+ if (root.contains("releases"))
+ {
+ release_list = Json::requireArray(root, "releases");
+ }
+ else if (root.contains("versions"))
+ {
+ release_list = Json::requireArray(root, "versions");
+ }
+ else
+ {
+ throw Json::JsonException("Release feed must be a release array or contain a 'releases' array");
+ }
+ }
+
+ for (auto release_json : release_list)
+ {
+ auto release_obj = Json::requireObject(release_json);
+
+ ReleaseInfo release = {};
+ release.name = Json::ensureString(release_obj, "name");
+ release.tag_name = Json::requireString(release_obj, "tag_name");
+ release.created_at = parseOptionalDateTime(release_obj, "created_at");
+ release.published_at = parseOptionalDateTime(release_obj, "published_at");
+ release.draft = Json::ensureBoolean(release_obj, "draft", false);
+ release.prerelease = Json::ensureBoolean(release_obj, "prerelease", false);
+ if (!release.prerelease)
+ {
+ auto channel = Json::ensureString(release_obj, "channel").trimmed();
+ release.prerelease = !channel.isEmpty() && channel != "stable";
+ }
+ release.body = Json::ensureString(release_obj, "body");
+
+ auto normalized_tag = release.tag_name;
+ if (normalized_tag.startsWith(QLatin1Char('v'), Qt::CaseInsensitive))
+ {
+ normalized_tag.remove(0, 1);
+ }
+ release.version = Version(normalized_tag);
+
+ auto release_assets_obj = Json::requireArray(release_obj, "assets");
+ for (auto asset_json : release_assets_obj)
+ {
+ auto asset_obj = Json::requireObject(asset_json);
+ ReleaseAsset asset = {};
+ asset.name = Json::requireString(asset_obj, "name");
+ asset.label = Json::ensureString(asset_obj, "label");
+ asset.content_type = Json::ensureString(asset_obj, "content_type");
+ asset.size = Json::ensureInteger(asset_obj, "size", 0);
+ asset.created_at = parseOptionalDateTime(asset_obj, "created_at");
+ asset.updated_at = parseOptionalDateTime(asset_obj, "updated_at");
+ asset.download_url = parseAssetUrl(asset_obj);
+ if (!asset.download_url.isValid())
+ {
+ throw Json::JsonException(QString("Asset '%1' is missing a valid download url").arg(asset.name));
+ }
+ release.assets.append(asset);
+ }
+
+ m_releases.append(release);
+ num_releases++;
+ }
+ }
+ catch (Json::JsonException& e)
+ {
+ auto err_msg = QString("Failed to parse release feed: %1\n%2").arg(e.what()).arg(QString::fromUtf8(response));
+ fail(err_msg);
+ }
+ return num_releases;
+}
+
+ReleaseInfo ProjTUpdaterApp::getLatestRelease()
+{
+ ReleaseInfo latest;
+ for (auto release : m_releases)
+ {
+ if (release.draft)
+ continue;
+ if (release.prerelease && !m_allowPreRelease)
+ continue;
+ if (!latest.isValid() || (release.version > latest.version))
+ {
+ latest = release;
+ }
+ }
+ return latest;
+}
+
+namespace
+{
+ struct LineVersion
+ {
+ int x = -1;
+ int y = -1;
+ int z = -1;
+ int t = -1;
+ bool is_rc = false;
+ };
+
+ std::optional<LineVersion> parseLineVersion(QString ver)
+ {
+ ver = ver.trimmed();
+ if (ver.startsWith(QLatin1Char('v'), Qt::CaseInsensitive))
+ {
+ ver.remove(0, 1);
+ }
+
+ while (ver.endsWith(QLatin1Char('.')))
+ {
+ ver.chop(1);
+ }
+
+ const auto dash_parts = ver.split(QLatin1Char('-'), Qt::KeepEmptyParts);
+ if (dash_parts.size() >= 2)
+ {
+ const auto main_part = dash_parts.at(0);
+ const auto t_part = dash_parts.at(1);
+
+ bool ok_t = false;
+ bool is_rc = false;
+ int t = -1;
+ if (t_part.startsWith("rc", Qt::CaseInsensitive))
+ {
+ const auto rc_part = t_part.mid(2);
+ t = rc_part.toInt(&ok_t);
+ is_rc = ok_t;
+ }
+ else
+ {
+ t = t_part.toInt(&ok_t);
+ }
+ if (!ok_t)
+ return std::nullopt;
+
+ const auto dot_parts = main_part.split(QLatin1Char('.'), Qt::KeepEmptyParts);
+ if (dot_parts.size() != 3)
+ return std::nullopt;
+
+ bool ok_x = false;
+ bool ok_y = false;
+ bool ok_z = false;
+ const auto x = dot_parts.at(0).toInt(&ok_x);
+ const auto y = dot_parts.at(1).toInt(&ok_y);
+ const auto z = dot_parts.at(2).toInt(&ok_z);
+ if (!ok_x || !ok_y || !ok_z)
+ return std::nullopt;
+
+ return LineVersion{ x, y, z, t, is_rc };
+ }
+
+ const auto dot_parts = ver.split(QLatin1Char('.'), Qt::KeepEmptyParts);
+ if (dot_parts.size() != 4)
+ return std::nullopt;
+
+ bool ok_x = false;
+ bool ok_y = false;
+ bool ok_z = false;
+ bool ok_t = false;
+ const auto x = dot_parts.at(0).toInt(&ok_x);
+ const auto y = dot_parts.at(1).toInt(&ok_y);
+ const auto z = dot_parts.at(2).toInt(&ok_z);
+ const auto t = dot_parts.at(3).toInt(&ok_t);
+ if (!ok_x || !ok_y || !ok_z || !ok_t)
+ return std::nullopt;
+
+ return LineVersion{ x, y, z, t, false };
+ }
+
+ int compareLine(const LineVersion& a, const LineVersion& b)
+ {
+ if (a.x != b.x)
+ return a.x < b.x ? -1 : 1;
+ if (a.y != b.y)
+ return a.y < b.y ? -1 : 1;
+ if (a.z != b.z)
+ return a.z < b.z ? -1 : 1;
+ return 0;
+ }
+
+ int compareLinePatch(const LineVersion& a, const LineVersion& b)
+ {
+ if (a.is_rc != b.is_rc)
+ return a.is_rc ? -1 : 1;
+ if (a.t != b.t)
+ return a.t < b.t ? -1 : 1;
+ return 0;
+ }
+} // namespace
+
+ProjTUpdaterApp::UpdateCandidate ProjTUpdaterApp::findUpdateCandidate()
+{
+ auto normalizeVersionString = [](QString ver)
+ {
+ ver = ver.trimmed();
+ if (ver.startsWith(QLatin1Char('v'), Qt::CaseInsensitive))
+ {
+ ver.remove(0, 1);
+ }
+ return ver;
+ };
+
+ auto appendChannelIfMissing = [](QString ver, const QString& channel)
+ {
+ if (!channel.isEmpty() && channel != "stable" && !ver.contains(QString("-%1").arg(channel)))
+ {
+ ver += QString("-%1").arg(channel);
+ }
+ return ver;
+ };
+
+ auto buildVersionFromConfig = [appendChannelIfMissing]()
+ {
+ QString ver = BuildConfig.versionString();
+ if (BuildConfig.VERSION_CHANNEL != "stable" && BuildConfig.GIT_TAG != ver)
+ {
+ ver = appendChannelIfMissing(ver, BuildConfig.VERSION_CHANNEL);
+ }
+ return ver;
+ };
+
+ QString current_version = m_projtVersion;
+ if (current_version.isEmpty())
+ {
+ current_version = buildVersionFromConfig();
+ }
+
+ // If we parsed a channel from the running binary, ensure it is present.
+ current_version = appendChannelIfMissing(current_version, m_prsimVersionChannel);
+
+ current_version = normalizeVersionString(current_version);
+ auto parsed_current = parseLineVersion(current_version);
+
+ UpdateCandidate candidate;
+
+ if (!parsed_current.has_value())
+ {
+ auto latest = getLatestRelease();
+ if (latest.isValid() && Version(current_version) < latest.version)
+ {
+ candidate.kind = UpdateKind::Update;
+ candidate.release = latest;
+ }
+ return candidate;
+ }
+
+ const auto current = parsed_current.value();
+
+ bool has_same_line = false;
+ bool has_migration = false;
+ LineVersion best_line = current;
+ LineVersion best_migration_line;
+ ReleaseInfo best_same_release;
+ ReleaseInfo best_migration_release;
+
+ for (const auto& release : m_releases)
+ {
+ if (release.draft)
+ continue;
+ if (release.prerelease && !m_allowPreRelease)
+ continue;
+
+ auto parsed_release = parseLineVersion(release.tag_name);
+ if (!parsed_release.has_value())
+ continue;
+
+ auto rel = parsed_release.value();
+ auto line_cmp = compareLine(rel, current);
+ if (line_cmp == 0)
+ {
+ if (compareLinePatch(rel, current) > 0 && (!has_same_line || compareLinePatch(rel, best_line) > 0))
+ {
+ best_line = rel;
+ best_same_release = release;
+ has_same_line = true;
+ }
+ continue;
+ }
+ if (line_cmp > 0)
+ {
+ if (!has_migration)
+ {
+ best_migration_line = rel;
+ best_migration_release = release;
+ has_migration = true;
+ continue;
+ }
+ auto best_cmp = compareLine(rel, best_migration_line);
+ if (best_cmp > 0 || (best_cmp == 0 && compareLinePatch(rel, best_migration_line) > 0))
+ {
+ best_migration_line = rel;
+ best_migration_release = release;
+ }
+ }
+ }
+
+ if (has_same_line)
+ {
+ candidate.kind = UpdateKind::Update;
+ candidate.release = best_same_release;
+ return candidate;
+ }
+
+ if (has_migration)
+ {
+ candidate.kind = UpdateKind::Migration;
+ candidate.release = best_migration_release;
+ return candidate;
+ }
+
+ return candidate;
+}
+
+void ProjTUpdaterApp::downloadError(QString reason)
+{
+ fail(QString("Network request Failed: %1 with reason %2").arg(m_current_url).arg(reason));
+}