diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 91f46e93..4bddf479 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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" . diff --git a/README.md b/README.md index 1f22d8a6..a8210185 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/src/launcher/CMakeLists.txt b/src/launcher/CMakeLists.txt index 77949e55..bee43592 100644 --- a/src/launcher/CMakeLists.txt +++ b/src/launcher/CMakeLists.txt @@ -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 $ + COMMAND ${CMAKE_COMMAND} -E copy_if_different $ + $/$ + 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( diff --git a/src/launcher/include/updateChecker.h b/src/launcher/include/updateChecker.h index e94414d5..9768f88b 100644 --- a/src/launcher/include/updateChecker.h +++ b/src/launcher/include/updateChecker.h @@ -1,12 +1,21 @@ #ifndef UPDATE_CHECKER_H #define UPDATE_CHECKER_H +#include "updateRelease.h" + +#include +#include #include #include -#include -#include +#include +#include -class QByteArray; +#include +#include + +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 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 m_reply; + QPointer m_progress; + QProcess m_prepare; + std::unique_ptr 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 diff --git a/src/launcher/include/updateRelease.h b/src/launcher/include/updateRelease.h new file mode 100644 index 00000000..6624d4da --- /dev/null +++ b/src/launcher/include/updateRelease.h @@ -0,0 +1,38 @@ +#ifndef UPDATE_RELEASE_H +#define UPDATE_RELEASE_H + +#include +#include +#include +#include + +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 diff --git a/src/launcher/src/mainDialog.cpp b/src/launcher/src/mainDialog.cpp index d0145614..ac0c4297 100644 --- a/src/launcher/src/mainDialog.cpp +++ b/src/launcher/src/mainDialog.cpp @@ -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(); diff --git a/src/launcher/src/updateChecker.cpp b/src/launcher/src/updateChecker.cpp index f0ef2e17..04c62e91 100644 --- a/src/launcher/src/updateChecker.cpp +++ b/src/launcher/src/updateChecker.cpp @@ -2,143 +2,418 @@ #include "kytyGitVersion.h" -#include +#include +#include #include #include -#include -#include +#include +#include #include #include #include -#include -#include -#include +#include +#include +#include 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 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( + 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(qMin(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(&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); } } diff --git a/src/launcher/src/updateRelease.cpp b/src/launcher/src/updateRelease.cpp new file mode 100644 index 00000000..7b2c0ae3 --- /dev/null +++ b/src/launcher/src/updateRelease.cpp @@ -0,0 +1,89 @@ +#include "updateRelease.h" + +#include +#include +#include +#include +#include + +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 diff --git a/src/updater/CMakeLists.txt b/src/updater/CMakeLists.txt new file mode 100644 index 00000000..623e9734 --- /dev/null +++ b/src/updater/CMakeLists.txt @@ -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$<$: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 $) + add_dependencies(kyty_tests update_installer_tests) +endif() diff --git a/src/updater/UpdateInstaller.cpp b/src/updater/UpdateInstaller.cpp new file mode 100644 index 00000000..c274705b --- /dev/null +++ b/src/updater/UpdateInstaller.cpp @@ -0,0 +1,249 @@ +#include "UpdateInstaller.h" + +#include +#include +#include + +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 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 changes; + std::vector 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 diff --git a/src/updater/UpdateInstaller.h b/src/updater/UpdateInstaller.h new file mode 100644 index 00000000..97e06221 --- /dev/null +++ b/src/updater/UpdateInstaller.h @@ -0,0 +1,18 @@ +#pragma once + +#include +#include + +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 diff --git a/src/updater/main.cpp b/src/updater/main.cpp new file mode 100644 index 00000000..243f1b2c --- /dev/null +++ b/src/updater/main.cpp @@ -0,0 +1,167 @@ +#include "UpdateInstaller.h" + +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#include +#else +#include +#include +#include +#include +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(std::numeric_limits::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), 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(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& 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(argv, argv + argc)); + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/tests/UpdateInstallerTests.cpp b/tests/UpdateInstallerTests.cpp new file mode 100644 index 00000000..166ae884 --- /dev/null +++ b/tests/UpdateInstallerTests.cpp @@ -0,0 +1,335 @@ +#include "updater/UpdateInstaller.h" + +#include +#include +#include +#include +#include +#include +#include +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#include +#else +#include +#include +#include +#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(input), std::istreambuf_iterator()}; +} + +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(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(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(argc); + static_cast(argv); +#endif + return 0; +} diff --git a/tests/UpdateReleaseTests.cpp b/tests/UpdateReleaseTests.cpp new file mode 100644 index 00000000..3a0a01c7 --- /dev/null +++ b/tests/UpdateReleaseTests.cpp @@ -0,0 +1,124 @@ +#include "updateRelease.h" + +#include +#include +#include + +#include +#include + +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(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; +}