Compare commits

...

2 Commits

Author SHA1 Message Date
nmzik f1764a0bba remove updater tests after validation 2026-10-10 05:31:08 +02:00
nmzik b41c23d338 add self updater 2026-10-10 05:12:22 +02:00
12 changed files with 1025 additions and 119 deletions

View File

@ -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
@ -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,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(

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

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