// SPDX-FileCopyrightText: 2023 Rachel Powers <508861+Ryex@users.noreply.github.com>
//
// SPDX-License-Identifier: GPL-3.0-only
/*
* 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, see .
*
*/
#include "PrismExternalUpdater.h"
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include "StringUtils.h"
#include "BuildConfig.h"
#include "ui/dialogs/UpdateAvailableDialog.h"
class PrismExternalUpdater::Private {
public:
QDir appDir;
QDir dataDir;
QTimer updateTimer;
bool allowBeta{};
bool autoCheck{};
double updateInterval{};
QDateTime lastCheck;
std::unique_ptr settings;
QWidget* parent{};
};
PrismExternalUpdater::PrismExternalUpdater(QWidget* parent, const QString& appDir, const QString& dataDir)
: priv(new PrismExternalUpdater::Private())
{
priv->appDir = QDir(appDir);
priv->dataDir = QDir(dataDir);
auto settingsFile = priv->dataDir.absoluteFilePath("prismlauncher_update.cfg");
priv->settings = std::make_unique(settingsFile, QSettings::Format::IniFormat);
priv->allowBeta = priv->settings->value("allow_beta", false).toBool();
priv->autoCheck = priv->settings->value("auto_check", true).toBool();
bool intervalOk = false;
// default once per day
priv->updateInterval = priv->settings->value("update_interval", 86400).toInt(&intervalOk);
if (!intervalOk) {
priv->updateInterval = 86400;
}
if (const auto lastCheck = priv->settings->value("last_check"); !lastCheck.isNull() && lastCheck.isValid()) {
priv->lastCheck = QDateTime::fromString(lastCheck.toString(), Qt::ISODate);
}
priv->parent = parent;
connectTimer();
resetAutoCheckTimer();
if (priv->updateInterval == 0) { // "On Launch"
checkForUpdates(false);
}
}
PrismExternalUpdater::~PrismExternalUpdater()
{
if (priv->updateTimer.isActive()) {
priv->updateTimer.stop();
}
disconnectTimer();
priv->settings->sync();
delete priv;
}
void PrismExternalUpdater::checkForUpdates()
{
checkForUpdates(true);
}
void PrismExternalUpdater::checkForUpdates(bool triggeredByUser) const
{
QProgressDialog progress(tr("Checking for updates..."), "", 0, 0, priv->parent);
progress.setMinimumDuration(0); // Appear immediately without waiting
progress.setCancelButton(nullptr);
progress.adjustSize();
if (triggeredByUser) {
progress.show();
}
QCoreApplication::processEvents();
QProcess proc;
auto exeName = QStringLiteral("%1_updater").arg(BuildConfig.LAUNCHER_APP_BINARY_NAME);
#ifdef Q_OS_WIN32
exeName.append(".exe");
auto env = QProcessEnvironment::systemEnvironment();
env.insert("__COMPAT_LAYER", "RUNASINVOKER");
proc.setProcessEnvironment(env);
#else
exeName = QString("bin/%1").arg(exeName);
#endif
QStringList args = { "--check-only", "--dir", priv->dataDir.absolutePath(), "--debug" };
if (priv->allowBeta) {
args.append("--pre-release");
}
proc.start(priv->appDir.absoluteFilePath(exeName), args);
if (auto resultStart = proc.waitForStarted(5000); !resultStart) {
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();
if (auto resultFinished = proc.waitForFinished(60000); !resultFinished) {
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 exitCode = proc.exitCode();
auto stdOutput = proc.readAllStandardOutput();
auto stdError = proc.readAllStandardError();
progress.cancel();
QCoreApplication::processEvents();
switch (exitCode) {
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(stdError);
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(stdError));
msgBox.setMinimumWidth(460);
msgBox.adjustSize();
msgBox.exec();
}
break;
case 100:
// update available
{
auto [firstLine, remainder1] = StringUtils::splitFirst(stdOutput, '\n');
auto [secondLine, remainder2] = StringUtils::splitFirst(remainder1, '\n');
auto [thirdLine, releaseNotes] = StringUtils::splitFirst(remainder2, '\n');
auto versionName = StringUtils::splitFirst(firstLine, ": ").second.trimmed();
auto versionTag = StringUtils::splitFirst(secondLine, ": ").second.trimmed();
auto releaseTimestamp = QDateTime::fromString(StringUtils::splitFirst(thirdLine, ": ").second.trimmed(), Qt::ISODate);
qDebug() << "Update available:" << versionName << versionTag << releaseTimestamp;
qDebug() << "Update release notes:" << releaseNotes;
offerUpdate(versionName, versionTag, releaseNotes, triggeredByUser);
}
break;
default:
// unknown error code
{
qDebug() << "Updater exited with unknown code" << exitCode;
auto msgBox = QMessageBox(QMessageBox::Information, tr("Unknown Update Error"),
tr("The updater exited with an unknown condition.\nExit Code: %1").arg(QString::number(exitCode)),
QMessageBox::Ok, priv->parent);
auto detailTxt = tr("StdOut: %1\nStdErr: %2").arg(QString(stdOutput)).arg(QString(stdError));
msgBox.setDetailedText(detailTxt);
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 PrismExternalUpdater::getAutomaticallyChecksForUpdates()
{
return priv->autoCheck;
}
double PrismExternalUpdater::getUpdateCheckInterval()
{
return priv->updateInterval;
}
bool PrismExternalUpdater::getBetaAllowed()
{
return priv->allowBeta;
}
void PrismExternalUpdater::setAutomaticallyChecksForUpdates(bool check)
{
priv->autoCheck = check;
priv->settings->setValue("auto_check", check);
priv->settings->sync();
resetAutoCheckTimer();
}
void PrismExternalUpdater::setUpdateCheckInterval(double seconds)
{
priv->updateInterval = seconds;
priv->settings->setValue("update_interval", seconds);
priv->settings->sync();
resetAutoCheckTimer();
}
void PrismExternalUpdater::setBetaAllowed(bool allowed)
{
priv->allowBeta = allowed;
priv->settings->setValue("auto_beta", allowed);
priv->settings->sync();
}
void PrismExternalUpdater::resetAutoCheckTimer() const
{
if (priv->autoCheck && priv->updateInterval > 0) {
auto now = QDateTime::currentDateTime();
qint64 timeoutMs = 0;
if (priv->lastCheck.isValid()) {
const qint64 diff = priv->lastCheck.secsTo(now);
const qint64 secsLeft = std::max(priv->updateInterval - diff, 0);
timeoutMs = secsLeft * 1000;
}
timeoutMs = std::min(timeoutMs, static_cast(INT_MAX));
qDebug() << "Auto update timer starting," << timeoutMs / 1000 << "seconds left";
priv->updateTimer.start(static_cast(timeoutMs));
} else {
if (priv->updateTimer.isActive()) {
priv->updateTimer.stop();
}
}
}
void PrismExternalUpdater::connectTimer()
{
connect(&priv->updateTimer, &QTimer::timeout, this, &PrismExternalUpdater::autoCheckTimerFired);
}
void PrismExternalUpdater::disconnectTimer()
{
disconnect(&priv->updateTimer, &QTimer::timeout, this, &PrismExternalUpdater::autoCheckTimerFired);
}
void PrismExternalUpdater::autoCheckTimerFired() const
{
qDebug() << "Auto update Timer fired";
checkForUpdates(false);
}
void PrismExternalUpdater::offerUpdate(const QString& versionName,
const QString& versionTag,
const QString& releaseNotes,
const bool triggeredByUser) const
{
priv->settings->beginGroup("skip");
auto shouldSkip = !triggeredByUser && priv->settings->value(versionTag, false).toBool();
priv->settings->endGroup();
if (shouldSkip) {
if (triggeredByUser) {
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(), versionName, releaseNotes);
auto result = dlg.exec();
qDebug() << "offer dlg result" << result;
priv->settings->beginGroup("skip");
if (result == UpdateAvailableDialog::Skip) {
priv->settings->setValue(versionTag, true);
} else {
if (result == UpdateAvailableDialog::Install) {
performUpdate(versionTag);
}
priv->settings->remove(versionTag);
}
priv->settings->endGroup();
priv->settings->sync();
}
void PrismExternalUpdater::performUpdate(const QString& versionTag) const
{
QProcess proc;
auto exeName = QStringLiteral("%1_updater").arg(BuildConfig.LAUNCHER_APP_BINARY_NAME);
#ifdef Q_OS_WIN32
exeName.append(".exe");
auto env = QProcessEnvironment::systemEnvironment();
env.insert("__COMPAT_LAYER", "RUNASINVOKER");
proc.setProcessEnvironment(env);
#else
exeName = QString("bin/%1").arg(exeName);
#endif
QStringList args = { "--dir", priv->dataDir.absolutePath(), "--install-version", versionTag };
if (priv->allowBeta) {
args.append("--pre-release");
}
proc.setProgram(priv->appDir.absoluteFilePath(exeName));
proc.setArguments(args);
auto result = proc.startDetached();
if (!result) {
qDebug() << "Failed to start updater:" << proc.error() << proc.errorString();
}
QCoreApplication::exit();
}