summaryrefslogtreecommitdiff
path: root/archived/projt-launcher/launcher/updater
diff options
context:
space:
mode:
Diffstat (limited to 'archived/projt-launcher/launcher/updater')
-rw-r--r--archived/projt-launcher/launcher/updater/ExternalUpdater.h114
-rw-r--r--archived/projt-launcher/launcher/updater/MacSparkleUpdater.h151
-rw-r--r--archived/projt-launcher/launcher/updater/MacSparkleUpdater.mm264
-rw-r--r--archived/projt-launcher/launcher/updater/ProjTExternalUpdater.cpp430
-rw-r--r--archived/projt-launcher/launcher/updater/ProjTExternalUpdater.h125
-rw-r--r--archived/projt-launcher/launcher/updater/projtupdater/ProjTUpdater.cpp1784
-rw-r--r--archived/projt-launcher/launcher/updater/projtupdater/ProjTUpdater.h199
-rw-r--r--archived/projt-launcher/launcher/updater/projtupdater/ReleaseInfo.cpp88
-rw-r--r--archived/projt-launcher/launcher/updater/projtupdater/ReleaseInfo.h68
-rw-r--r--archived/projt-launcher/launcher/updater/projtupdater/SelectReleaseDialog.ui89
-rw-r--r--archived/projt-launcher/launcher/updater/projtupdater/UpdaterDialogs.cpp211
-rw-r--r--archived/projt-launcher/launcher/updater/projtupdater/UpdaterDialogs.h109
-rw-r--r--archived/projt-launcher/launcher/updater/projtupdater/updater.exe.manifest26
-rw-r--r--archived/projt-launcher/launcher/updater/projtupdater/updater_main.cpp64
14 files changed, 3722 insertions, 0 deletions
diff --git a/archived/projt-launcher/launcher/updater/ExternalUpdater.h b/archived/projt-launcher/launcher/updater/ExternalUpdater.h
new file mode 100644
index 0000000000..2522425223
--- /dev/null
+++ b/archived/projt-launcher/launcher/updater/ExternalUpdater.h
@@ -0,0 +1,114 @@
+// 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 Kenneth Chew <kenneth.c0@protonmail.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.
+ *
+ *
+ *
+ * ======================================================================== */
+
+#ifndef LAUNCHER_EXTERNALUPDATER_H
+#define LAUNCHER_EXTERNALUPDATER_H
+
+#include <QObject>
+
+/*!
+ * A base class for an updater that uses an external library.
+ * This class contains basic functions to control the updater.
+ *
+ * To implement the updater on a new platform, create a new class that inherits from this class and
+ * implement the pure virtual functions.
+ *
+ * The initializer of the new class should have the side effect of starting the automatic updater. That is,
+ * once the class is initialized, the program should automatically check for updates if necessary.
+ */
+class ExternalUpdater : public QObject
+{
+ Q_OBJECT
+
+ public:
+ /*!
+ * Check for updates manually, showing the user a progress bar and an alert if no updates are found.
+ */
+ virtual void checkForUpdates() = 0;
+
+ /*!
+ * Indicates whether or not to check for updates automatically.
+ */
+ virtual bool getAutomaticallyChecksForUpdates() = 0;
+
+ /*!
+ * Indicates the current automatic update check interval in seconds.
+ */
+ virtual double getUpdateCheckInterval() = 0;
+
+ /*!
+ * Indicates whether or not beta updates should be checked for in addition to regular releases.
+ */
+ virtual bool getBetaAllowed() = 0;
+
+ /*!
+ * Set whether or not to check for updates automatically.
+ */
+ virtual void setAutomaticallyChecksForUpdates(bool check) = 0;
+
+ /*!
+ * Set the current automatic update check interval in seconds.
+ */
+ virtual void setUpdateCheckInterval(double seconds) = 0;
+
+ /*!
+ * Set whether or not beta updates should be checked for in addition to regular releases.
+ */
+ virtual void setBetaAllowed(bool allowed) = 0;
+
+ signals:
+ /*!
+ * Emits whenever the user's ability to check for updates changes.
+ *
+ * As per Sparkle documentation, "An update check can be made by the user when an update session isn’t in progress,
+ * or when an update or its progress is being shown to the user. A user cannot check for updates when data (such
+ * as the feed or an update) is still being downloaded automatically in the background.
+ *
+ * This property is suitable to use for menu item validation for seeing if checkForUpdates can be invoked."
+ */
+ void canCheckForUpdatesChanged(bool canCheck);
+};
+
+#endif // LAUNCHER_EXTERNALUPDATER_H
diff --git a/archived/projt-launcher/launcher/updater/MacSparkleUpdater.h b/archived/projt-launcher/launcher/updater/MacSparkleUpdater.h
new file mode 100644
index 0000000000..8e689eeb71
--- /dev/null
+++ b/archived/projt-launcher/launcher/updater/MacSparkleUpdater.h
@@ -0,0 +1,151 @@
+// 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 Kenneth Chew <kenneth.c0@protonmail.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.
+ *
+ *
+ *
+ * ======================================================================== */
+
+#ifndef LAUNCHER_MACSPARKLEUPDATER_H
+#define LAUNCHER_MACSPARKLEUPDATER_H
+
+#include <QObject>
+#include <QSet>
+#include "ExternalUpdater.h"
+
+/*!
+ * An implementation for the updater on macOS that uses the Sparkle framework.
+ */
+class MacSparkleUpdater : public ExternalUpdater
+{
+ Q_OBJECT
+
+ public:
+ /*!
+ * Start the Sparkle updater, which automatically checks for updates if necessary.
+ */
+ MacSparkleUpdater();
+ ~MacSparkleUpdater() override;
+
+ /*!
+ * Check for updates manually, showing the user a progress bar and an alert if no updates are found.
+ */
+ void checkForUpdates() override;
+
+ /*!
+ * Indicates whether or not to check for updates automatically.
+ */
+ bool getAutomaticallyChecksForUpdates() override;
+
+ /*!
+ * Indicates the current automatic update check interval in seconds.
+ */
+ double getUpdateCheckInterval() override;
+
+ /*!
+ * Indicates the set of Sparkle channels the updater is allowed to find new updates from.
+ */
+ QSet<QString> getAllowedChannels();
+
+ /*!
+ * Indicates whether or not beta updates should be checked for in addition to regular releases.
+ */
+ bool getBetaAllowed() override;
+
+ /*!
+ * Set whether or not to check for updates automatically.
+ *
+ * As per Sparkle documentation, "By default, Sparkle asks users on second launch for permission if they want
+ * automatic update checks enabled and sets this property based on their response. If SUEnableAutomaticChecks is
+ * set in the Info.plist, this permission request is not performed however.
+ *
+ * Setting this property will persist in the host bundle’s user defaults. Only set this property if you need
+ * dynamic behavior (e.g. user preferences).
+ *
+ * The update schedule cycle will be reset in a short delay after the property’s new value is set. This is to allow
+ * reverting this property without kicking off a schedule change immediately."
+ */
+ void setAutomaticallyChecksForUpdates(bool check) override;
+
+ /*!
+ * Set the current automatic update check interval in seconds.
+ *
+ * As per Sparkle documentation, "Setting this property will persist in the host bundle’s user defaults. For this
+ * reason, only set this property if you need dynamic behavior (eg user preferences). Otherwise prefer to set
+ * SUScheduledCheckInterval directly in your Info.plist.
+ *
+ * The update schedule cycle will be reset in a short delay after the property’s new value is set. This is to allow
+ * reverting this property without kicking off a schedule change immediately."
+ */
+ void setUpdateCheckInterval(double seconds) override;
+
+ /*!
+ * Clears all allowed Sparkle channels, returning to the default updater channel behavior.
+ */
+ void clearAllowedChannels();
+
+ /*!
+ * Set a single Sparkle channel the updater is allowed to find new updates from.
+ *
+ * Items in the default channel can always be found, regardless of this setting. If an empty string is passed,
+ * return to the default behavior.
+ */
+ void setAllowedChannel(const QString& channel);
+
+ /*!
+ * Set a set of Sparkle channels the updater is allowed to find new updates from.
+ *
+ * Items in the default channel can always be found, regardless of this setting. If an empty set is passed,
+ * return to the default behavior.
+ */
+ void setAllowedChannels(const QSet<QString>& channels);
+
+ /*!
+ * Set whether or not beta updates should be checked for in addition to regular releases.
+ */
+ void setBetaAllowed(bool allowed) override;
+
+ private:
+ class Private;
+
+ Private* priv;
+};
+
+#endif // LAUNCHER_MACSPARKLEUPDATER_H
diff --git a/archived/projt-launcher/launcher/updater/MacSparkleUpdater.mm b/archived/projt-launcher/launcher/updater/MacSparkleUpdater.mm
new file mode 100644
index 0000000000..82de1074ee
--- /dev/null
+++ b/archived/projt-launcher/launcher/updater/MacSparkleUpdater.mm
@@ -0,0 +1,264 @@
+// 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 Kenneth Chew <kenneth.c0@protonmail.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 "MacSparkleUpdater.h"
+
+#include "Application.h"
+#include "BuildConfig.h"
+
+#include <Cocoa/Cocoa.h>
+#include <Sparkle/Sparkle.h>
+
+@interface UpdaterObserver : NSObject
+
+@property(nonatomic, readonly) SPUUpdater* updater;
+
+/// A callback to run when the state of `canCheckForUpdates` for the `updater` changes.
+@property(nonatomic, copy) void (^callback)(bool);
+
+- (id)initWithUpdater:(SPUUpdater*)updater;
+
+@end
+
+@implementation UpdaterObserver
+
+- (id)initWithUpdater:(SPUUpdater*)updater {
+ self = [super init];
+ _updater = updater;
+ [self addObserver:self forKeyPath:@"updater.canCheckForUpdates" options:NSKeyValueObservingOptionNew context:nil];
+
+ return self;
+}
+
+- (void)observeValueForKeyPath:(NSString*)keyPath
+ ofObject:(id)object
+ change:(NSDictionary<NSKeyValueChangeKey, id>*)change
+ context:(void*)context {
+ if ([keyPath isEqualToString:@"updater.canCheckForUpdates"]) {
+ bool canCheck = [change[NSKeyValueChangeNewKey] boolValue];
+ self.callback(canCheck);
+ }
+}
+
+@end
+
+@interface UpdaterDelegate : NSObject <SPUUpdaterDelegate>
+
+@property(nonatomic, copy) NSSet<NSString*>* allowedChannels;
+
+@end
+
+@implementation UpdaterDelegate
+
+- (NSSet<NSString*>*)allowedChannelsForUpdater:(SPUUpdater*)updater {
+ return _allowedChannels;
+}
+
+@end
+
+class MacSparkleUpdater::Private {
+ public:
+ SPUStandardUpdaterController* updaterController;
+ UpdaterObserver* updaterObserver;
+ UpdaterDelegate* updaterDelegate;
+ NSAutoreleasePool* autoReleasePool;
+ QString lineChannel;
+ QString migrationChannel;
+};
+
+MacSparkleUpdater::MacSparkleUpdater() {
+ priv = new MacSparkleUpdater::Private();
+
+ // Enable Cocoa's memory management.
+ NSApplicationLoad();
+ priv->autoReleasePool = [[NSAutoreleasePool alloc] init];
+
+ // Delegate is used for setting/getting allowed update channels.
+ priv->updaterDelegate = [[UpdaterDelegate alloc] init];
+
+ // Controller is the interface for actually doing the updates.
+ priv->updaterController = [[SPUStandardUpdaterController alloc] initWithStartingUpdater:true
+ updaterDelegate:priv->updaterDelegate
+ userDriverDelegate:nil];
+
+ priv->updaterObserver = [[UpdaterObserver alloc] initWithUpdater:priv->updaterController.updater];
+ // Use KVO to run a callback that emits a Qt signal when `canCheckForUpdates` changes, so the UI can respond
+ // accordingly.
+ priv->updaterObserver.callback = ^(bool canCheck) {
+ emit canCheckForUpdatesChanged(canCheck);
+ };
+
+ auto extractLineChannel = [](QString version) -> QString {
+ version = version.trimmed();
+ if (version.startsWith(QLatin1Char('v'), Qt::CaseInsensitive)) {
+ version.remove(0, 1);
+ }
+ while (version.endsWith(QLatin1Char('.'))) {
+ version.chop(1);
+ }
+
+ if (version.contains(QLatin1Char('-'))) {
+ return version.section(QLatin1Char('-'), 0, 0);
+ }
+
+ const auto parts = version.split(QLatin1Char('.'), Qt::KeepEmptyParts);
+ if (parts.size() >= 3) {
+ return QString("%1.%2.%3").arg(parts.at(0), parts.at(1), parts.at(2));
+ }
+ return QString();
+ };
+
+ priv->lineChannel = extractLineChannel(BuildConfig.printableVersionString());
+ if (!priv->lineChannel.isEmpty()) {
+ const auto parts = priv->lineChannel.split(QLatin1Char('.'), Qt::KeepEmptyParts);
+ if (parts.size() == 3) {
+ bool ok = false;
+ const auto z = parts.at(2).toInt(&ok);
+ if (ok) {
+ priv->migrationChannel = QString("%1.%2.%3").arg(parts.at(0), parts.at(1)).arg(z + 1);
+ }
+ }
+
+ QSet<QString> channels{priv->lineChannel};
+ if (!priv->migrationChannel.isEmpty()) {
+ channels.insert(priv->migrationChannel);
+ }
+ setAllowedChannels(channels);
+ }
+}
+
+MacSparkleUpdater::~MacSparkleUpdater() {
+ [priv->updaterObserver removeObserver:priv->updaterObserver forKeyPath:@"updater.canCheckForUpdates"];
+
+ [priv->updaterController release];
+ [priv->updaterObserver release];
+ [priv->updaterDelegate release];
+ [priv->autoReleasePool release];
+ delete priv;
+}
+
+void MacSparkleUpdater::checkForUpdates() {
+ [priv->updaterController checkForUpdates:nil];
+}
+
+bool MacSparkleUpdater::getAutomaticallyChecksForUpdates() {
+ return priv->updaterController.updater.automaticallyChecksForUpdates;
+}
+
+double MacSparkleUpdater::getUpdateCheckInterval() {
+ return priv->updaterController.updater.updateCheckInterval;
+}
+
+QSet<QString> MacSparkleUpdater::getAllowedChannels() {
+ // Convert NSSet<NSString> -> QSet<QString>
+ __block QSet<QString> channels;
+ [priv->updaterDelegate.allowedChannels enumerateObjectsUsingBlock:^(NSString* channel, BOOL* stop) {
+ channels.insert(QString::fromNSString(channel));
+ }];
+ return channels;
+}
+
+bool MacSparkleUpdater::getBetaAllowed() {
+ return getAllowedChannels().contains("beta");
+}
+
+void MacSparkleUpdater::setAutomaticallyChecksForUpdates(bool check) {
+ priv->updaterController.updater.automaticallyChecksForUpdates = check ? YES : NO; // make clang-tidy happy
+}
+
+void MacSparkleUpdater::setUpdateCheckInterval(double seconds) {
+ priv->updaterController.updater.updateCheckInterval = seconds;
+}
+
+void MacSparkleUpdater::clearAllowedChannels() {
+ priv->updaterDelegate.allowedChannels = [NSSet set];
+}
+
+void MacSparkleUpdater::setAllowedChannel(const QString& channel) {
+ if (channel.isEmpty()) {
+ clearAllowedChannels();
+ return;
+ }
+
+ NSSet<NSString*>* nsChannels = [NSSet setWithObject:channel.toNSString()];
+ priv->updaterDelegate.allowedChannels = nsChannels;
+}
+
+void MacSparkleUpdater::setAllowedChannels(const QSet<QString>& channels) {
+ if (channels.isEmpty()) {
+ clearAllowedChannels();
+ return;
+ }
+
+ QString channelsConfig = "";
+ // Convert QSet<QString> -> NSSet<NSString>
+ NSMutableSet<NSString*>* nsChannels = [NSMutableSet setWithCapacity:channels.count()];
+ for (const QString& channel : channels) {
+ [nsChannels addObject:channel.toNSString()];
+ channelsConfig += channel + " ";
+ }
+
+ priv->updaterDelegate.allowedChannels = nsChannels;
+}
+
+void MacSparkleUpdater::setBetaAllowed(bool allowed) {
+ if (priv->lineChannel.isEmpty()) {
+ if (allowed) {
+ setAllowedChannel("beta");
+ } else {
+ clearAllowedChannels();
+ }
+ return;
+ }
+
+ QSet<QString> channels{priv->lineChannel};
+ if (!priv->migrationChannel.isEmpty()) {
+ channels.insert(priv->migrationChannel);
+ }
+ if (allowed) {
+ channels.insert("beta");
+ }
+ setAllowedChannels(channels);
+}
diff --git a/archived/projt-launcher/launcher/updater/ProjTExternalUpdater.cpp b/archived/projt-launcher/launcher/updater/ProjTExternalUpdater.cpp
new file mode 100644
index 0000000000..2b6ef19015
--- /dev/null
+++ b/archived/projt-launcher/launcher/updater/ProjTExternalUpdater.cpp
@@ -0,0 +1,430 @@
+// 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) 2023 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 "ProjTExternalUpdater.h"
+#include <QCoreApplication>
+#include <QDateTime>
+#include <QDebug>
+#include <QDir>
+#include <QMessageBox>
+#include <QProcess>
+#include <QProgressDialog>
+#include <QSettings>
+#include <QTimer>
+#include <algorithm>
+#include <memory>
+
+#include "StringUtils.h"
+
+#include "BuildConfig.h"
+
+#include "ui/dialogs/UpdateAvailableDialog.h"
+
+class ProjTExternalUpdater::Private
+{
+ public:
+ QDir appDir;
+ QDir dataDir;
+ QTimer updateTimer;
+ bool allowBeta{};
+ bool autoCheck{};
+ double updateInterval{};
+ QDateTime lastCheck;
+ std::unique_ptr<QSettings> settings;
+
+ QWidget* parent{};
+};
+
+ProjTExternalUpdater::ProjTExternalUpdater(QWidget* parent, const QString& appDir, const QString& dataDir)
+ : priv(new ProjTExternalUpdater::Private())
+{
+ priv->appDir = QDir(appDir);
+ priv->dataDir = QDir(dataDir);
+ auto settings_file = priv->dataDir.absoluteFilePath("projtlauncher_update.cfg");
+ priv->settings = std::make_unique<QSettings>(settings_file, QSettings::Format::IniFormat);
+ priv->allowBeta = priv->settings->value("allow_beta", false).toBool();
+ priv->autoCheck = priv->settings->value("auto_check", false).toBool();
+ bool interval_ok = false;
+ // default once per day
+ priv->updateInterval = priv->settings->value("update_interval", 86400).toInt(&interval_ok);
+ if (!interval_ok)
+ priv->updateInterval = 86400;
+ auto last_check = priv->settings->value("last_check");
+ if (!last_check.isNull() && last_check.isValid())
+ {
+ priv->lastCheck = QDateTime::fromString(last_check.toString(), Qt::ISODate);
+ }
+ priv->parent = parent;
+ connectTimer();
+ resetAutoCheckTimer();
+ if (priv->updateInterval == 0)
+ checkForUpdates(false);
+}
+
+ProjTExternalUpdater::~ProjTExternalUpdater()
+{
+ if (priv->updateTimer.isActive())
+ priv->updateTimer.stop();
+ disconnectTimer();
+ priv->settings->sync();
+ delete priv;
+}
+
+void ProjTExternalUpdater::checkForUpdates()
+{
+ checkForUpdates(true);
+}
+
+void ProjTExternalUpdater::checkForUpdates(bool triggeredByUser)
+{
+ QProgressDialog progress(tr("Checking for updates..."), "", 0, 0, priv->parent);
+ progress.setCancelButton(nullptr);
+ progress.adjustSize();
+ if (triggeredByUser)
+ progress.show();
+ QCoreApplication::processEvents();
+
+ QProcess proc;
+ 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 = QString("bin/%1").arg(exe_name);
+#endif
+
+ QStringList args = { "--check-only", "--dir", priv->dataDir.absolutePath(), "--debug" };
+ if (priv->allowBeta)
+ args.append("--pre-release");
+
+ proc.start(priv->appDir.absoluteFilePath(exe_name), args);
+ auto result_start = proc.waitForStarted(5000);
+ if (!result_start)
+ {
+ auto err = proc.error();
+ qDebug() << "Failed to start updater after 5 seconds."
+ << "reason:" << err << proc.errorString();
+ auto msgBox = QMessageBox(QMessageBox::Information,
+ tr("Update Check Failed"),
+ tr("Failed to start after 5 seconds\nReason: %1.").arg(proc.errorString()),
+ QMessageBox::Ok,
+ priv->parent);
+ msgBox.setMinimumWidth(460);
+ msgBox.adjustSize();
+ msgBox.exec();
+ priv->lastCheck = QDateTime::currentDateTime();
+ priv->settings->setValue("last_check", priv->lastCheck.toString(Qt::ISODate));
+ priv->settings->sync();
+ resetAutoCheckTimer();
+ return;
+ }
+ QCoreApplication::processEvents();
+
+ auto result_finished = proc.waitForFinished(60000);
+ if (!result_finished)
+ {
+ proc.kill();
+ auto err = proc.error();
+ auto output = proc.readAll();
+ qDebug() << "Updater failed to close after 60 seconds."
+ << "reason:" << err << proc.errorString();
+ auto msgBox = QMessageBox(QMessageBox::Information,
+ tr("Update Check Failed"),
+ tr("Updater failed to close 60 seconds\nReason: %1.").arg(proc.errorString()),
+ QMessageBox::Ok,
+ priv->parent);
+ msgBox.setDetailedText(output);
+ msgBox.setMinimumWidth(460);
+ msgBox.adjustSize();
+ msgBox.exec();
+ priv->lastCheck = QDateTime::currentDateTime();
+ priv->settings->setValue("last_check", priv->lastCheck.toString(Qt::ISODate));
+ priv->settings->sync();
+ resetAutoCheckTimer();
+ return;
+ }
+
+ auto exit_code = proc.exitCode();
+
+ auto std_output = proc.readAllStandardOutput();
+ auto std_error = proc.readAllStandardError();
+
+ progress.hide();
+ QCoreApplication::processEvents();
+
+ switch (exit_code)
+ {
+ case 0:
+ // no update available
+ if (triggeredByUser)
+ {
+ qDebug() << "No update available";
+ auto msgBox = QMessageBox(QMessageBox::Information,
+ tr("No Update Available"),
+ tr("You are running the latest version."),
+ QMessageBox::Ok,
+ priv->parent);
+ msgBox.setMinimumWidth(460);
+ msgBox.adjustSize();
+ msgBox.exec();
+ }
+ break;
+ case 1:
+ // there was an error
+ {
+ qDebug() << "Updater subprocess error" << qPrintable(std_error);
+ auto msgBox = QMessageBox(QMessageBox::Warning,
+ tr("Update Check Error"),
+ tr("There was an error running the update check."),
+ QMessageBox::Ok,
+ priv->parent);
+ msgBox.setDetailedText(QString(std_error));
+ msgBox.setMinimumWidth(460);
+ msgBox.adjustSize();
+ msgBox.exec();
+ }
+ break;
+ case 100:
+ case 101:
+ // update or migration available
+ {
+ auto [first_line, remainder1] = StringUtils::splitFirst(std_output, '\n');
+ auto [second_line, remainder2] = StringUtils::splitFirst(remainder1, '\n');
+ auto [third_line, release_notes] = StringUtils::splitFirst(remainder2, '\n');
+ auto version_name = StringUtils::splitFirst(first_line, ": ").second.trimmed();
+ auto version_tag = StringUtils::splitFirst(second_line, ": ").second.trimmed();
+ auto release_timestamp =
+ QDateTime::fromString(StringUtils::splitFirst(third_line, ": ").second.trimmed(), Qt::ISODate);
+ if (exit_code == 100)
+ qDebug() << "Update available:" << version_name << version_tag << release_timestamp;
+ else
+ qDebug() << "Migration available:" << version_name << version_tag << release_timestamp;
+ qDebug() << "Update release notes:" << release_notes;
+
+ offerUpdate(version_name, version_tag, release_notes, exit_code == 101);
+ }
+ break;
+ default:
+ // unknown error code
+ {
+ qDebug() << "Updater exited with unknown code" << exit_code;
+ auto msgBox = QMessageBox(
+ QMessageBox::Information,
+ tr("Unknown Update Error"),
+ tr("The updater exited with an unknown condition.\nExit Code: %1").arg(QString::number(exit_code)),
+ QMessageBox::Ok,
+ priv->parent);
+ auto detail_txt = tr("StdOut: %1\nStdErr: %2").arg(QString(std_output)).arg(QString(std_error));
+ msgBox.setDetailedText(detail_txt);
+ msgBox.setMinimumWidth(460);
+ msgBox.adjustSize();
+ msgBox.exec();
+ }
+ }
+ priv->lastCheck = QDateTime::currentDateTime();
+ priv->settings->setValue("last_check", priv->lastCheck.toString(Qt::ISODate));
+ priv->settings->sync();
+ resetAutoCheckTimer();
+}
+
+bool ProjTExternalUpdater::getAutomaticallyChecksForUpdates()
+{
+ return priv->autoCheck;
+}
+
+double ProjTExternalUpdater::getUpdateCheckInterval()
+{
+ return priv->updateInterval;
+}
+
+bool ProjTExternalUpdater::getBetaAllowed()
+{
+ return priv->allowBeta;
+}
+
+void ProjTExternalUpdater::setAutomaticallyChecksForUpdates(bool check)
+{
+ priv->autoCheck = check;
+ priv->settings->setValue("auto_check", check);
+ priv->settings->sync();
+ resetAutoCheckTimer();
+}
+
+void ProjTExternalUpdater::setUpdateCheckInterval(double seconds)
+{
+ priv->updateInterval = seconds;
+ priv->settings->setValue("update_interval", seconds);
+ priv->settings->sync();
+ resetAutoCheckTimer();
+}
+
+void ProjTExternalUpdater::setBetaAllowed(bool allowed)
+{
+ priv->allowBeta = allowed;
+ priv->settings->setValue("allow_beta", allowed);
+ priv->settings->sync();
+}
+
+void ProjTExternalUpdater::resetAutoCheckTimer()
+{
+ if (priv->autoCheck && priv->updateInterval > 0)
+ {
+ qint64 timeoutMs = 0;
+ auto now = QDateTime::currentDateTime();
+ if (priv->lastCheck.isValid())
+ {
+ qint64 diff = priv->lastCheck.secsTo(now);
+ qint64 secs_left = std::max<qint64>(priv->updateInterval - diff, 0);
+ timeoutMs = secs_left * 1000;
+ }
+ timeoutMs = std::min(timeoutMs, static_cast<qint64>(INT_MAX));
+
+ qDebug() << "Auto update timer starting," << timeoutMs / 1000 << "seconds left";
+ priv->updateTimer.start(static_cast<int>(timeoutMs));
+ }
+ else
+ {
+ if (priv->updateTimer.isActive())
+ priv->updateTimer.stop();
+ }
+}
+
+void ProjTExternalUpdater::connectTimer()
+{
+ connect(&priv->updateTimer, &QTimer::timeout, this, &ProjTExternalUpdater::autoCheckTimerFired);
+}
+
+void ProjTExternalUpdater::disconnectTimer()
+{
+ disconnect(&priv->updateTimer, &QTimer::timeout, this, &ProjTExternalUpdater::autoCheckTimerFired);
+}
+
+void ProjTExternalUpdater::autoCheckTimerFired()
+{
+ qDebug() << "Auto update Timer fired";
+ checkForUpdates(false);
+}
+
+void ProjTExternalUpdater::offerUpdate(const QString& version_name,
+ const QString& version_tag,
+ const QString& release_notes,
+ bool isMigration)
+{
+ priv->settings->beginGroup("skip");
+ auto should_skip = priv->settings->value(version_tag, false).toBool();
+ priv->settings->endGroup();
+
+ if (should_skip)
+ {
+ auto msgBox = QMessageBox(QMessageBox::Information,
+ tr("No Update Available"),
+ tr("There are no new updates available."),
+ QMessageBox::Ok,
+ priv->parent);
+ msgBox.setMinimumWidth(460);
+ msgBox.adjustSize();
+ msgBox.exec();
+ return;
+ }
+
+ UpdateAvailableDialog dlg(BuildConfig.printableVersionString(),
+ version_name,
+ release_notes,
+ isMigration ? UpdateAvailableDialog::Mode::Migration
+ : UpdateAvailableDialog::Mode::Update);
+
+ auto result = dlg.exec();
+ qDebug() << "offer dlg result" << result;
+ switch (result)
+ {
+ case UpdateAvailableDialog::Install:
+ {
+ performUpdate(version_tag);
+ return;
+ }
+ case UpdateAvailableDialog::Skip:
+ {
+ priv->settings->beginGroup("skip");
+ priv->settings->setValue(version_tag, true);
+ priv->settings->endGroup();
+ priv->settings->sync();
+ return;
+ }
+ default: return;
+ }
+}
+
+void ProjTExternalUpdater::performUpdate(const QString& version_tag)
+{
+ QProcess proc;
+ 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 = QString("bin/%1").arg(exe_name);
+#endif
+
+ QStringList args = { "--dir", priv->dataDir.absolutePath(), "--install-version", version_tag };
+ if (priv->allowBeta)
+ args.append("--pre-release");
+
+ proc.setProgram(priv->appDir.absoluteFilePath(exe_name));
+ proc.setArguments(args);
+ auto result = proc.startDetached();
+ if (!result)
+ {
+ qDebug() << "Failed to start updater:" << proc.error() << proc.errorString();
+ }
+ QCoreApplication::exit();
+}
diff --git a/archived/projt-launcher/launcher/updater/ProjTExternalUpdater.h b/archived/projt-launcher/launcher/updater/ProjTExternalUpdater.h
new file mode 100644
index 0000000000..d944a5de5e
--- /dev/null
+++ b/archived/projt-launcher/launcher/updater/ProjTExternalUpdater.h
@@ -0,0 +1,125 @@
+// 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) 2023 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.
+ *
+ *
+ *
+ * ======================================================================== */
+
+#pragma once
+
+#include <QObject>
+
+#include "ExternalUpdater.h"
+
+/*!
+ * An implementation for the updater on windows and linux that uses out external updater.
+ */
+
+class ProjTExternalUpdater : public ExternalUpdater
+{
+ Q_OBJECT
+
+ public:
+ ProjTExternalUpdater(QWidget* parent, const QString& appDir, const QString& dataDir);
+ ~ProjTExternalUpdater() override;
+
+ /*!
+ * Check for updates manually, showing the user a progress bar and an alert if no updates are found.
+ */
+ void checkForUpdates() override;
+ void checkForUpdates(bool triggeredByUser);
+
+ /*!
+ * Indicates whether or not to check for updates automatically.
+ */
+ bool getAutomaticallyChecksForUpdates() override;
+
+ /*!
+ * Indicates the current automatic update check interval in seconds.
+ */
+ double getUpdateCheckInterval() override;
+
+ /*!
+ * Indicates whether or not beta updates should be checked for in addition to regular releases.
+ */
+ bool getBetaAllowed() override;
+
+ /*!
+ * Set whether or not to check for updates automatically.
+ *
+ * The update schedule cycle will be reset in a short delay after the property’s new value is set. This is to allow
+ * reverting this property without kicking off a schedule change immediately."
+ */
+ void setAutomaticallyChecksForUpdates(bool check) override;
+
+ /*!
+ * Set the current automatic update check interval in seconds.
+ *
+ * The update schedule cycle will be reset in a short delay after the property’s new value is set. This is to allow
+ * reverting this property without kicking off a schedule change immediately."
+ */
+ void setUpdateCheckInterval(double seconds) override;
+
+ /*!
+ * Set whether or not beta updates should be checked for in addition to regular releases.
+ */
+ void setBetaAllowed(bool allowed) override;
+
+ void resetAutoCheckTimer();
+ void disconnectTimer();
+ void connectTimer();
+
+ void offerUpdate(const QString& version_name,
+ const QString& version_tag,
+ const QString& release_notes,
+ bool isMigration = false);
+ void performUpdate(const QString& version_tag);
+
+ public slots:
+ void autoCheckTimerFired();
+
+ private:
+ class Private;
+
+ Private* priv;
+};
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));
+}
diff --git a/archived/projt-launcher/launcher/updater/projtupdater/ProjTUpdater.h b/archived/projt-launcher/launcher/updater/projtupdater/ProjTUpdater.h
new file mode 100644
index 0000000000..6f4fde4dbf
--- /dev/null
+++ b/archived/projt-launcher/launcher/updater/projtupdater/ProjTUpdater.h
@@ -0,0 +1,199 @@
+// 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) 2023 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.
+ *
+ *
+ *
+ * ======================================================================== */
+
+#pragma once
+
+#include <QtCore>
+
+#include <QApplication>
+#include <QDataStream>
+#include <QDateTime>
+#include <QDebug>
+#include <QFlag>
+#include <QIcon>
+#include <QLocalSocket>
+#include <QNetworkAccessManager>
+#include <QNetworkReply>
+#include <QUrl>
+#include <memory>
+#include <optional>
+
+#include "QObjectPtr.h"
+#include "net/Download.h"
+
+#define PRISM_EXTERNAL_EXE
+#include "FileSystem.h"
+
+#include "ReleaseInfo.h"
+
+class ProjTUpdaterApp : public QApplication
+{
+ // friends for the purpose of limiting access to deprecated stuff
+ Q_OBJECT
+ public:
+ enum Status
+ {
+ Starting,
+ Failed,
+ Succeeded,
+ Initialized,
+ Aborted
+ };
+ ProjTUpdaterApp(int& argc, char** argv);
+ virtual ~ProjTUpdaterApp();
+ void loadReleaseFeed();
+ void run();
+ Status status() const
+ {
+ return m_status;
+ }
+
+ private:
+ void fail(const QString& reason);
+ void abort(const QString& reason);
+ void showFatalErrorMessage(const QString& title, const QString& content);
+
+ bool loadProjTVersionFromExe(const QString& exe_path);
+
+ void downloadReleaseFeed();
+ int parseReleaseFeed(const QByteArray& response);
+
+ enum class UpdateKind
+ {
+ None,
+ Update,
+ Migration
+ };
+
+ struct UpdateCandidate
+ {
+ UpdateKind kind = UpdateKind::None;
+ ReleaseInfo release;
+ };
+
+ UpdateCandidate findUpdateCandidate();
+
+ ReleaseInfo getLatestRelease();
+ ReleaseInfo selectRelease();
+ QList<ReleaseInfo> newerReleases();
+ QList<ReleaseInfo> nonDraftReleases();
+
+ void printReleases();
+
+ QList<ReleaseAsset> validReleaseArtifacts(const ReleaseInfo& release);
+ ReleaseAsset selectAsset(const QList<ReleaseAsset>& assets);
+ void performUpdate(const ReleaseInfo& release);
+ void performInstall(QFileInfo file);
+ void unpackAndInstall(QFileInfo file);
+ void backupAppDir();
+ std::optional<QDir> unpackArchive(QFileInfo file);
+
+ QFileInfo downloadAsset(const ReleaseAsset& asset);
+ bool callAppImageUpdate();
+
+ void moveAndFinishUpdate(QDir target);
+
+ public slots:
+ void downloadError(QString reason);
+
+ private:
+ const QString& root()
+ {
+ return m_rootPath;
+ }
+
+ bool isPortable()
+ {
+ return m_isPortable;
+ }
+
+ void clearUpdateLog();
+ void logUpdate(const QString& msg);
+
+ QString m_rootPath;
+ QString m_dataPath;
+ bool m_isPortable = false;
+ bool m_isAppimage = false;
+ bool m_isFlatpak = false;
+ QString m_appimagePath;
+ QString m_projtExecutable;
+ QUrl m_releaseFeedUrl;
+ Version m_userSelectedVersion;
+ bool m_checkOnly;
+ bool m_forceUpdate;
+ bool m_printOnly;
+ bool m_selectUI;
+ bool m_allowDowngrade;
+ bool m_allowPreRelease;
+
+ QString m_updateLogPath;
+
+ QString m_projtBinaryName;
+ QString m_projtVersion;
+ int m_projtVersionMajor = -1;
+ int m_projtVersionMinor = -1;
+ int m_projtVersionPatch = -1;
+ QString m_prsimVersionChannel;
+ QString m_projtGitCommit;
+
+ ReleaseInfo m_install_release;
+
+ Status m_status = Status::Starting;
+ shared_qobject_ptr<QNetworkAccessManager> m_network;
+ QString m_current_url;
+ Task::Ptr m_current_task;
+ QList<ReleaseInfo> m_releases;
+
+ public:
+ std::unique_ptr<QFile> logFile;
+ bool logToConsole = false;
+
+#if defined Q_OS_WIN32
+ // used on Windows to attach the standard IO streams
+ bool consoleAttached = false;
+#endif
+};
diff --git a/archived/projt-launcher/launcher/updater/projtupdater/ReleaseInfo.cpp b/archived/projt-launcher/launcher/updater/projtupdater/ReleaseInfo.cpp
new file mode 100644
index 0000000000..8a115972da
--- /dev/null
+++ b/archived/projt-launcher/launcher/updater/projtupdater/ReleaseInfo.cpp
@@ -0,0 +1,88 @@
+// 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
+ * Copyright (C) 2023 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 "ReleaseInfo.h"
+
+QDebug operator<<(QDebug debug, const ReleaseAsset& asset)
+{
+ QDebugStateSaver saver(debug);
+ debug.nospace() << "ReleaseAsset( "
+ "name: "
+ << asset.name
+ << ", "
+ "label: "
+ << asset.label
+ << ", "
+ "content_type: "
+ << asset.content_type
+ << ", "
+ "size: "
+ << asset.size
+ << ", "
+ "created_at: "
+ << asset.created_at
+ << ", "
+ "updated_at: "
+ << asset.updated_at
+ << ", "
+ "download_url: "
+ << asset.download_url
+ << " "
+ ")";
+ return debug;
+}
+
+QDebug operator<<(QDebug debug, const ReleaseInfo& release)
+{
+ QDebugStateSaver saver(debug);
+ debug.nospace() << "ReleaseInfo( "
+ "name: "
+ << release.name
+ << ", "
+ "tag_name: "
+ << release.tag_name
+ << ", "
+ "created_at: "
+ << release.created_at
+ << ", "
+ "published_at: "
+ << release.published_at
+ << ", "
+ "prerelease: "
+ << release.prerelease
+ << ", "
+ "draft: "
+ << release.draft
+ << ", "
+ "version: "
+ << release.version
+ << ", "
+ "body: "
+ << release.body
+ << ", "
+ "assets: "
+ << release.assets
+ << " "
+ ")";
+ return debug;
+}
diff --git a/archived/projt-launcher/launcher/updater/projtupdater/ReleaseInfo.h b/archived/projt-launcher/launcher/updater/projtupdater/ReleaseInfo.h
new file mode 100644
index 0000000000..99da487055
--- /dev/null
+++ b/archived/projt-launcher/launcher/updater/projtupdater/ReleaseInfo.h
@@ -0,0 +1,68 @@
+// 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.
+ *
+ */
+
+#pragma once
+#include <QDateTime>
+#include <QList>
+#include <QString>
+#include <QUrl>
+
+#include <QDebug>
+
+#include "Version.h"
+
+struct ReleaseAsset
+{
+ QString name;
+ QString label;
+ QString content_type;
+ int size = 0;
+ QDateTime created_at;
+ QDateTime updated_at;
+ QUrl download_url;
+
+ bool isValid() const
+ {
+ return !name.isEmpty() && download_url.isValid();
+ }
+};
+
+struct ReleaseInfo
+{
+ QString name;
+ QString tag_name;
+ QDateTime created_at;
+ QDateTime published_at;
+ bool prerelease = false;
+ bool draft = false;
+ QString body;
+ QList<ReleaseAsset> assets;
+ Version version;
+
+ bool isValid() const
+ {
+ return !tag_name.isEmpty();
+ }
+};
+
+QDebug operator<<(QDebug debug, const ReleaseAsset& releaseAsset);
+QDebug operator<<(QDebug debug, const ReleaseInfo& releaseInfo);
diff --git a/archived/projt-launcher/launcher/updater/projtupdater/SelectReleaseDialog.ui b/archived/projt-launcher/launcher/updater/projtupdater/SelectReleaseDialog.ui
new file mode 100644
index 0000000000..a1aa38371b
--- /dev/null
+++ b/archived/projt-launcher/launcher/updater/projtupdater/SelectReleaseDialog.ui
@@ -0,0 +1,89 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>SelectReleaseDialog</class>
+ <widget class="QDialog" name="SelectReleaseDialog">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>468</width>
+ <height>385</height>
+ </rect>
+ </property>
+ <property name="windowTitle">
+ <string>Select Release to Install</string>
+ </property>
+ <property name="sizeGripEnabled">
+ <bool>true</bool>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout" stretch="0,0,1,0">
+ <item>
+ <widget class="QLabel" name="eplainLabel">
+ <property name="text">
+ <string>Please select the release you wish to update to.</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QTreeWidget" name="versionsTree">
+ <property name="alternatingRowColors">
+ <bool>true</bool>
+ </property>
+ <column>
+ <property name="text">
+ <string notr="true">1</string>
+ </property>
+ </column>
+ </widget>
+ </item>
+ <item>
+ <widget class="QTextBrowser" name="changelogTextBrowser"/>
+ </item>
+ <item>
+ <widget class="QDialogButtonBox" name="buttonBox">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ <property name="standardButtons">
+ <set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <resources/>
+ <connections>
+ <connection>
+ <sender>buttonBox</sender>
+ <signal>accepted()</signal>
+ <receiver>SelectReleaseDialog</receiver>
+ <slot>accept()</slot>
+ <hints>
+ <hint type="sourcelabel">
+ <x>248</x>
+ <y>254</y>
+ </hint>
+ <hint type="destinationlabel">
+ <x>157</x>
+ <y>274</y>
+ </hint>
+ </hints>
+ </connection>
+ <connection>
+ <sender>buttonBox</sender>
+ <signal>rejected()</signal>
+ <receiver>SelectReleaseDialog</receiver>
+ <slot>reject()</slot>
+ <hints>
+ <hint type="sourcelabel">
+ <x>316</x>
+ <y>260</y>
+ </hint>
+ <hint type="destinationlabel">
+ <x>286</x>
+ <y>274</y>
+ </hint>
+ </hints>
+ </connection>
+ </connections>
+</ui>
diff --git a/archived/projt-launcher/launcher/updater/projtupdater/UpdaterDialogs.cpp b/archived/projt-launcher/launcher/updater/projtupdater/UpdaterDialogs.cpp
new file mode 100644
index 0000000000..4534d3963c
--- /dev/null
+++ b/archived/projt-launcher/launcher/updater/projtupdater/UpdaterDialogs.cpp
@@ -0,0 +1,211 @@
+// 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) 2023 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 "UpdaterDialogs.h"
+
+#include "ui_SelectReleaseDialog.h"
+
+#include <QPushButton>
+#include <QTextBrowser>
+#include "Markdown.h"
+#include "StringUtils.h"
+
+SelectReleaseDialog::SelectReleaseDialog(const Version& current_version,
+ const QList<ReleaseInfo>& releases,
+ QWidget* parent)
+ : QDialog(parent),
+ m_releases(releases),
+ m_currentVersion(current_version),
+ ui(new Ui::SelectReleaseDialog)
+{
+ ui->setupUi(this);
+
+ ui->changelogTextBrowser->setOpenExternalLinks(true);
+ ui->changelogTextBrowser->setLineWrapMode(QTextBrowser::LineWrapMode::WidgetWidth);
+ ui->changelogTextBrowser->setVerticalScrollBarPolicy(Qt::ScrollBarPolicy::ScrollBarAsNeeded);
+
+ ui->versionsTree->setColumnCount(2);
+
+ ui->versionsTree->header()->setSectionResizeMode(0, QHeaderView::Stretch);
+ ui->versionsTree->header()->setSectionResizeMode(1, QHeaderView::ResizeToContents);
+ ui->versionsTree->setHeaderLabels({ tr("Version"), tr("Published Date") });
+ ui->versionsTree->header()->setStretchLastSection(false);
+
+ ui->eplainLabel->setText(tr("Select a version to install.\n"
+ "\n"
+ "Currently installed version: %1")
+ .arg(m_currentVersion.toString()));
+
+ loadReleases();
+
+ connect(ui->versionsTree, &QTreeWidget::currentItemChanged, this, &SelectReleaseDialog::selectionChanged);
+
+ connect(ui->buttonBox, &QDialogButtonBox::accepted, this, &SelectReleaseDialog::accept);
+ connect(ui->buttonBox, &QDialogButtonBox::rejected, this, &SelectReleaseDialog::reject);
+
+ ui->buttonBox->button(QDialogButtonBox::Cancel)->setText(tr("Cancel"));
+ ui->buttonBox->button(QDialogButtonBox::Ok)->setText(tr("OK"));
+}
+
+SelectReleaseDialog::~SelectReleaseDialog()
+{
+ delete ui;
+}
+
+void SelectReleaseDialog::loadReleases()
+{
+ for (auto rls : m_releases)
+ {
+ appendRelease(rls);
+ }
+}
+
+void SelectReleaseDialog::appendRelease(ReleaseInfo const& release)
+{
+ auto rls_item = new QTreeWidgetItem(ui->versionsTree);
+ rls_item->setText(0, release.tag_name);
+ rls_item->setExpanded(true);
+ rls_item->setText(1, release.published_at.toString());
+ rls_item->setData(0, Qt::UserRole, QVariant(release.tag_name));
+
+ ui->versionsTree->addTopLevelItem(rls_item);
+}
+
+ReleaseInfo SelectReleaseDialog::getRelease(QTreeWidgetItem* item)
+{
+ auto tag_name = item->data(0, Qt::UserRole).toString();
+ ReleaseInfo release;
+ for (auto rls : m_releases)
+ {
+ if (rls.tag_name == tag_name)
+ release = rls;
+ }
+ return release;
+}
+
+void SelectReleaseDialog::selectionChanged(QTreeWidgetItem* current, QTreeWidgetItem* previous)
+{
+ Q_UNUSED(previous)
+ ReleaseInfo release = getRelease(current);
+ QString body = markdownToHTML(release.body.toUtf8());
+ m_selectedRelease = release;
+
+ ui->changelogTextBrowser->setHtml(StringUtils::htmlListPatch(body));
+}
+
+SelectReleaseAssetDialog::SelectReleaseAssetDialog(const QList<ReleaseAsset>& assets, QWidget* parent)
+ : QDialog(parent),
+ m_assets(assets),
+ ui(new Ui::SelectReleaseDialog)
+{
+ ui->setupUi(this);
+
+ ui->changelogTextBrowser->setOpenExternalLinks(true);
+ ui->changelogTextBrowser->setLineWrapMode(QTextBrowser::LineWrapMode::WidgetWidth);
+ ui->changelogTextBrowser->setVerticalScrollBarPolicy(Qt::ScrollBarPolicy::ScrollBarAsNeeded);
+
+ ui->versionsTree->setColumnCount(2);
+
+ ui->versionsTree->header()->setSectionResizeMode(0, QHeaderView::Stretch);
+ ui->versionsTree->header()->setSectionResizeMode(1, QHeaderView::ResizeToContents);
+ ui->versionsTree->setHeaderLabels({ tr("Version"), tr("Published Date") });
+ ui->versionsTree->header()->setStretchLastSection(false);
+
+ ui->eplainLabel->setText(tr("Select a version to install."));
+
+ ui->changelogTextBrowser->setHidden(true);
+
+ loadAssets();
+
+ connect(ui->versionsTree, &QTreeWidget::currentItemChanged, this, &SelectReleaseAssetDialog::selectionChanged);
+
+ connect(ui->buttonBox, &QDialogButtonBox::accepted, this, &SelectReleaseAssetDialog::accept);
+ connect(ui->buttonBox, &QDialogButtonBox::rejected, this, &SelectReleaseAssetDialog::reject);
+}
+
+SelectReleaseAssetDialog::~SelectReleaseAssetDialog()
+{
+ delete ui;
+}
+
+void SelectReleaseAssetDialog::loadAssets()
+{
+ for (auto rls : m_assets)
+ {
+ appendAsset(rls);
+ }
+}
+
+void SelectReleaseAssetDialog::appendAsset(ReleaseAsset const& asset)
+{
+ auto rls_item = new QTreeWidgetItem(ui->versionsTree);
+ rls_item->setText(0, asset.name);
+ rls_item->setExpanded(true);
+ rls_item->setText(1, asset.updated_at.toString());
+ rls_item->setData(0, Qt::UserRole, QVariant(asset.download_url.toString()));
+
+ ui->versionsTree->addTopLevelItem(rls_item);
+}
+
+ReleaseAsset SelectReleaseAssetDialog::getAsset(QTreeWidgetItem* item)
+{
+ auto asset_url = item->data(0, Qt::UserRole).toString();
+ ReleaseAsset selected_asset;
+ for (auto asset : m_assets)
+ {
+ if (asset.download_url.toString() == asset_url)
+ selected_asset = asset;
+ }
+ return selected_asset;
+}
+
+void SelectReleaseAssetDialog::selectionChanged(QTreeWidgetItem* current, QTreeWidgetItem* previous)
+{
+ Q_UNUSED(previous)
+ ReleaseAsset asset = getAsset(current);
+ m_selectedAsset = asset;
+}
diff --git a/archived/projt-launcher/launcher/updater/projtupdater/UpdaterDialogs.h b/archived/projt-launcher/launcher/updater/projtupdater/UpdaterDialogs.h
new file mode 100644
index 0000000000..3c469dc80f
--- /dev/null
+++ b/archived/projt-launcher/launcher/updater/projtupdater/UpdaterDialogs.h
@@ -0,0 +1,109 @@
+// 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) 2023 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.
+ *
+ *
+ *
+ * ======================================================================== */
+
+#pragma once
+
+#include <QDialog>
+#include <QTreeWidgetItem>
+
+#include "ReleaseInfo.h"
+#include "Version.h"
+
+namespace Ui
+{
+ class SelectReleaseDialog;
+}
+
+class SelectReleaseDialog : public QDialog
+{
+ Q_OBJECT
+
+ public:
+ explicit SelectReleaseDialog(const Version& cur_version, const QList<ReleaseInfo>& releases, QWidget* parent = 0);
+ ~SelectReleaseDialog();
+
+ void loadReleases();
+ void appendRelease(ReleaseInfo const& release);
+ ReleaseInfo selectedRelease()
+ {
+ return m_selectedRelease;
+ }
+ private slots:
+ ReleaseInfo getRelease(QTreeWidgetItem* item);
+ void selectionChanged(QTreeWidgetItem* current, QTreeWidgetItem* previous);
+
+ protected:
+ QList<ReleaseInfo> m_releases;
+ ReleaseInfo m_selectedRelease;
+ Version m_currentVersion;
+
+ Ui::SelectReleaseDialog* ui;
+};
+
+class SelectReleaseAssetDialog : public QDialog
+{
+ Q_OBJECT
+ public:
+ explicit SelectReleaseAssetDialog(const QList<ReleaseAsset>& assets, QWidget* parent = 0);
+ ~SelectReleaseAssetDialog();
+
+ void loadAssets();
+ void appendAsset(ReleaseAsset const& asset);
+ ReleaseAsset selectedAsset()
+ {
+ return m_selectedAsset;
+ }
+ private slots:
+ ReleaseAsset getAsset(QTreeWidgetItem* item);
+ void selectionChanged(QTreeWidgetItem* current, QTreeWidgetItem* previous);
+
+ protected:
+ QList<ReleaseAsset> m_assets;
+ ReleaseAsset m_selectedAsset;
+
+ Ui::SelectReleaseDialog* ui;
+};
diff --git a/archived/projt-launcher/launcher/updater/projtupdater/updater.exe.manifest b/archived/projt-launcher/launcher/updater/projtupdater/updater.exe.manifest
new file mode 100644
index 0000000000..2bce76b77b
--- /dev/null
+++ b/archived/projt-launcher/launcher/updater/projtupdater/updater.exe.manifest
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
+ <application xmlns="urn:schemas-microsoft-com:asm.v3">
+ <windowsSettings>
+ </windowsSettings>
+ </application>
+ <compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
+ <application>
+ <!-- Windows 10, Windows 11 -->
+ <supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
+ <!-- Windows 8.1 -->
+ <supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
+ <!-- Windows 8 -->
+ <supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/>
+ <!-- Windows 7 -->
+ <supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/>
+ </application>
+ </compatibility>
+ <trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
+ <security>
+ <requestedPrivileges>
+ <requestedExecutionLevel level="asInvoker" uiAccess="false"/>
+ </requestedPrivileges>
+ </security>
+ </trustInfo>
+</assembly>
diff --git a/archived/projt-launcher/launcher/updater/projtupdater/updater_main.cpp b/archived/projt-launcher/launcher/updater/projtupdater/updater_main.cpp
new file mode 100644
index 0000000000..dca529c973
--- /dev/null
+++ b/archived/projt-launcher/launcher/updater/projtupdater/updater_main.cpp
@@ -0,0 +1,64 @@
+// 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"
+int main(int argc, char* argv[])
+{
+ ProjTUpdaterApp wUpApp(argc, argv);
+
+ switch (wUpApp.status())
+ {
+ case ProjTUpdaterApp::Starting:
+ case ProjTUpdaterApp::Initialized:
+ {
+ return wUpApp.exec();
+ }
+ case ProjTUpdaterApp::Failed: return 1;
+ case ProjTUpdaterApp::Succeeded: return 0;
+ default: return -1;
+ }
+}