mirror of
https://github.com/KytyPS5/KytyPS5.git
synced 2026-10-21 03:16:54 +02:00
add self updater (#1352)
* add self updater * remove updater tests after validation
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
|
||||
find_package(Qt6 COMPONENTS Concurrent Network Widgets REQUIRED)
|
||||
add_subdirectory("${KYTY_SOURCE_DIR}/updater" "${CMAKE_CURRENT_BINARY_DIR}/updater")
|
||||
|
||||
set(CMAKE_AUTOUIC_SEARCH_PATHS forms)
|
||||
set(CMAKE_INCLUDE_CURRENT_DIR ON)
|
||||
@@ -42,7 +43,13 @@ elseif(LINUX)
|
||||
set_target_properties(launcher PROPERTIES LINK_FLAGS "${KYTY_LD_OPTIONS}")
|
||||
endif()
|
||||
|
||||
add_dependencies(launcher kyty_emulator)
|
||||
add_custom_target(stage_updater
|
||||
COMMAND ${CMAKE_COMMAND} -E make_directory $<TARGET_FILE_DIR:launcher>
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different $<TARGET_FILE:kyty_updater>
|
||||
$<TARGET_FILE_DIR:launcher>/$<TARGET_FILE_NAME:kyty_updater>
|
||||
DEPENDS kyty_updater
|
||||
VERBATIM)
|
||||
add_dependencies(launcher kyty_emulator stage_updater)
|
||||
|
||||
if(KYTY_CLANG_CL)
|
||||
add_custom_command(TARGET launcher POST_BUILD
|
||||
@@ -96,6 +103,7 @@ install(TARGETS launcher
|
||||
|
||||
if(APPLE)
|
||||
install(TARGETS kyty_emulator DESTINATION "KytyPS5.app/Contents/MacOS")
|
||||
install(TARGETS kyty_updater DESTINATION "KytyPS5.app/Contents/MacOS")
|
||||
install(FILES "${KYTY_TROPHY_UNLOCK_SOUND}" DESTINATION "KytyPS5.app/Contents/Resources/assets/sounds")
|
||||
# Deploy and ad-hoc sign the Qt frameworks and plugins in the bundle.
|
||||
find_program(KYTY_MACDEPLOYQT NAMES macdeployqt PATHS "${Qt6_DIR}/../../../bin" NO_DEFAULT_PATH)
|
||||
@@ -112,6 +120,10 @@ if(APPLE)
|
||||
")
|
||||
endif()
|
||||
|
||||
if(NOT APPLE)
|
||||
install(TARGETS kyty_updater RUNTIME DESTINATION .)
|
||||
endif()
|
||||
|
||||
if(WIN32)
|
||||
install(CODE "
|
||||
execute_process(
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
#ifndef UPDATE_CHECKER_H
|
||||
#define UPDATE_CHECKER_H
|
||||
|
||||
#include "updateRelease.h"
|
||||
|
||||
#include <QCryptographicHash>
|
||||
#include <QFile>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QUrl>
|
||||
#include <QPointer>
|
||||
#include <QProcess>
|
||||
|
||||
class QByteArray;
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
|
||||
class QNetworkReply;
|
||||
class QProgressDialog;
|
||||
class QTemporaryDir;
|
||||
class QWidget;
|
||||
|
||||
class UpdateChecker final: public QObject {
|
||||
@@ -14,28 +23,43 @@ class UpdateChecker final: public QObject {
|
||||
|
||||
public:
|
||||
explicit UpdateChecker(QWidget* parent);
|
||||
~UpdateChecker() override;
|
||||
|
||||
[[nodiscard]] static bool IsSupported();
|
||||
void Check(bool manual);
|
||||
static void ShowPreviousResult(QWidget* parent);
|
||||
void Check(bool manual);
|
||||
void SetGameRunning(bool running) { m_game_running = running; }
|
||||
[[nodiscard]] bool IsInstalling() const { return m_installing; }
|
||||
|
||||
signals:
|
||||
void CheckingChanged(bool checking);
|
||||
void InstallingChanged(bool installing);
|
||||
|
||||
private:
|
||||
struct UpdateInfo;
|
||||
void FetchRelease(const QUrl& url, std::function<void(UpdateRelease::Info)> done);
|
||||
void CheckCurrent(const UpdateRelease::Info& current, bool manual);
|
||||
void OfferUpdate();
|
||||
void Download();
|
||||
void ReadDownload(QNetworkReply* reply);
|
||||
void PreparePackage();
|
||||
void RunPreparation(const QString& program, const QStringList& arguments, bool validate);
|
||||
void Restart();
|
||||
void Finish(QString error = {}, bool report = true);
|
||||
|
||||
static UpdateInfo ParseUpdateInfo(const QByteArray& data);
|
||||
void FetchUpdateInfo(const char* url, bool fallback, bool manual);
|
||||
void ShowUpdateResult(const UpdateInfo& info, bool manual);
|
||||
|
||||
QWidget* m_parent = nullptr;
|
||||
QNetworkAccessManager m_network;
|
||||
bool m_checking_updates = false;
|
||||
|
||||
// Answer from the primary update feed, kept while the GitHub fallback feed is
|
||||
// being consulted, so it can still be shown if the fallback request fails.
|
||||
QString m_primary_tag;
|
||||
QUrl m_primary_page_url;
|
||||
QWidget* m_parent;
|
||||
QNetworkAccessManager m_network;
|
||||
QPointer<QNetworkReply> m_reply;
|
||||
QPointer<QProgressDialog> m_progress;
|
||||
QProcess m_prepare;
|
||||
std::unique_ptr<QTemporaryDir> m_workspace;
|
||||
QFile m_download;
|
||||
QCryptographicHash m_hash {QCryptographicHash::Sha256};
|
||||
UpdateRelease::Info m_release;
|
||||
QString m_download_error;
|
||||
bool m_busy = false;
|
||||
bool m_installing = false;
|
||||
bool m_game_running = false;
|
||||
bool m_canceled = false;
|
||||
};
|
||||
|
||||
#endif // UPDATE_CHECKER_H
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
#ifndef UPDATE_RELEASE_H
|
||||
#define UPDATE_RELEASE_H
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QDateTime>
|
||||
#include <QString>
|
||||
#include <QUrl>
|
||||
|
||||
namespace UpdateRelease {
|
||||
|
||||
inline const QString LATEST_URL =
|
||||
QStringLiteral("https://api.github.com/repos/KytyPS5/KytyPS5/releases/latest");
|
||||
inline const QString TAG_URL =
|
||||
QStringLiteral("https://api.github.com/repos/KytyPS5/KytyPS5/releases/tags/%1");
|
||||
inline const QString PAGE_URL =
|
||||
QStringLiteral("https://github.com/KytyPS5/KytyPS5/releases/tag/%1");
|
||||
inline const QString DOWNLOAD_URL =
|
||||
QStringLiteral("https://github.com/KytyPS5/KytyPS5/releases/download/%1/%2");
|
||||
|
||||
struct Info {
|
||||
QString tag;
|
||||
QUrl page_url;
|
||||
QUrl download_url;
|
||||
QByteArray sha256;
|
||||
qint64 size = 0;
|
||||
QDateTime published_at;
|
||||
QString error;
|
||||
};
|
||||
|
||||
enum class VersionRelation { Current, Newer, Older, Unknown };
|
||||
|
||||
[[nodiscard]] QString PlatformSuffix();
|
||||
[[nodiscard]] Info Parse(const QByteArray& data, const QString& platform_suffix);
|
||||
[[nodiscard]] VersionRelation Compare(const Info& latest, const Info& current);
|
||||
|
||||
} // namespace UpdateRelease
|
||||
|
||||
#endif // UPDATE_RELEASE_H
|
||||
@@ -136,6 +136,7 @@ void MainDialogPrivate::Setup(MainDialog* main_dialog) {
|
||||
[this](const QString&) { m_update_checker->Check(true); });
|
||||
connect(m_update_checker, &UpdateChecker::CheckingChanged, m_ui->check_updates_link,
|
||||
&QLabel::setDisabled);
|
||||
connect(m_update_checker, &UpdateChecker::InstallingChanged, this, &MainDialogPrivate::Update);
|
||||
connect(m_ui->check_updates_on_startup, &QCheckBox::toggled, this, [this](bool checked) {
|
||||
g_check_updates_on_startup = checked;
|
||||
m_ui->widget->WriteSettings();
|
||||
@@ -146,6 +147,7 @@ void MainDialogPrivate::Setup(MainDialog* main_dialog) {
|
||||
});
|
||||
|
||||
connect(&m_process, &QProcess::stateChanged, this, [this](QProcess::ProcessState state) {
|
||||
m_update_checker->SetGameRunning(state != QProcess::NotRunning);
|
||||
if (state == QProcess::NotRunning) {
|
||||
if (m_running_item != nullptr) {
|
||||
m_running_item->SetRunning(false);
|
||||
@@ -162,6 +164,7 @@ void MainDialogPrivate::Setup(MainDialog* main_dialog) {
|
||||
}
|
||||
|
||||
void MainDialogPrivate::FindInterpreter() {
|
||||
UpdateChecker::ShowPreviousResult(m_main_dialog);
|
||||
QDir search_dir(QApplication::applicationDirPath());
|
||||
m_interpreter = search_dir.absoluteFilePath(EMULATOR_EXE);
|
||||
|
||||
@@ -509,6 +512,9 @@ void MainDialogPrivate::ReadSettings(QSettings& s) {
|
||||
}
|
||||
|
||||
void MainDialogPrivate::Run() {
|
||||
if (m_update_checker->IsInstalling()) {
|
||||
return;
|
||||
}
|
||||
m_running_item = m_ui->widget->GetSelectedItem();
|
||||
if (m_running_item == nullptr) {
|
||||
return;
|
||||
@@ -526,7 +532,8 @@ void MainDialogPrivate::Run() {
|
||||
void MainDialogPrivate::Update() {
|
||||
const auto* item = m_ui->widget->GetSelectedItem();
|
||||
|
||||
bool run_enabled = (m_process.state() == QProcess::NotRunning && item != nullptr);
|
||||
bool run_enabled = (m_process.state() == QProcess::NotRunning && item != nullptr &&
|
||||
!m_update_checker->IsInstalling());
|
||||
|
||||
if (run_enabled) {
|
||||
const auto& info = item->GetInfo();
|
||||
|
||||
@@ -2,143 +2,418 @@
|
||||
|
||||
#include "kytyGitVersion.h"
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QAbstractButton>
|
||||
#include <QApplication>
|
||||
#include <QDebug>
|
||||
#include <QDesktopServices>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QMessageBox>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
#include <QString>
|
||||
#include <QUrl>
|
||||
#include <QWidget>
|
||||
#include <QProcessEnvironment>
|
||||
#include <QProgressDialog>
|
||||
#include <QTemporaryDir>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr char DEFAULT_FEED_URL[] = "https://kytyps5.github.io/data/updates.json";
|
||||
constexpr char FALLBACK_FEED_URL[] =
|
||||
"https://api.github.com/repos/KytyPS5/KytyPS5/releases/latest";
|
||||
constexpr qint64 MAX_METADATA_SIZE = 2 * 1024 * 1024;
|
||||
|
||||
QString LauncherRelativePath() {
|
||||
#if defined(_WIN32)
|
||||
return QStringLiteral("launcher.exe");
|
||||
#elif defined(__APPLE__)
|
||||
return QStringLiteral("KytyPS5.app/Contents/MacOS/KytyPS5");
|
||||
#else
|
||||
return QStringLiteral("launcher");
|
||||
#endif
|
||||
}
|
||||
|
||||
QString InstallDirectory() {
|
||||
QDir dir(QCoreApplication::applicationDirPath());
|
||||
#if defined(__APPLE__)
|
||||
// Official macOS releases are a bundle; never update a flat development build.
|
||||
if (!dir.cdUp() || !dir.cdUp() || dir.dirName() != QStringLiteral("KytyPS5.app") ||
|
||||
!dir.cdUp()) {
|
||||
return {};
|
||||
}
|
||||
#endif
|
||||
return dir.absolutePath();
|
||||
}
|
||||
|
||||
QString HelperName() {
|
||||
#if defined(_WIN32)
|
||||
return QStringLiteral("kyty_updater.exe");
|
||||
#else
|
||||
return QStringLiteral("kyty_updater");
|
||||
#endif
|
||||
}
|
||||
|
||||
QNetworkRequest Request(const QUrl& url) {
|
||||
QNetworkRequest request(url);
|
||||
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
||||
QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||
request.setRawHeader("User-Agent", "KytyPS5-Updater");
|
||||
request.setTransferTimeout(30000);
|
||||
return request;
|
||||
}
|
||||
|
||||
QString TarProgram() {
|
||||
#if defined(_WIN32)
|
||||
// Use the OS tool, not a tar.exe from the download or current directory.
|
||||
return QDir(qEnvironmentVariable("SystemRoot", QStringLiteral("C:/Windows")))
|
||||
.filePath(QStringLiteral("System32/tar.exe"));
|
||||
#else
|
||||
return QStringLiteral("/usr/bin/tar");
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
struct UpdateChecker::UpdateInfo {
|
||||
QString tag;
|
||||
QUrl page_url;
|
||||
QString error;
|
||||
};
|
||||
|
||||
UpdateChecker::UpdateChecker(QWidget* parent): QObject(parent), m_parent(parent), m_network(this) {}
|
||||
|
||||
UpdateChecker::~UpdateChecker() {
|
||||
if (m_reply) {
|
||||
m_reply->disconnect(this);
|
||||
m_reply->abort();
|
||||
}
|
||||
// Extraction must stop before QTemporaryDir removes its output.
|
||||
m_prepare.disconnect(this);
|
||||
if (m_prepare.state() != QProcess::NotRunning) {
|
||||
m_prepare.kill();
|
||||
m_prepare.waitForFinished();
|
||||
}
|
||||
}
|
||||
|
||||
bool UpdateChecker::IsSupported() {
|
||||
#if defined(KYTY_OFFICIAL_BUILD) && defined(NDEBUG)
|
||||
return !QString::fromLatin1(KYTY_GIT_HASH).endsWith(QStringLiteral("-dirty"));
|
||||
return !QString::fromLatin1(KYTY_GIT_HASH).endsWith(QStringLiteral("-dirty")) &&
|
||||
!UpdateRelease::PlatformSuffix().isEmpty();
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
UpdateChecker::UpdateInfo UpdateChecker::ParseUpdateInfo(const QByteArray& data) {
|
||||
const auto document = QJsonDocument::fromJson(data);
|
||||
if (!document.isObject()) {
|
||||
return {{}, {}, tr("Invalid update feed")};
|
||||
}
|
||||
|
||||
const auto root = document.object();
|
||||
UpdateInfo info {root.value(QStringLiteral("tag")).toString(),
|
||||
QUrl(root.value(QStringLiteral("html_url")).toString()), {}};
|
||||
if (info.tag.isEmpty()) {
|
||||
info.tag = root.value(QStringLiteral("tag_name")).toString();
|
||||
}
|
||||
if (info.tag.isEmpty() || !info.page_url.isValid() ||
|
||||
info.page_url.scheme() != QStringLiteral("https")) {
|
||||
info.error = tr("Incomplete update feed");
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
void UpdateChecker::Check(bool manual) {
|
||||
if (!IsSupported() || m_checking_updates) {
|
||||
void UpdateChecker::ShowPreviousResult(QWidget* parent) {
|
||||
const auto directory = InstallDirectory();
|
||||
QFile result(QDir(directory).filePath(QStringLiteral(".kyty-update-result")));
|
||||
if (directory.isEmpty() || !result.open(QIODevice::ReadOnly)) {
|
||||
return;
|
||||
}
|
||||
m_checking_updates = true;
|
||||
emit CheckingChanged(true);
|
||||
FetchUpdateInfo(DEFAULT_FEED_URL, false, manual);
|
||||
const auto status = result.readLine().trimmed();
|
||||
auto message = QString::fromUtf8(result.read(64 * 1024)).trimmed();
|
||||
result.close();
|
||||
result.remove();
|
||||
if (status != "success" && status != "failure") {
|
||||
return;
|
||||
}
|
||||
const bool success = status == "success";
|
||||
if (message.isEmpty()) {
|
||||
message = success ? tr("The update was installed successfully.")
|
||||
: tr("The update could not be installed.");
|
||||
}
|
||||
QMessageBox(success ? QMessageBox::Information : QMessageBox::Warning, tr("KytyPS5 Update"),
|
||||
message, QMessageBox::Ok, parent)
|
||||
.exec();
|
||||
}
|
||||
|
||||
void UpdateChecker::FetchUpdateInfo(const char* url, bool fallback, bool manual) {
|
||||
QNetworkRequest request(QUrl(QString::fromLatin1(url)));
|
||||
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
||||
QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||
request.setRawHeader("User-Agent", "Kyty-Launcher");
|
||||
void UpdateChecker::FetchRelease(const QUrl& url, std::function<void(UpdateRelease::Info)> done) {
|
||||
auto request = Request(url);
|
||||
request.setRawHeader("Accept", "application/vnd.github+json");
|
||||
request.setRawHeader("X-GitHub-Api-Version", "2022-11-28");
|
||||
request.setTransferTimeout(15000);
|
||||
|
||||
auto* reply = m_network.get(request);
|
||||
connect(reply, &QNetworkReply::finished, this, [this, reply, fallback, manual]() {
|
||||
UpdateInfo info;
|
||||
if (reply->error() == QNetworkReply::NoError) {
|
||||
info = ParseUpdateInfo(reply->readAll());
|
||||
} else {
|
||||
m_reply = reply;
|
||||
reply->setReadBufferSize(MAX_METADATA_SIZE + 1);
|
||||
connect(reply, &QNetworkReply::readyRead, this, [reply]() {
|
||||
if (reply->bytesAvailable() > MAX_METADATA_SIZE) {
|
||||
reply->abort();
|
||||
}
|
||||
});
|
||||
connect(reply, &QNetworkReply::finished, this, [this, reply, done = std::move(done)]() {
|
||||
UpdateRelease::Info info;
|
||||
if (reply->bytesAvailable() > MAX_METADATA_SIZE) {
|
||||
info.error = tr("The update response is too large.");
|
||||
} else if (reply->error() != QNetworkReply::NoError) {
|
||||
info.error = reply->errorString();
|
||||
} else {
|
||||
info = UpdateRelease::Parse(reply->readAll(), UpdateRelease::PlatformSuffix());
|
||||
}
|
||||
m_reply = nullptr;
|
||||
reply->deleteLater();
|
||||
|
||||
if (!fallback) {
|
||||
const bool up_to_date =
|
||||
info.error.isEmpty() && info.tag == QString::fromLatin1(KYTY_RELEASE_TAG);
|
||||
if (!up_to_date) {
|
||||
if (info.error.isEmpty()) {
|
||||
// The primary feed is reachable but disagrees with the build tag
|
||||
// (e.g. a stale mirror); remember its answer in case the fallback
|
||||
// request also fails.
|
||||
m_primary_tag = info.tag;
|
||||
m_primary_page_url = info.page_url;
|
||||
}
|
||||
FetchUpdateInfo(FALLBACK_FEED_URL, true, manual);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (fallback && !info.error.isEmpty() && !m_primary_tag.isEmpty()) {
|
||||
// The fallback feed failed, but the primary feed already returned a
|
||||
// usable answer; prefer it over reporting a network error.
|
||||
info = {m_primary_tag, m_primary_page_url, {}};
|
||||
}
|
||||
m_primary_tag.clear();
|
||||
m_primary_page_url.clear();
|
||||
|
||||
m_checking_updates = false;
|
||||
emit CheckingChanged(false);
|
||||
ShowUpdateResult(info, manual);
|
||||
done(info);
|
||||
});
|
||||
}
|
||||
|
||||
void UpdateChecker::ShowUpdateResult(const UpdateInfo& info, bool manual) {
|
||||
if (!info.error.isEmpty()) {
|
||||
qWarning() << "Could not check for updates:" << info.error;
|
||||
if (manual) {
|
||||
QMessageBox::warning(m_parent, tr("Update Check"),
|
||||
tr("Could not check for updates:\n%1").arg(info.error));
|
||||
}
|
||||
void UpdateChecker::Check(bool manual) {
|
||||
if (!IsSupported() || m_busy) {
|
||||
return;
|
||||
}
|
||||
m_busy = true;
|
||||
emit CheckingChanged(true);
|
||||
FetchRelease(QUrl(UpdateRelease::LATEST_URL), [this, manual](UpdateRelease::Info latest) {
|
||||
m_release = std::move(latest);
|
||||
if (!m_release.published_at.isValid()) {
|
||||
Finish(m_release.error, manual);
|
||||
} else if (m_release.tag == QString::fromLatin1(KYTY_RELEASE_TAG)) {
|
||||
CheckCurrent(m_release, manual);
|
||||
} else {
|
||||
// Date/hash tags cannot order releases published on the same day.
|
||||
const auto tag = QUrl::toPercentEncoding(QString::fromLatin1(KYTY_RELEASE_TAG));
|
||||
FetchRelease(QUrl(UpdateRelease::TAG_URL.arg(QString::fromLatin1(tag))),
|
||||
[this, manual](const UpdateRelease::Info& current) {
|
||||
CheckCurrent(current, manual);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const bool current = info.tag == QString::fromLatin1(KYTY_RELEASE_TAG);
|
||||
if (current) {
|
||||
if (manual) {
|
||||
QMessageBox::information(m_parent, tr("Update Check"),
|
||||
tr("You are using the latest version (%1).").arg(info.tag));
|
||||
void UpdateChecker::CheckCurrent(const UpdateRelease::Info& current, bool manual) {
|
||||
if (current.tag != QString::fromLatin1(KYTY_RELEASE_TAG) || !current.published_at.isValid()) {
|
||||
Finish(tr("Could not verify the currently installed release. Please try again later."),
|
||||
manual);
|
||||
return;
|
||||
}
|
||||
const auto relation = UpdateRelease::Compare(m_release, current);
|
||||
if (relation == UpdateRelease::VersionRelation::Newer) {
|
||||
if (m_release.error.isEmpty()) {
|
||||
OfferUpdate();
|
||||
} else {
|
||||
Finish(m_release.error, manual);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const auto message =
|
||||
tr("An update is available.\n\nCurrent: %1\nLatest: %2\n\n"
|
||||
"Open the release page?")
|
||||
.arg(QString::fromLatin1(KYTY_RELEASE_TAG), info.tag);
|
||||
if (QMessageBox::question(m_parent, tr("KytyPS5 Update"), message,
|
||||
QMessageBox::Open | QMessageBox::Cancel,
|
||||
QMessageBox::Open) == QMessageBox::Open) {
|
||||
QDesktopServices::openUrl(info.page_url);
|
||||
if (relation == UpdateRelease::VersionRelation::Unknown) {
|
||||
Finish(tr("Could not determine whether the available release is newer."), manual);
|
||||
return;
|
||||
}
|
||||
Finish();
|
||||
if (manual) {
|
||||
QMessageBox::information(m_parent, tr("Update Check"),
|
||||
tr("No newer release is available."));
|
||||
}
|
||||
}
|
||||
|
||||
void UpdateChecker::OfferUpdate() {
|
||||
QMessageBox question(QMessageBox::Information, tr("KytyPS5 Update"),
|
||||
tr("Version %1 is available.\n\nCurrent: %2\n\n"
|
||||
"Download and install it now? The launcher will restart. Your settings "
|
||||
"and games are kept.")
|
||||
.arg(m_release.tag, QString::fromLatin1(KYTY_RELEASE_TAG)),
|
||||
QMessageBox::Yes | QMessageBox::Open | QMessageBox::Cancel, m_parent);
|
||||
question.button(QMessageBox::Yes)->setText(tr("Install Update"));
|
||||
question.button(QMessageBox::Open)->setText(tr("Release Notes"));
|
||||
question.setDefaultButton(QMessageBox::Yes);
|
||||
const auto answer = question.exec();
|
||||
if (answer == QMessageBox::Yes) {
|
||||
Download();
|
||||
return;
|
||||
}
|
||||
if (answer == QMessageBox::Open) {
|
||||
QDesktopServices::openUrl(m_release.page_url);
|
||||
}
|
||||
Finish();
|
||||
}
|
||||
|
||||
void UpdateChecker::Download() {
|
||||
if (m_game_running) {
|
||||
Finish(tr("Close the running game before installing an update."));
|
||||
return;
|
||||
}
|
||||
const auto directory = InstallDirectory();
|
||||
const auto helper = QDir(QCoreApplication::applicationDirPath()).filePath(HelperName());
|
||||
if (directory.isEmpty() || !QFileInfo::exists(helper) || !QFileInfo::exists(TarProgram())) {
|
||||
Finish(tr("This installation cannot update itself. Download the release from GitHub and "
|
||||
"extract it manually."));
|
||||
return;
|
||||
}
|
||||
m_workspace = std::make_unique<QTemporaryDir>(
|
||||
QDir(directory).filePath(QStringLiteral(".kyty-update-XXXXXX")));
|
||||
if (!m_workspace->isValid()) {
|
||||
Finish(tr("The installation folder is not writable. Move KytyPS5 to a writable folder and "
|
||||
"try again."));
|
||||
return;
|
||||
}
|
||||
if (!QFile::copy(helper, m_workspace->filePath(HelperName())) ||
|
||||
!QDir(m_workspace->path()).mkdir(QStringLiteral("payload"))) {
|
||||
Finish(tr("Could not prepare the update folder."));
|
||||
return;
|
||||
}
|
||||
m_download.setFileName(
|
||||
m_workspace->filePath(QStringLiteral("package") + UpdateRelease::PlatformSuffix()));
|
||||
if (!m_download.open(QIODevice::WriteOnly)) {
|
||||
Finish(m_download.errorString());
|
||||
return;
|
||||
}
|
||||
m_hash.reset();
|
||||
m_download_error.clear();
|
||||
m_canceled = false;
|
||||
m_installing = true;
|
||||
emit InstallingChanged(true);
|
||||
m_progress = new QProgressDialog(tr("Downloading %1…").arg(m_release.tag), tr("Cancel"), 0, 100,
|
||||
m_parent);
|
||||
m_progress->setWindowTitle(tr("KytyPS5 Update"));
|
||||
m_progress->setWindowModality(Qt::ApplicationModal);
|
||||
m_progress->setAutoClose(false);
|
||||
m_progress->setAutoReset(false);
|
||||
m_progress->setMinimumDuration(0);
|
||||
connect(m_progress, &QProgressDialog::canceled, this, [this]() {
|
||||
m_canceled = true;
|
||||
if (m_reply) {
|
||||
m_reply->abort();
|
||||
} else if (m_prepare.state() != QProcess::NotRunning) {
|
||||
m_prepare.kill();
|
||||
}
|
||||
});
|
||||
auto* reply = m_network.get(Request(m_release.download_url));
|
||||
m_reply = reply;
|
||||
reply->setReadBufferSize(1024 * 1024);
|
||||
connect(reply, &QNetworkReply::readyRead, this, [this, reply]() { ReadDownload(reply); });
|
||||
connect(reply, &QNetworkReply::downloadProgress, this, [this](qint64 received, qint64) {
|
||||
if (m_progress) {
|
||||
m_progress->setValue(
|
||||
static_cast<int>(qMin<qint64>(99, received * 100 / m_release.size)));
|
||||
}
|
||||
});
|
||||
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
|
||||
ReadDownload(reply);
|
||||
const auto error = reply->error();
|
||||
const auto error_text = reply->errorString();
|
||||
m_reply = nullptr;
|
||||
reply->deleteLater();
|
||||
const auto size = m_download.size();
|
||||
const bool flushed = m_download.flush();
|
||||
m_download.close();
|
||||
if (m_canceled) {
|
||||
Finish();
|
||||
} else if (!m_download_error.isEmpty()) {
|
||||
Finish(m_download_error);
|
||||
} else if (error != QNetworkReply::NoError) {
|
||||
Finish(error_text);
|
||||
} else if (!flushed || size != m_release.size || m_hash.result() != m_release.sha256) {
|
||||
Finish(tr("The update failed its size or SHA-256 verification. Please try again."));
|
||||
} else {
|
||||
PreparePackage();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void UpdateChecker::ReadDownload(QNetworkReply* reply) {
|
||||
const auto data = reply->readAll();
|
||||
if (data.isEmpty() || m_canceled || !m_download_error.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (m_download.size() + data.size() > m_release.size) {
|
||||
m_download_error = tr("The downloaded file is larger than the release asset.");
|
||||
} else if (m_download.write(data) != data.size()) {
|
||||
m_download_error = m_download.errorString();
|
||||
} else {
|
||||
m_hash.addData(data);
|
||||
}
|
||||
if (!m_download_error.isEmpty() && !reply->isFinished()) {
|
||||
reply->abort();
|
||||
}
|
||||
}
|
||||
|
||||
void UpdateChecker::PreparePackage() {
|
||||
m_progress->setLabelText(tr("Preparing the update…"));
|
||||
m_progress->setRange(0, 0);
|
||||
RunPreparation(TarProgram(),
|
||||
{QStringLiteral("-xf"), m_download.fileName(), QStringLiteral("-C"),
|
||||
m_workspace->filePath(QStringLiteral("payload")),
|
||||
QStringLiteral("--no-same-owner"), QStringLiteral("--no-same-permissions")},
|
||||
false);
|
||||
}
|
||||
|
||||
void UpdateChecker::RunPreparation(const QString& program, const QStringList& arguments,
|
||||
bool validate) {
|
||||
m_prepare.disconnect(this);
|
||||
m_prepare.setProgram(program);
|
||||
m_prepare.setArguments(arguments);
|
||||
m_prepare.setWorkingDirectory(m_workspace->path());
|
||||
auto environment = QProcessEnvironment::systemEnvironment();
|
||||
environment.remove(QStringLiteral("TAR_OPTIONS"));
|
||||
m_prepare.setProcessEnvironment(environment);
|
||||
connect(&m_prepare, &QProcess::errorOccurred, this, [this](QProcess::ProcessError error) {
|
||||
if (error == QProcess::FailedToStart) {
|
||||
Finish(m_prepare.errorString());
|
||||
}
|
||||
});
|
||||
connect(&m_prepare, qOverload<int, QProcess::ExitStatus>(&QProcess::finished), this,
|
||||
[this, validate](int exit_code, QProcess::ExitStatus status) {
|
||||
if (m_canceled) {
|
||||
Finish();
|
||||
} else if (exit_code != 0 || status != QProcess::NormalExit) {
|
||||
Finish(
|
||||
tr("Could not prepare the update:\n%1")
|
||||
.arg(QString::fromUtf8(m_prepare.readAllStandardError()).left(4096)));
|
||||
} else if (!validate) {
|
||||
RunPreparation(m_workspace->filePath(HelperName()),
|
||||
{QStringLiteral("--validate"),
|
||||
m_workspace->filePath(QStringLiteral("payload")),
|
||||
InstallDirectory(), LauncherRelativePath()},
|
||||
true);
|
||||
} else {
|
||||
Restart();
|
||||
}
|
||||
});
|
||||
m_prepare.start();
|
||||
}
|
||||
|
||||
void UpdateChecker::Restart() {
|
||||
if (m_game_running || m_canceled) {
|
||||
Finish(tr("The update was canceled. Close any running game before trying again."));
|
||||
return;
|
||||
}
|
||||
m_progress->setLabelText(tr("Restarting to install the update…"));
|
||||
m_progress->setCancelButton(nullptr);
|
||||
QProcess helper;
|
||||
helper.setProgram(m_workspace->filePath(HelperName()));
|
||||
helper.setArguments({QStringLiteral("--install"),
|
||||
m_workspace->filePath(QStringLiteral("payload")), InstallDirectory(),
|
||||
LauncherRelativePath(),
|
||||
QString::number(QCoreApplication::applicationPid()), QDir::currentPath()});
|
||||
helper.setWorkingDirectory(m_workspace->path());
|
||||
helper.setStandardOutputFile(m_workspace->filePath(QStringLiteral("helper.log")));
|
||||
helper.setStandardErrorFile(m_workspace->filePath(QStringLiteral("helper.log")),
|
||||
QIODevice::Append);
|
||||
if (!helper.startDetached()) {
|
||||
Finish(tr("Could not start the update installer."));
|
||||
return;
|
||||
}
|
||||
m_workspace->setAutoRemove(false);
|
||||
QApplication::quit();
|
||||
}
|
||||
|
||||
void UpdateChecker::Finish(QString error, bool report) {
|
||||
const auto release_page = m_release.page_url;
|
||||
m_release = {};
|
||||
m_download.close();
|
||||
m_workspace.reset();
|
||||
if (m_progress) {
|
||||
m_progress->disconnect(this);
|
||||
m_progress->hide();
|
||||
m_progress->deleteLater();
|
||||
m_progress = nullptr;
|
||||
}
|
||||
m_busy = false;
|
||||
m_installing = false;
|
||||
emit CheckingChanged(false);
|
||||
emit InstallingChanged(false);
|
||||
if (error.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
qWarning() << "KytyPS5 updater:" << error;
|
||||
if (!report) {
|
||||
return;
|
||||
}
|
||||
QMessageBox message(QMessageBox::Warning, tr("KytyPS5 Update"), error, QMessageBox::Close,
|
||||
m_parent);
|
||||
if (!release_page.isEmpty()) {
|
||||
message.addButton(QMessageBox::Open);
|
||||
message.button(QMessageBox::Open)->setText(tr("Open Release Page"));
|
||||
}
|
||||
if (message.exec() == QMessageBox::Open) {
|
||||
QDesktopServices::openUrl(release_page);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
#include "updateRelease.h"
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QObject>
|
||||
#include <QRegularExpression>
|
||||
|
||||
namespace UpdateRelease {
|
||||
|
||||
QString PlatformSuffix() {
|
||||
#if defined(_WIN32) && (defined(_M_X64) || defined(__x86_64__))
|
||||
return QStringLiteral("-Windows-x64.zip");
|
||||
#elif defined(__linux__) && defined(__x86_64__)
|
||||
return QStringLiteral("-Linux-x86_64.tar.gz");
|
||||
#elif defined(__APPLE__) && defined(__x86_64__)
|
||||
return QStringLiteral("-macOS-x86_64.zip");
|
||||
#else
|
||||
return {};
|
||||
#endif
|
||||
}
|
||||
|
||||
Info Parse(const QByteArray& data, const QString& platform_suffix) {
|
||||
Info info;
|
||||
const auto document = QJsonDocument::fromJson(data);
|
||||
if (!document.isObject()) {
|
||||
info.error = QObject::tr("Invalid GitHub release response.");
|
||||
return info;
|
||||
}
|
||||
|
||||
const auto root = document.object();
|
||||
info.tag = root.value(QStringLiteral("tag_name")).toString();
|
||||
const auto published_at =
|
||||
QDateTime::fromString(root.value(QStringLiteral("published_at")).toString(), Qt::ISODate);
|
||||
if (info.tag.isEmpty() || !published_at.isValid() ||
|
||||
root.value(QStringLiteral("draft")).toBool() ||
|
||||
root.value(QStringLiteral("prerelease")).toBool()) {
|
||||
info.error = QObject::tr("The release is not a published stable release.");
|
||||
return info;
|
||||
}
|
||||
info.published_at = published_at;
|
||||
const auto encoded_tag = QString::fromLatin1(QUrl::toPercentEncoding(info.tag));
|
||||
info.page_url = QUrl(PAGE_URL.arg(encoded_tag));
|
||||
|
||||
if (platform_suffix.isEmpty()) {
|
||||
info.error = QObject::tr("Built-in updates are unavailable for this platform.");
|
||||
return info;
|
||||
}
|
||||
|
||||
const auto asset_name = info.tag + platform_suffix;
|
||||
for (const auto& value: root.value(QStringLiteral("assets")).toArray()) {
|
||||
const auto asset = value.toObject();
|
||||
if (asset.value(QStringLiteral("name")).toString() != asset_name) {
|
||||
continue;
|
||||
}
|
||||
const auto digest = asset.value(QStringLiteral("digest")).toString();
|
||||
const auto size = asset.value(QStringLiteral("size")).toInteger();
|
||||
static const QRegularExpression digest_pattern(
|
||||
QStringLiteral("\\Asha256:[A-Fa-f0-9]{64}\\z"));
|
||||
if (!digest_pattern.match(digest).hasMatch() || size <= 0) {
|
||||
info.error = QObject::tr("The update package size or SHA-256 checksum is invalid.");
|
||||
return info;
|
||||
}
|
||||
const auto encoded_asset = QString::fromLatin1(QUrl::toPercentEncoding(asset_name));
|
||||
info.download_url = QUrl(DOWNLOAD_URL.arg(encoded_tag, encoded_asset));
|
||||
info.sha256 = QByteArray::fromHex(digest.mid(7).toLatin1());
|
||||
info.size = size;
|
||||
return info;
|
||||
}
|
||||
info.error = QObject::tr("This release has no update package for your platform.");
|
||||
return info;
|
||||
}
|
||||
|
||||
VersionRelation Compare(const Info& latest, const Info& current) {
|
||||
if (latest.tag.isEmpty() || current.tag.isEmpty()) {
|
||||
return VersionRelation::Unknown;
|
||||
}
|
||||
if (latest.tag == current.tag) {
|
||||
return VersionRelation::Current;
|
||||
}
|
||||
if (!latest.published_at.isValid() || !current.published_at.isValid() ||
|
||||
latest.published_at == current.published_at) {
|
||||
return VersionRelation::Unknown;
|
||||
}
|
||||
return latest.published_at > current.published_at ? VersionRelation::Newer
|
||||
: VersionRelation::Older;
|
||||
}
|
||||
|
||||
} // namespace UpdateRelease
|
||||
Reference in New Issue
Block a user