add self updater (#1352)

* add self updater

* remove updater tests after validation
This commit is contained in:
nmzik
2026-10-10 05:42:32 +02:00
committed by GitHub
parent 679be25c37
commit 307fdcf7ad
12 changed files with 1025 additions and 119 deletions
+13 -1
View File
@@ -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(
+41 -17
View File
@@ -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
+38
View File
@@ -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
+8 -1
View File
@@ -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();
+374 -99
View File
@@ -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);
}
}
+89
View File
@@ -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
+15
View File
@@ -0,0 +1,15 @@
# The installer is copied outside the application before it runs. Keep it free of
# Qt and application DLL dependencies so it can replace those files on Windows.
if(MSVC)
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
endif()
add_executable(kyty_updater main.cpp UpdateInstaller.cpp)
if(MSVC)
target_compile_options(kyty_updater PRIVATE /EHsc)
else()
target_compile_options(kyty_updater PRIVATE -fexceptions)
endif()
if(MINGW)
target_link_options(kyty_updater PRIVATE -static -municode)
endif()
+249
View File
@@ -0,0 +1,249 @@
#include "UpdateInstaller.h"
#include <algorithm>
#include <stdexcept>
#include <vector>
namespace Kyty::Updater {
namespace {
namespace fs = std::filesystem;
struct Entry {
fs::path relative;
fs::file_type type;
};
struct Plan {
fs::path source;
fs::path target;
std::vector<Entry> entries;
};
bool IsWithin(const fs::path& path, const fs::path& root) {
auto part = path.begin();
for (const auto& component: root) {
if (part == path.end() || *part++ != component) {
return false;
}
}
return true;
}
bool IsRelative(const fs::path& path) {
if (path.empty() || path.is_absolute() || path.has_root_name()) {
return false;
}
for (const auto& component: path) {
if (component == ".." || component == "." || component.empty()) {
return false;
}
}
return true;
}
fs::file_type Type(const fs::path& path) {
std::error_code error;
const auto status = fs::symlink_status(path, error);
if (error && error != std::errc::no_such_file_or_directory) {
throw fs::filesystem_error("Cannot inspect update path", path, error);
}
return status.type();
}
void CheckLink(const fs::path& path, const fs::path& root) {
const auto link = fs::read_symlink(path);
if (link.is_absolute() || !IsWithin(fs::canonical(path), root)) {
throw std::runtime_error("Update contains a link outside its installation: " +
path.string());
}
}
void CheckDestination(const fs::path& target, const Entry& entry) {
const auto destination = target / entry.relative;
const auto type = Type(destination);
if (type == fs::file_type::not_found) {
return;
}
if ((entry.type == fs::file_type::directory) != (type == fs::file_type::directory) ||
(type != fs::file_type::regular && type != fs::file_type::directory &&
type != fs::file_type::symlink)) {
throw std::runtime_error("Update would replace a different file type: " +
destination.string());
}
if (type == fs::file_type::symlink) {
CheckLink(destination, target);
}
}
Plan MakePlan(const fs::path& source, const fs::path& target, const fs::path& launcher) {
if (!IsRelative(launcher) || Type(source) != fs::file_type::directory ||
Type(target) != fs::file_type::directory) {
throw std::runtime_error(
"Update source, installation directory, or launcher path is invalid.");
}
Plan plan {fs::canonical(source), fs::canonical(target), {}};
if (IsWithin(plan.target, plan.source)) {
throw std::runtime_error("Update source must not contain the installation directory.");
}
// macOS releases also have flat runtime files; install only the signed bundle.
fs::path scope;
if (launcher.begin()->extension() == ".app") {
scope = *launcher.begin();
}
const auto source_root = scope.empty() ? plan.source : plan.source / scope;
if (Type(source_root) != fs::file_type::directory) {
throw std::runtime_error("The release does not contain the application bundle.");
}
const auto binary_directory = launcher.parent_path();
#ifdef _WIN32
const fs::path suffix = ".exe";
#else
const fs::path suffix;
#endif
for (const auto& required: {launcher, binary_directory / ("kyty_emulator" + suffix.string()),
binary_directory / ("kyty_updater" + suffix.string())}) {
if (Type(plan.source / required) != fs::file_type::regular) {
throw std::runtime_error("Release is missing an executable: " + required.string());
}
#ifndef _WIN32
const auto executable =
fs::perms::owner_exec | fs::perms::group_exec | fs::perms::others_exec;
if ((fs::status(plan.source / required).permissions() & executable) == fs::perms::none) {
throw std::runtime_error("Release executable lacks execute permissions: " +
required.string());
}
#endif
}
if (!scope.empty()) {
plan.entries.push_back({scope, fs::file_type::directory});
}
for (const auto& item: fs::recursive_directory_iterator(source_root)) {
const auto relative = item.path().lexically_relative(plan.source);
const auto type = item.symlink_status().type();
if (relative.begin()->string().starts_with(".kyty-update")) {
throw std::runtime_error("Release uses a reserved updater filename.");
}
if (type == fs::file_type::symlink) {
CheckLink(item.path(), source_root);
} else if (type != fs::file_type::directory && type != fs::file_type::regular) {
throw std::runtime_error("Release contains an unsupported file: " + relative.string());
}
plan.entries.push_back({relative, type});
}
// Checking directories before their children prevents destination symlink traversal.
std::sort(plan.entries.begin(), plan.entries.end(),
[](const Entry& left, const Entry& right) { return left.relative < right.relative; });
for (const auto& entry: plan.entries) {
CheckDestination(plan.target, entry);
}
return plan;
}
struct InstallLock {
fs::path path;
void Acquire(const fs::path& target) {
auto candidate = target / ".kyty-update-lock";
if (!fs::create_directory(candidate)) {
throw std::runtime_error(
"Another update is running, or a previous update was interrupted. "
"Close all updater processes, inspect retained backups, then remove " +
candidate.string());
}
path = std::move(candidate);
}
~InstallLock() {
if (!path.empty()) {
std::error_code ignored;
// An incomplete rollback leaves backups here and keeps the lock for recovery.
fs::remove(path, ignored);
}
}
};
struct Change {
fs::path relative;
bool old_moved = false;
bool installed = false;
};
} // namespace
Result Validate(const fs::path& source, const fs::path& target, const fs::path& launcher_relative) {
try {
MakePlan(source, target, launcher_relative);
// Check actual write access, including ACLs, and interrupted/concurrent updates.
InstallLock lock;
lock.Acquire(target);
return {true, {}};
} catch (const std::exception& error) {
return {false, error.what()};
}
}
Result Install(const fs::path& source, const fs::path& target, const fs::path& launcher_relative) {
InstallLock lock;
fs::path backup;
std::vector<Change> changes;
std::vector<fs::path> created_directories;
try {
const auto plan = MakePlan(source, target, launcher_relative);
lock.Acquire(plan.target);
backup = lock.path / "backup";
changes.reserve(plan.entries.size());
created_directories.reserve(plan.entries.size());
// The launcher extracts beside the installation, so files can move directly
// from that staging directory without another full copy of the release.
for (const auto& entry: plan.entries) {
CheckDestination(plan.target, entry);
const auto destination = plan.target / entry.relative;
if (entry.type == fs::file_type::directory) {
if (fs::create_directory(destination)) {
created_directories.push_back(destination);
}
continue;
}
changes.push_back({entry.relative});
auto& change = changes.back();
if (Type(destination) != fs::file_type::not_found) {
fs::create_directories((backup / entry.relative).parent_path());
fs::rename(destination, backup / entry.relative);
change.old_moved = true;
}
fs::rename(plan.source / entry.relative, destination);
change.installed = true;
}
std::error_code cleanup_error;
fs::remove_all(backup, cleanup_error);
return {true, cleanup_error ? "Update installed; backup cleanup failed: " + backup.string()
: std::string {}};
} catch (const std::exception& error) {
std::string message = error.what();
bool restored = true;
for (auto change = changes.rbegin(); change != changes.rend(); ++change) {
try {
const auto destination = target / change->relative;
if (change->installed) {
fs::rename(destination, source / change->relative);
}
if (change->old_moved) {
fs::rename(backup / change->relative, destination);
}
} catch (const std::exception& rollback_error) {
restored = false;
message += "\nRollback failed: " + std::string(rollback_error.what());
}
}
for (auto directory = created_directories.rbegin(); directory != created_directories.rend();
++directory) {
std::error_code ignored;
fs::remove(*directory, ignored);
}
if (!backup.empty()) {
if (restored) {
std::error_code ignored;
fs::remove_all(backup, ignored);
} else {
message += "\nBackup retained at: " + backup.string();
}
}
return {false, message};
}
}
} // namespace Kyty::Updater
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include <filesystem>
#include <string>
namespace Kyty::Updater {
struct Result {
bool success;
std::string message;
};
// Only release files are replaced. Files absent from the release are preserved.
// Successful installation consumes staged files; stage on the target filesystem.
Result Validate(const std::filesystem::path& source, const std::filesystem::path& target,
const std::filesystem::path& launcher_relative);
Result Install(const std::filesystem::path& source, const std::filesystem::path& target,
const std::filesystem::path& launcher_relative);
} // namespace Kyty::Updater
+167
View File
@@ -0,0 +1,167 @@
#include "UpdateInstaller.h"
#include <chrono>
#include <fstream>
#include <iostream>
#include <limits>
#include <stdexcept>
#include <thread>
#include <vector>
#ifdef _WIN32
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#else
#include <cerrno>
#include <csignal>
#include <spawn.h>
#include <unistd.h>
extern char** environ;
#endif
namespace {
namespace fs = std::filesystem;
bool WaitForParent(unsigned long pid) {
#ifdef _WIN32
HANDLE process = OpenProcess(SYNCHRONIZE, FALSE, pid);
if (!process) {
return GetLastError() == ERROR_INVALID_PARAMETER;
}
const auto result = WaitForSingleObject(process, 120000);
CloseHandle(process);
return result == WAIT_OBJECT_0;
#else
if (pid > static_cast<unsigned long>(std::numeric_limits<pid_t>::max())) {
return false;
}
const auto deadline = std::chrono::steady_clock::now() + std::chrono::minutes(2);
while (std::chrono::steady_clock::now() < deadline) {
if (kill(static_cast<pid_t>(pid), 0) != 0) {
return errno == ESRCH;
}
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
return false;
#endif
}
bool Launch(const fs::path& launcher, const fs::path& directory) {
#ifdef _WIN32
std::wstring command = L"\"" + launcher.wstring() + L"\"";
STARTUPINFOW startup {};
startup.cb = sizeof(startup);
PROCESS_INFORMATION process {};
if (!CreateProcessW(launcher.c_str(), command.data(), nullptr, nullptr, FALSE, 0, nullptr,
directory.c_str(), &startup, &process)) {
return false;
}
CloseHandle(process.hThread);
CloseHandle(process.hProcess);
return true;
#else
if (chdir(directory.c_str()) != 0) {
return false;
}
setsid();
pid_t child;
char* arguments[] = {const_cast<char*>(launcher.c_str()), nullptr};
return posix_spawn(&child, launcher.c_str(), nullptr, nullptr, arguments, environ) == 0;
#endif
}
void WriteResult(const fs::path& workspace, const fs::path& target,
const Kyty::Updater::Result& result) try {
const auto temporary = workspace / "update-result.txt";
std::ofstream stream(temporary, std::ios::trunc);
stream << (result.success ? "success\n" : "failure\n") << result.message;
stream.close();
if (!stream) {
throw std::runtime_error("Cannot write the update result.");
}
const auto destination = target / ".kyty-update-result";
// Never open the destination: rename safely replaces even an existing symlink.
std::error_code ignored;
fs::remove(destination, ignored);
fs::rename(temporary, destination);
} catch (const std::exception& error) {
// Reporting must never prevent the original or updated launcher restarting.
std::cerr << "Cannot record update result: " << error.what() << '\n';
}
void CleanupWorkspace(const fs::path& source, const fs::path& target) try {
const auto workspace = source.parent_path();
if (source.filename() != "payload" ||
!workspace.filename().string().starts_with(".kyty-update-") ||
fs::canonical(workspace.parent_path()) != fs::canonical(target)) {
return;
}
// Windows cannot unlink the running helper. Remove the archive and payload
// separately so that only this small executable can remain after success.
for (const auto& entry: fs::directory_iterator(workspace)) {
std::error_code ignored;
fs::remove_all(entry.path(), ignored);
}
std::error_code ignored;
fs::remove(workspace, ignored);
} catch (const std::exception& error) {
std::cerr << "Update installed; temporary files could not be removed: " << error.what() << '\n';
}
int Run(const std::vector<fs::path>& arguments) {
const bool validate = arguments.size() == 5 && arguments[1] == "--validate";
const bool install = arguments.size() == 7 && arguments[1] == "--install";
if (!validate && !install) {
std::cerr
<< "Usage: kyty_updater --validate SOURCE TARGET LAUNCHER_REL\n"
" kyty_updater --install SOURCE TARGET LAUNCHER_REL PARENT_PID WORKDIR\n";
return 2;
}
const auto source = fs::absolute(arguments[2]);
const auto target = fs::absolute(arguments[3]);
const auto launcher = arguments[4];
if (validate) {
const auto result = Kyty::Updater::Validate(source, target, launcher);
if (!result.success) {
std::cerr << result.message << '\n';
}
return result.success ? 0 : 1;
}
const auto pid_string = arguments[5].string();
std::size_t consumed = 0;
const auto pid = std::stoul(pid_string, &consumed);
if (pid <= 1 || consumed != pid_string.size() || pid_string.front() == '-') {
throw std::runtime_error("Invalid launcher process identifier.");
}
if (!WaitForParent(pid)) {
WriteResult(source.parent_path(), target,
{false, "Update cancelled: the launcher did not exit within two minutes."});
return 1;
}
auto result = Kyty::Updater::Install(source, target, launcher);
WriteResult(source.parent_path(), target, result);
if (!Launch(target / launcher, fs::absolute(arguments[6]))) {
result.message += "\nThe launcher could not restart. Open it manually.";
WriteResult(source.parent_path(), target, result);
std::cerr << result.message << '\n';
return 1;
}
if (result.success) {
CleanupWorkspace(source, target);
}
return result.success ? 0 : 1;
}
} // namespace
#ifdef _WIN32
int wmain(int argc, wchar_t* argv[]) {
#else
int main(int argc, char* argv[]) {
#endif
try {
return Run(std::vector<fs::path>(argv, argv + argc));
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
return 1;
}
}