add self updater

This commit is contained in:
nmzik 2026-10-10 05:12:22 +02:00
parent 679be25c37
commit b41c23d338
14 changed files with 1510 additions and 125 deletions

View File

@ -116,12 +116,12 @@ jobs:
audio_out2_port_tests pad_haptics_tests controller_settings_tests ime_dialog_tests lru_cache_tests virtual_memory_allocation_tests ^
shader_recompiler_compute_tests ^
resource_tracking_tests scalar_provenance_tests ^
resource_materialization_tests shader_vertex_metadata_tests archive_file_tests avplayer_file_tests --parallel
resource_materialization_tests shader_vertex_metadata_tests archive_file_tests avplayer_file_tests update_release_tests update_installer_tests --parallel
- name: Test
shell: cmd
run: |
ctest --test-dir _Build/windows --output-on-failure -R "^(audio_out2_port|pad_haptics|controller_settings|ime_dialog|lru_cache|resource_tracking|scalar_provenance|resource_materialization|shader_vertex_metadata|archive_file|avplayer_file|virtual_memory_allocation)$"
ctest --test-dir _Build/windows --output-on-failure -R "^(audio_out2_port|pad_haptics|controller_settings|ime_dialog|lru_cache|resource_tracking|scalar_provenance|resource_materialization|shader_vertex_metadata|archive_file|avplayer_file|virtual_memory_allocation|update_release|update_installer)$"
- name: Install
shell: cmd
@ -205,14 +205,14 @@ jobs:
--target launcher audio_out2_port_tests pad_haptics_tests controller_settings_tests ime_dialog_tests lru_cache_tests virtual_memory_allocation_tests \
memory_tracker_tests page_manager_tests shader_recompiler_compute_tests \
resource_tracking_tests scalar_provenance_tests \
resource_materialization_tests shader_vertex_metadata_tests archive_file_tests avplayer_file_tests \
resource_materialization_tests shader_vertex_metadata_tests archive_file_tests avplayer_file_tests update_release_tests update_installer_tests \
--parallel
- name: Test
shell: bash
run: |
ctest --test-dir _Build/macos --output-on-failure \
-R '^(audio_out2_port|pad_haptics|controller_settings|ime_dialog|lru_cache|memory_tracker|page_manager|resource_tracking|scalar_provenance|resource_materialization|shader_vertex_metadata|archive_file|avplayer_file|virtual_memory_allocation)$'
-R '^(audio_out2_port|pad_haptics|controller_settings|ime_dialog|lru_cache|memory_tracker|page_manager|resource_tracking|scalar_provenance|resource_materialization|shader_vertex_metadata|archive_file|avplayer_file|virtual_memory_allocation|update_release|update_installer)$'
- name: Install
shell: bash
@ -257,6 +257,7 @@ jobs:
"$install_dir/libMoltenVK.dylib" \
"$app_dir/Contents/MacOS/KytyPS5" \
"$app_dir/Contents/MacOS/kyty_emulator" \
"$app_dir/Contents/MacOS/kyty_updater" \
"$app_dir/Contents/Frameworks/libMoltenVK.dylib"; do
file "$binary"
lipo "$binary" -verify_arch x86_64
@ -266,6 +267,7 @@ jobs:
"$install_dir/libMoltenVK.dylib" \
"$app_dir/Contents/MacOS/KytyPS5" \
"$app_dir/Contents/MacOS/kyty_emulator" \
"$app_dir/Contents/MacOS/kyty_updater" \
"$app_dir/Contents/Frameworks/libMoltenVK.dylib"; do
codesign --verify --strict "$binary"
done
@ -388,14 +390,14 @@ jobs:
--target launcher page_manager_tests memory_tracker_tests \
audio_out2_port_tests pad_haptics_tests controller_settings_tests ime_dialog_tests lru_cache_tests virtual_memory_allocation_tests \
resource_tracking_tests scalar_provenance_tests \
resource_materialization_tests shader_vertex_metadata_tests archive_file_tests avplayer_file_tests \
resource_materialization_tests shader_vertex_metadata_tests archive_file_tests avplayer_file_tests update_release_tests update_installer_tests \
--parallel
- name: Test
shell: bash
run: |
ctest --test-dir _Build/linux --output-on-failure \
-R '^(audio_out2_port|pad_haptics|controller_settings|ime_dialog|lru_cache|memory_tracker|page_manager|resource_tracking|scalar_provenance|resource_materialization|shader_vertex_metadata|archive_file|avplayer_file|virtual_memory_allocation)$'
-R '^(audio_out2_port|pad_haptics|controller_settings|ime_dialog|lru_cache|memory_tracker|page_manager|resource_tracking|scalar_provenance|resource_materialization|shader_vertex_metadata|archive_file|avplayer_file|virtual_memory_allocation|update_release|update_installer)$'
- name: Install
shell: bash
@ -407,6 +409,7 @@ jobs:
run: |
file _Build/linux/install/launcher
file _Build/linux/install/kyty_emulator
test -x _Build/linux/install/kyty_updater
file _Build/linux/install/kyty_emulator | grep -q "ELF 64-bit LSB .*x86-64"
ldd _Build/linux/install/kyty_emulator > /dev/null
readelf -d _Build/linux/install/launcher | grep -q 'RPATH.*\$ORIGIN/lib'
@ -495,7 +498,8 @@ jobs:
test -d "$linux_dir"
chmod a+x \
"$linux_dir/launcher" \
"$linux_dir/kyty_emulator"
"$linux_dir/kyty_emulator" \
"$linux_dir/kyty_updater"
tar -C "$linux_dir" -czf \
"$GITHUB_WORKSPACE/$RELEASE_NAME-Linux-x86_64.tar.gz" .

View File

@ -338,6 +338,14 @@ folders recursively for game directories containing `eboot.bin` and ZArchive (`.
whose archive root contains `eboot.bin`. Select a detected game and run it from the game list.
ZArchive dumps are mounted read-only and streamed directly; they do not need to be extracted first.
Official release builds include a built-in updater. Leave **Check for updates on startup** enabled,
or click **Check for updates**, then choose **Install Update**. The launcher downloads the package
for your platform from GitHub, checks its SHA-256 checksum, and restarts after installation. Close
any running games first. Settings, saves, patches, and other files outside the release package are
preserved; replacement failures restore the previous files. The installation folder must be writable.
If a package cannot be installed automatically, the updater offers the GitHub release page.
Source and fork builds do not replace themselves with official releases.
The emulator can also be started directly with a legally obtained game directory, ELF file, or
ZArchive dump:

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,22 @@ 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(BUILD_TESTING)
add_executable(update_release_tests EXCLUDE_FROM_ALL
"${KYTY_TESTS_DIR}/UpdateReleaseTests.cpp" src/updateRelease.cpp)
target_include_directories(update_release_tests PRIVATE include)
target_link_libraries(update_release_tests PRIVATE Qt6::Core)
add_test(NAME update_release COMMAND update_release_tests)
add_dependencies(kyty_tests update_release_tests)
endif()
if(KYTY_CLANG_CL)
add_custom_command(TARGET launcher POST_BUILD
@ -96,6 +112,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 +129,10 @@ if(APPLE)
")
endif()
if(NOT APPLE)
install(TARGETS kyty_updater RUNTIME DESTINATION .)
endif()
if(WIN32)
install(CODE "
execute_process(

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

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

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();

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);
}
}

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

View File

@ -0,0 +1,26 @@
# 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_library(update_installer STATIC UpdateInstaller.cpp)
target_include_directories(update_installer PUBLIC "${KYTY_SOURCE_DIR}")
add_executable(kyty_updater main.cpp)
target_link_libraries(kyty_updater PRIVATE update_installer)
if(MSVC)
target_compile_options(update_installer PUBLIC /EHsc)
else()
target_compile_options(update_installer PUBLIC -fexceptions)
endif()
if(MINGW)
target_link_options(kyty_updater PRIVATE -static -municode)
endif()
if(BUILD_TESTING)
add_executable(update_installer_tests EXCLUDE_FROM_ALL "${KYTY_TESTS_DIR}/UpdateInstallerTests.cpp")
target_link_libraries(update_installer_tests PRIVATE update_installer)
add_dependencies(update_installer_tests kyty_updater)
add_test(NAME update_installer COMMAND update_installer_tests $<TARGET_FILE:kyty_updater>)
add_dependencies(kyty_tests update_installer_tests)
endif()

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

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
src/updater/main.cpp Normal file
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;
}
}

View File

@ -0,0 +1,335 @@
#include "updater/UpdateInstaller.h"
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <string>
#include <thread>
#ifdef _WIN32
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#else
#include <csignal>
#include <sys/wait.h>
#include <unistd.h>
#endif
namespace {
namespace fs = std::filesystem;
using Kyty::Updater::Install;
using Kyty::Updater::Validate;
void Check(bool condition, const char* message) {
if (!condition) {
std::fprintf(stderr, "UpdateInstallerTests: %s\n", message);
std::abort();
}
}
std::string Read(const fs::path& path) {
std::ifstream input(path);
return {std::istreambuf_iterator<char>(input), std::istreambuf_iterator<char>()};
}
void Write(const fs::path& path, const std::string& content) {
fs::create_directories(path.parent_path());
std::ofstream(path) << content;
fs::permissions(path, fs::perms::owner_all, fs::perm_options::add);
}
struct Fixture {
fs::path root = fs::temp_directory_path() /
("kyty-updater-test-" +
std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
fs::path source = root / "release";
fs::path target = root / "installed";
#ifdef _WIN32
fs::path launcher = "launcher.exe";
const char* emulator = "kyty_emulator.exe";
const char* updater = "kyty_updater.exe";
#else
fs::path launcher = "launcher";
const char* emulator = "kyty_emulator";
const char* updater = "kyty_updater";
#endif
Fixture() {
fs::create_directories(target);
Release();
Write(target / launcher, "old launcher");
}
void Release() {
Write(source / launcher, "new launcher");
Write(source / emulator, "new emulator");
Write(source / updater, "new updater");
}
~Fixture() {
std::error_code ignored;
fs::remove_all(root, ignored);
}
};
void TestOverlayPreservesUserFiles() {
Fixture fixture;
Write(fixture.target / "_SaveData" / "save.dat", "valuable save");
Write(fixture.target / "config.ini", "user settings");
Write(fixture.source / "lib" / "runtime", "new runtime");
Check(Validate(fixture.source, fixture.target, fixture.launcher).success,
"valid release rejected");
Check(Install(fixture.source, fixture.target, fixture.launcher).success,
"valid release failed to install");
Check(Read(fixture.target / fixture.launcher) == "new launcher", "launcher was not updated");
Check(!fs::exists(fixture.source / fixture.launcher),
"update copied rather than moved its staged executable");
Check(Read(fixture.target / "lib" / "runtime") == "new runtime",
"nested runtime was not installed");
Check(Read(fixture.target / "_SaveData" / "save.dat") == "valuable save",
"savedata was removed");
Check(Read(fixture.target / "config.ini") == "user settings", "settings were overwritten");
for (const auto& entry: fs::directory_iterator(fixture.target)) {
Check(!entry.path().filename().string().starts_with(".kyty-update"),
"successful update left a backup");
}
}
void TestExclusiveInstallLock() {
Fixture fixture;
fs::create_directory(fixture.target / ".kyty-update-lock");
Check(!Validate(fixture.source, fixture.target, fixture.launcher).success,
"validation ignored existing install lock");
const auto result = Install(fixture.source, fixture.target, fixture.launcher);
Check(!result.success, "concurrent install lock was ignored");
Check(Read(fixture.target / fixture.launcher) == "old launcher",
"locked update changed installed files");
Check(fs::exists(fixture.target / ".kyty-update-lock"), "another updater's lock was removed");
}
void TestValidation() {
Fixture fixture;
Check(!Validate(fixture.source, fixture.target, "../launcher").success,
"parent traversal accepted");
Check(!Validate(fixture.source, fixture.target, fixture.source / fixture.launcher).success,
"absolute launcher accepted");
Check(!Validate(fixture.source, fixture.source, fixture.launcher).success,
"overlapping source accepted");
fs::remove(fixture.source / fixture.updater);
Check(!Validate(fixture.source, fixture.target, fixture.launcher).success,
"missing updater accepted");
Write(fixture.source / fixture.updater, "updater");
Write(fixture.source / ".kyty-update-result", "spoofed result");
Check(!Validate(fixture.source, fixture.target, fixture.launcher).success,
"reserved updater file accepted");
fs::remove(fixture.source / ".kyty-update-result");
Write(fixture.target / "lib", "user file");
Write(fixture.source / "lib" / "runtime", "runtime");
Check(!Install(fixture.source, fixture.target, fixture.launcher).success,
"directory/file collision accepted");
Check(Read(fixture.target / fixture.launcher) == "old launcher",
"validation failure changed existing launcher");
}
#ifndef _WIN32
void TestSymlinks() {
Fixture fixture;
Write(fixture.source / "lib" / "runtime.2", "runtime v2");
fs::create_symlink("runtime.2", fixture.source / "lib" / "runtime");
const auto first = Install(fixture.source, fixture.target, fixture.launcher);
if (!first.success) {
std::fprintf(stderr, "%s\n", first.message.c_str());
}
Check(first.success, "internal release symlink rejected");
Check(fs::is_symlink(fixture.target / "lib" / "runtime"), "release symlink was dereferenced");
Check(Read(fixture.target / "lib" / "runtime") == "runtime v2",
"installed symlink target is wrong");
fixture.Release();
Write(fixture.source / "lib" / "runtime.2", "runtime v3");
fs::create_symlink("runtime.2", fixture.source / "lib" / "runtime");
Check(Install(fixture.source, fixture.target, fixture.launcher).success,
"second update rejected existing internal link");
fixture.Release();
fs::create_symlink("../../installed/launcher", fixture.source / "lib" / "escape");
Check(!Validate(fixture.source, fixture.target, fixture.launcher).success,
"escaping source symlink accepted");
fs::remove(fixture.source / "lib" / "escape");
fs::create_symlink(fixture.source / fixture.launcher, fixture.source / "absolute-link");
Check(!Validate(fixture.source, fixture.target, fixture.launcher).success,
"absolute source symlink accepted");
fs::remove(fixture.source / "absolute-link");
fs::remove_all(fixture.target / "lib");
fs::create_directory(fixture.root / "user-data");
fs::create_directory_symlink(fixture.root / "user-data", fixture.target / "lib");
Check(!Validate(fixture.source, fixture.target, fixture.launcher).success,
"destination symlink directory accepted");
Check(fs::is_empty(fixture.root / "user-data"), "validation wrote through destination symlink");
}
void TestExecutePermissions() {
Fixture fixture;
fs::permissions(fixture.source / fixture.launcher,
fs::perms::owner_read | fs::perms::owner_write);
Check(!Validate(fixture.source, fixture.target, fixture.launcher).success,
"non-executable launcher accepted");
}
#endif
void TestRollback() {
#ifndef _WIN32
// A root test runner bypasses file permissions; normal users exercise a real
// rename failure after the launcher and a new nested file have been
// installed.
if (geteuid() == 0) {
return;
}
#endif
Fixture fixture;
Write(fixture.source / "a-new" / "runtime", "new file");
Write(fixture.source / "z-locked" / "runtime", "new library");
Write(fixture.target / "z-locked" / "runtime", "old library");
#ifdef _WIN32
HANDLE locked_file =
CreateFileW((fixture.target / "z-locked" / "runtime").c_str(), GENERIC_READ,
FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
Check(locked_file != INVALID_HANDLE_VALUE, "could not lock destination for rollback test");
#else
fs::permissions(fixture.target / "z-locked", fs::perms::owner_read | fs::perms::owner_exec);
#endif
const auto result = Install(fixture.source, fixture.target, fixture.launcher);
#ifdef _WIN32
CloseHandle(locked_file);
#else
fs::permissions(fixture.target / "z-locked", fs::perms::owner_all);
#endif
Check(!result.success, "locked destination did not fail the transaction");
Check(Read(fixture.source / fixture.launcher) == "new launcher",
"rollback did not restore the staged release");
Check(Read(fixture.target / fixture.launcher) == "old launcher",
"rollback did not restore the launcher");
Check(Read(fixture.target / "z-locked" / "runtime") == "old library",
"rollback damaged an unchanged file");
Check(!fs::exists(fixture.target / "a-new"), "rollback retained newly installed directories");
Check(!fs::exists(fixture.target / fixture.emulator),
"rollback retained a newly installed executable");
}
#ifndef _WIN32
void TestCommandLine(const fs::path& helper) {
for (int scenario = 0; scenario < 3; ++scenario) {
Fixture fixture;
const auto workspace = fixture.target / ".kyty-update-test";
fs::create_directory(workspace);
fs::rename(fixture.source, workspace / "payload");
fixture.source = workspace / "payload";
const auto original_cwd = fixture.root / "original working directory";
fs::create_directory(original_cwd);
const std::string old_launcher =
"#!/bin/sh\nprintf 'old\\n' > restarted-version\npwd > restarted-cwd\n";
Write(fixture.target / fixture.launcher, old_launcher);
Write(fixture.source / fixture.launcher,
"#!/bin/sh\nprintf 'new\\n' > restarted-version\npwd > "
"restarted-cwd\n");
if (scenario == 1) {
// Invalid payload exercises the CLI's failed-install/old-launcher path.
fs::remove(fixture.source / fixture.updater);
} else if (scenario == 2) {
Write(fixture.target / ".kyty-update-result" / "blocker", "keep");
}
int parent_pipe[2];
Check(pipe(parent_pipe) == 0, "cannot create parent synchronization pipe");
const auto parent = fork();
Check(parent >= 0, "cannot fork simulated launcher");
if (parent == 0) {
close(parent_pipe[1]);
char signal;
static_cast<void>(read(parent_pipe[0], &signal, 1));
_exit(0);
}
close(parent_pipe[0]);
const auto parent_pid = std::to_string(parent);
const auto updater = fork();
Check(updater >= 0, "cannot fork update helper");
if (updater == 0) {
close(parent_pipe[1]);
execl(helper.c_str(), helper.c_str(), "--install", fixture.source.c_str(),
fixture.target.c_str(), fixture.launcher.c_str(), parent_pid.c_str(),
original_cwd.c_str(), static_cast<char*>(nullptr));
_exit(127);
}
std::this_thread::sleep_for(std::chrono::milliseconds(100));
int status = 0;
Check(waitpid(updater, &status, WNOHANG) == 0, "helper exited before its parent");
Check(Read(fixture.target / fixture.launcher) == old_launcher,
"helper updated a running launcher");
close(parent_pipe[1]);
waitpid(parent, nullptr, 0);
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5);
pid_t finished;
while ((finished = waitpid(updater, &status, WNOHANG)) == 0 &&
std::chrono::steady_clock::now() < deadline) {
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
if (finished == 0) {
kill(updater, SIGKILL);
waitpid(updater, nullptr, 0);
}
Check(finished == updater && WIFEXITED(status) &&
WEXITSTATUS(status) == (scenario == 1 ? 1 : 0),
"helper did not complete with the expected status");
while (Read(original_cwd / "restarted-cwd").empty() &&
std::chrono::steady_clock::now() < deadline) {
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
Check(Read(original_cwd / "restarted-version") == (scenario == 1 ? "old\n" : "new\n"),
"wrong launcher restarted");
Check(Read(original_cwd / "restarted-cwd") == fs::canonical(original_cwd).string() + "\n",
"restart changed the working directory");
if (scenario != 2) {
Check(Read(fixture.target / ".kyty-update-result")
.starts_with(scenario == 1 ? "failure\n" : "success\n"),
"helper did not record the update result");
}
if (scenario != 1) {
Check(!fs::exists(workspace), "helper did not clean the successful download");
}
}
}
void TestMacBundleScope() {
Fixture fixture;
const fs::path launcher = "KytyPS5.app/Contents/MacOS/KytyPS5";
Write(fixture.source / launcher, "new mac launcher");
Write(fixture.source / launcher.parent_path() / "kyty_emulator", "new mac emulator");
Write(fixture.source / launcher.parent_path() / "kyty_updater", "new mac updater");
Write(fixture.target / launcher, "old mac launcher");
Write(fixture.target / launcher.parent_path() / "_SaveData" / "save", "mac save");
Write(fixture.source / "unrelated.txt", "must stay outside installation");
Check(Install(fixture.source, fixture.target, launcher).success, "mac bundle update failed");
Check(Read(fixture.target / launcher) == "new mac launcher", "mac launcher not replaced");
Check(Read(fixture.target / launcher.parent_path() / "_SaveData" / "save") == "mac save",
"mac savedata was removed");
Check(!fs::exists(fixture.target / "unrelated.txt"), "mac update installed archive siblings");
Check(Read(fixture.target / fixture.launcher) == "old launcher",
"mac update changed flat launcher sibling");
}
#endif
} // namespace
int main(int argc, char* argv[]) {
TestOverlayPreservesUserFiles();
TestValidation();
TestExclusiveInstallLock();
TestRollback();
#ifndef _WIN32
TestSymlinks();
TestExecutePermissions();
TestMacBundleScope();
if (argc == 2) {
TestCommandLine(fs::absolute(argv[1]));
}
#else
static_cast<void>(argc);
static_cast<void>(argv);
#endif
return 0;
}

View File

@ -0,0 +1,124 @@
#include "updateRelease.h"
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <cstdio>
#include <cstdlib>
namespace {
const QUrl EXPECTED_PAGE_URL(
QStringLiteral("https://github.com/KytyPS5/KytyPS5/releases/tag/KytyPS5-2026-10-10-abcdef0"));
const QByteArray EXPECTED_DOWNLOAD_URL =
"https://github.com/KytyPS5/KytyPS5/releases/download/v1%2Fstable%2Btest/"
"v1%2Fstable%2Btest-Linux-x86_64.tar.gz";
const QString TAG = QStringLiteral("KytyPS5-2026-10-10-abcdef0");
const QString SUFFIX = QStringLiteral("-Linux-x86_64.tar.gz");
void Check(bool condition, const char* message) {
if (!condition) {
std::fprintf(stderr, "UpdateReleaseTests: %s\n", message);
std::abort();
}
}
QJsonObject Release(const QString& suffix = SUFFIX, const QString& tag = TAG) {
const QJsonObject asset {
{QStringLiteral("name"), tag + suffix},
{QStringLiteral("size"), 1234},
{QStringLiteral("digest"), QStringLiteral("sha256:") + QString(64, QLatin1Char('a'))}};
return {{QStringLiteral("tag_name"), tag},
{QStringLiteral("published_at"), QStringLiteral("2026-10-10T12:00:00Z")},
{QStringLiteral("draft"), false},
{QStringLiteral("prerelease"), false},
{QStringLiteral("assets"), QJsonArray {asset}}};
}
UpdateRelease::Info Parse(const QJsonObject& root, const QString& suffix = SUFFIX) {
return UpdateRelease::Parse(QJsonDocument(root).toJson(QJsonDocument::Compact), suffix);
}
void CheckInvalidAsset(const QString& key, const QJsonValue& value) {
auto root = Release();
auto asset = root.value(QStringLiteral("assets")).toArray().first().toObject();
asset.insert(key, value);
root.insert(QStringLiteral("assets"), QJsonArray {asset});
const auto info = Parse(root);
Check(!info.error.isEmpty() && info.download_url.isEmpty() && info.sha256.isEmpty(),
"invalid asset was accepted");
Check(info.tag == TAG && !info.page_url.isEmpty() && info.published_at.isValid(),
"invalid package discarded valid release metadata");
}
void TestParsing() {
for (const auto& suffix:
{SUFFIX, QStringLiteral("-Windows-x64.zip"), QStringLiteral("-macOS-x86_64.zip")}) {
const auto info = Parse(Release(suffix), suffix);
Check(info.error.isEmpty() && info.tag == TAG && info.size == 1234,
"valid platform package was rejected");
Check(info.sha256 == QByteArray(32, static_cast<char>(0xaa)), "checksum was not decoded");
Check(info.download_url.fileName() == TAG + suffix, "wrong asset was selected");
Check(info.page_url == EXPECTED_PAGE_URL,
"release page was not derived from the official repository");
}
const auto encoded = Parse(Release(SUFFIX, QStringLiteral("v1/stable+test")));
Check(encoded.download_url.toEncoded() == EXPECTED_DOWNLOAD_URL,
"release tag and asset name were not encoded as URL segments");
Check(!UpdateRelease::Parse("not JSON", SUFFIX).error.isEmpty(), "malformed JSON was accepted");
Check(!UpdateRelease::Parse("[]", SUFFIX).error.isEmpty(), "JSON array was accepted");
Check(!Parse(Release(), {}).error.isEmpty(), "unsupported platform was accepted");
Check(!Parse(Release(), QStringLiteral("-Windows-x64.zip")).error.isEmpty(),
"missing asset was accepted");
for (const auto& key: {QStringLiteral("draft"), QStringLiteral("prerelease")}) {
auto root = Release();
root.insert(key, true);
Check(!Parse(root).error.isEmpty(), "draft or prerelease was accepted");
}
for (const auto& field: {QStringLiteral("tag_name"), QStringLiteral("published_at")}) {
auto root = Release();
root.remove(field);
Check(!Parse(root).error.isEmpty(), "missing release metadata was accepted");
}
for (const auto& digest: {QString(), QStringLiteral("sha256:abc"),
QStringLiteral("sha256:") + QString(64, QLatin1Char('z'))}) {
CheckInvalidAsset(QStringLiteral("digest"), digest);
}
for (const double size: {-1., 0., 1.5}) {
CheckInvalidAsset(QStringLiteral("size"), size);
}
CheckInvalidAsset(QStringLiteral("name"), QStringLiteral("another-release") + SUFFIX);
}
void TestOrdering() {
using UpdateRelease::Compare;
using UpdateRelease::VersionRelation;
const auto latest = Parse(Release());
auto current = latest;
Check(Compare(latest, current) == VersionRelation::Current, "matching tags were not current");
current.tag = QStringLiteral("KytyPS5-2026-10-10-1234567");
Check(Compare(latest, current) == VersionRelation::Unknown,
"equal timestamps implied an update");
current.published_at = latest.published_at.addSecs(-60);
Check(Compare(latest, current) == VersionRelation::Newer, "same-day newer release was missed");
current.published_at = latest.published_at.addSecs(60);
Check(Compare(latest, current) == VersionRelation::Older,
"older release would cause a downgrade");
current.published_at = {};
Check(Compare(latest, current) == VersionRelation::Unknown,
"missing current metadata implied an update");
Check(Compare(latest, {}) == VersionRelation::Unknown,
"empty current release implied an update");
}
} // namespace
int main() {
TestParsing();
TestOrdering();
return 0;
}