From 676b1aabfcd5f755ba82da963763410d7842ddc6 Mon Sep 17 00:00:00 2001 From: crueter Date: Tue, 28 Apr 2026 20:42:23 +0200 Subject: [PATCH] [frontend] Built-in auto updater (#3845) Checks latest release and opens a dialog containing the changelog, and allow the user to select a specific build to download. After downloading, it prompts the user to open it. On Windows, this just opens up the zip in File Explorer. In the future setup files will be available. On macOS this opens up the DMG in Finder so the user can drag it to the Applications folder. Android retains the auto-update functionality from before, but updated to the new scheme. Body/View on Forgejo are not implemented, that should be in a future PR. Additionally, moved some common httplib incantations to `Common::Net`. This will serve as the common network accessor and JSON parser from here on out. TODO: - [x] android :( - [x] Search for builds based on keywords, with weights towards certain builds (e.g. macOS will search for dmg then tar.gz, windows msvc then mingw/exe then zip, etc.) - [x] remove linux leftovers - [x] don't allow asset selection on platforms w/o assets - [x] nightly changelog should be in the real FUTURE IMPLEMENTATION: - [ ] Body/View on Forgejo for Android - [ ] Setup files for Windows (Eden/nightly are separate) -- maybe portable/setup selector? - [ ] Something else I'm forgetting Signed-off-by: crueter Reviewed-on: https://git.eden-emu.dev/eden-emu/eden/pulls/3845 --- CMakeLists.txt | 13 +- CMakeModules/GenerateSCMRev.cmake | 6 +- src/CMakeLists.txt | 10 + src/android/app/build.gradle.kts | 7 + .../java/org/yuzu/yuzu_emu/NativeLibrary.kt | 24 +- .../org/yuzu/yuzu_emu/ui/main/MainActivity.kt | 36 ++- src/android/app/src/main/jni/native.cpp | 116 +++---- .../app/src/main/res/values/strings.xml | 1 + src/common/CMakeLists.txt | 5 +- src/common/net/net.cpp | 287 ++++++++++++++++++ src/common/net/net.h | 56 ++++ src/common/scm_rev.cpp.in | 6 + src/common/scm_rev.h | 3 + .../hle/service/bcat/news/builtin_news.cpp | 159 ++-------- src/frontend_common/update_checker.cpp | 147 ++------- src/frontend_common/update_checker.h | 10 +- src/yuzu/CMakeLists.txt | 2 + .../configure_per_game_addons.cpp | 1 - src/yuzu/main_window.cpp | 20 +- src/yuzu/main_window.h | 4 +- src/yuzu/updater/update_dialog.cpp | 178 +++++++++++ src/yuzu/updater/update_dialog.h | 28 ++ src/yuzu/updater/update_dialog.ui | 112 +++++++ 23 files changed, 856 insertions(+), 375 deletions(-) create mode 100644 src/common/net/net.cpp create mode 100644 src/common/net/net.h create mode 100644 src/yuzu/updater/update_dialog.cpp create mode 100644 src/yuzu/updater/update_dialog.h create mode 100644 src/yuzu/updater/update_dialog.ui diff --git a/CMakeLists.txt b/CMakeLists.txt index 1f000141f0..1142585b6d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -268,11 +268,6 @@ if (NOT EXISTS ${PROJECT_BINARY_DIR}/${compat_json}) file(WRITE ${PROJECT_BINARY_DIR}/${compat_json} "") endif() -if (YUZU_LEGACY) - message(WARNING "Making legacy build. Performance may suffer.") - add_compile_definitions(YUZU_LEGACY) -endif() - if (ARCHITECTURE_arm64 AND (ANDROID OR PLATFORM_LINUX)) set(HAS_NCE 1) add_compile_definitions(HAS_NCE=1) @@ -424,10 +419,10 @@ if (zstd_ADDED) add_library(zstd::libzstd ALIAS libzstd_static) endif() -if (NOT YUZU_STATIC_ROOM) - # nlohmann - AddJsonPackage(nlohmann) +# nlohmann +AddJsonPackage(nlohmann) +if (NOT YUZU_STATIC_ROOM) # zlib AddJsonPackage(zlib) @@ -485,7 +480,7 @@ endfunction() # ============================================= if (APPLE) - foreach(fw Carbon Metal Cocoa IOKit CoreVideo CoreMedia) + foreach(fw Carbon Metal Cocoa IOKit CoreVideo CoreMedia Security) find_library(${fw}_LIBRARY ${fw} REQUIRED) list(APPEND PLATFORM_LIBRARIES ${${fw}_LIBRARY}) endforeach() diff --git a/CMakeModules/GenerateSCMRev.cmake b/CMakeModules/GenerateSCMRev.cmake index 2b028b4846..40c64fea1e 100644 --- a/CMakeModules/GenerateSCMRev.cmake +++ b/CMakeModules/GenerateSCMRev.cmake @@ -33,9 +33,11 @@ endif() set(GIT_DESC ${BUILD_VERSION}) # Generate cpp with Git revision from template -# Also if this is a CI build, add the build name (ie: Nightly, Canary) to the scm_rev file as well -# Auto-updater metadata! Must somewhat mirror GitHub/Forgejo API endpoint +# TODO(crueter): Stable releases feed. +set(BUILD_AUTO_UPDATE_STABLE_REPO "eden-emu/eden") +set(BUILD_AUTO_UPDATE_STABLE_API "git.eden-emu.dev") +set(BUILD_AUTO_UPDATE_STABLE_API_PATH "/api/v1/repos/") set(BUILD_AUTO_UPDATE_API_PATH "/latest/release.json") if (NIGHTLY_BUILD) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 7df229d9f4..ec064a0e34 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -25,6 +25,16 @@ if (NIGHTLY_BUILD) add_compile_definitions(NIGHTLY_BUILD) endif() +if (YUZU_LEGACY) + message(WARNING "Making legacy build. Performance may suffer.") + add_compile_definitions(YUZU_LEGACY) +endif() + +if (GENSHIN_SPOOF) + message(WARNING "Making Genshin spoof build") + add_compile_definitions(GENSHIN_SPOOF) +endif() + # Set compilation flags if (MSVC AND NOT CXX_CLANG) set(CMAKE_CONFIGURATION_TYPES Debug Release CACHE STRING "" FORCE) diff --git a/src/android/app/build.gradle.kts b/src/android/app/build.gradle.kts index 3279a2202f..69b9f850d0 100644 --- a/src/android/app/build.gradle.kts +++ b/src/android/app/build.gradle.kts @@ -88,6 +88,7 @@ android { "-DBUILD_TESTING=OFF", "-DYUZU_TESTS=OFF", "-DDYNARMIC_TESTS=OFF", + "-DENABLE_UPDATE_CHECKER=ON", *extraCMakeArgs.toTypedArray() ) ) @@ -192,6 +193,12 @@ android { manifestPlaceholders += mapOf("appNameBase" to "Eden") resValue("string", "app_name_suffixed", "Eden") + externalNativeBuild { + cmake { + arguments.add("-DGENSHIN_SPOOF=ON") + } + } + ndk { abiFilters += listOf("arm64-v8a") } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt index 397b44c9f9..b21b43a53c 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt @@ -33,6 +33,18 @@ import org.yuzu.yuzu_emu.applets.web.WebBrowser * with the native side of the Yuzu code. */ object NativeLibrary { + data class UpdateResult( + var tag: String = "", + var title: String = "", + var body: String = "", + var url: String = "", + var assets: MutableList = mutableListOf() + ) { + fun addAsset(asset: String) { + assets.add(asset) + } + } + @JvmField var sEmulationActivity = WeakReference(null) @@ -240,17 +252,7 @@ object NativeLibrary { /** * Checks for available updates. */ - external fun checkForUpdate(): Array? - - /** - * Return the URL to the release page - */ - external fun getUpdateUrl(version: String): String - - /** - * Return the URL to download the APK for the given version - */ - external fun getUpdateApkUrl(tag: String, artifact: String, packageId: String): String + external fun checkForUpdate(): UpdateResult? /** * Returns whether the update checker is enabled through CMAKE options. diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt index 584322df50..1da275ab87 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt @@ -175,25 +175,25 @@ class MainActivity : AppCompatActivity(), ThemeProvider { val latestVersion = NativeLibrary.checkForUpdate() if (latestVersion != null) { runOnUiThread { - val tag: String = latestVersion[0] - val name: String = latestVersion[1] - showUpdateDialog(tag, name) + showUpdateDialog(latestVersion) } } }.start() } - private fun showUpdateDialog(tag: String, name: String) { + // TODO(crueter): body, "View on Forgejo" button + private fun showUpdateDialog(release: NativeLibrary.UpdateResult) { MaterialAlertDialogBuilder(this) .setTitle(R.string.update_available) - .setMessage(getString(R.string.update_available_description, name)) + .setMessage(getString(R.string.update_available_description, release.title)) .setPositiveButton(android.R.string.ok) { _, _ -> - var artifact = tag - // Nightly builds have a slightly different format - if (NativeLibrary.isNightlyBuild()) { - artifact = tag.substringAfter('.', tag) + val assets = release.assets + + if (assets.isEmpty()) { + openLink(release.url) + } else { + downloadAndInstallUpdate(release) } - downloadAndInstallUpdate(tag, artifact) } .setNeutralButton(R.string.cancel) { dialog, _ -> dialog.dismiss() @@ -206,17 +206,23 @@ class MainActivity : AppCompatActivity(), ThemeProvider { .show() } - private fun downloadAndInstallUpdate(version: String, artifact: String) { + private fun openLink(link: String) { + val intent = Intent(Intent.ACTION_VIEW, link.toUri()) + startActivity(intent) + } + + private fun downloadAndInstallUpdate(release: NativeLibrary.UpdateResult) { CoroutineScope(Dispatchers.IO).launch { val packageId = applicationContext.packageName - val apkUrl = NativeLibrary.getUpdateApkUrl(version, artifact, packageId) + val asset = release.assets[0] + val artifact = asset.split("/").last() val apkFile = File(cacheDir, "update-$artifact.apk") withContext(Dispatchers.Main) { showDownloadProgressDialog() } - val downloader = APKDownloader(apkUrl, apkFile) + val downloader = APKDownloader(asset, apkFile) downloader.download( onProgress = { progress -> runOnUiThread { @@ -248,7 +254,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider { } else { Toast.makeText( this@MainActivity, - getString(R.string.update_download_failed) + "\n\nURL: $apkUrl", + getString(R.string.update_download_failed) + "\n\nURL: $asset", Toast.LENGTH_LONG ).show() } @@ -277,7 +283,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider { private fun updateDownloadProgress(progress: Int) { progressBar?.progress = progress - progressMessage?.text = "$progress%" + progressMessage?.text = getString(R.string.percent, progress) } private fun dismissDownloadProgressDialog() { diff --git a/src/android/app/src/main/jni/native.cpp b/src/android/app/src/main/jni/native.cpp index b78be0cefa..abfc428919 100644 --- a/src/android/app/src/main/jni/native.cpp +++ b/src/android/app/src/main/jni/native.cpp @@ -1699,76 +1699,76 @@ JNIEXPORT jboolean JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_isNightlyBuild( #ifdef ENABLE_UPDATE_CHECKER -JNIEXPORT jobjectArray JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_checkForUpdate( +JNIEXPORT jobject JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_checkForUpdate( JNIEnv* env, jobject obj) { - std::optional release = UpdateChecker::GetUpdate(); + std::optional release = UpdateChecker::GetUpdate(); if (!release) return nullptr; const std::string tag = release->tag; - const std::string name = release->name; + const std::string title = release->title; + const std::string body = release->body; + const std::string url = release->html_url; - jobjectArray result = env->NewObjectArray(2, env->FindClass("java/lang/String"), nullptr); + // Android *should* only ever define a single asset. + // If not, something has gone wrong, but the Kotlin side can handle it. + const auto assets = release->GetPlatformAssets(); - const jstring jtag = env->NewStringUTF(tag.c_str()); - const jstring jname = env->NewStringUTF(name.c_str()); - - env->SetObjectArrayElement(result, 0, jtag); - env->SetObjectArrayElement(result, 1, jname); - env->DeleteLocalRef(jtag); - env->DeleteLocalRef(jname); - - return result; -} - -JNIEXPORT jstring JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_getUpdateUrl( - JNIEnv* env, - jobject obj, - jstring version) { - const char* version_str = env->GetStringUTFChars(version, nullptr); - const std::string url = fmt::format("{}/{}", - std::string{Common::g_build_auto_update_api}, - version_str); - env->ReleaseStringUTFChars(version, version_str); - return env->NewStringUTF(url.c_str()); -} - -JNIEXPORT jstring JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_getUpdateApkUrl( - JNIEnv* env, - jobject obj, - jstring tag, - jstring artifact, - jstring packageId) { - const char* version_str = env->GetStringUTFChars(tag, nullptr); - const char* artifact_str = env->GetStringUTFChars(artifact, nullptr); - const char* package_id_str = env->GetStringUTFChars(packageId, nullptr); - - std::string variant; - std::string package_id(package_id_str); - - if (package_id.find("dev.legacy.eden_emulator") != std::string::npos) { - variant = "legacy"; - } else if (package_id.find("com.miHoYo.Yuanshen") != std::string::npos) { - variant = "optimized"; - } else { -#ifdef ARCHITECTURE_arm64 - variant = "standard"; -#else - variant = "chromeos"; -#endif + jclass updateResultClass = env->FindClass("org/yuzu/yuzu_emu/NativeLibrary$UpdateResult"); + if (!updateResultClass) { + LOG_ERROR(Frontend, "Could not find UpdateResult class"); + return nullptr; } - const std::string apk_filename = fmt::format("Eden-Android-{}-{}.apk", artifact_str, variant); + jmethodID updateResultCtor = env->GetMethodID(updateResultClass, "", "()V"); - const std::string url = fmt::format("https://{}/{}/{}", - std::string{Common::g_build_auto_update_api}, - version_str, apk_filename); + if (!updateResultCtor) { + LOG_ERROR(Frontend, "Could not find UpdateResult ctor"); + env->DeleteLocalRef(updateResultClass); + return nullptr; + } - env->ReleaseStringUTFChars(tag, version_str); - env->ReleaseStringUTFChars(artifact, artifact_str); - env->ReleaseStringUTFChars(packageId, package_id_str); - return env->NewStringUTF(url.c_str()); + jmethodID setTag = env->GetMethodID(updateResultClass, "setTag", "(Ljava/lang/String;)V"); + jmethodID setTitle = env->GetMethodID(updateResultClass, "setTitle", "(Ljava/lang/String;)V"); + jmethodID setBody = env->GetMethodID(updateResultClass, "setBody", "(Ljava/lang/String;)V"); + jmethodID setUrl = env->GetMethodID(updateResultClass, "setUrl", "(Ljava/lang/String;)V"); + jmethodID addAsset = env->GetMethodID(updateResultClass, "addAsset", "(Ljava/lang/String;)V"); + + jobject updateResult = env->NewObject(updateResultClass, updateResultCtor); + + LOG_DEBUG(Frontend, "Tag: {}", tag); + LOG_DEBUG(Frontend, "Title: {}", title); + LOG_DEBUG(Frontend, "Body: {}", body); + LOG_DEBUG(Frontend, "Url: {}", url); + + const auto jtag = env->NewStringUTF(tag.c_str()); + const auto jtitle = env->NewStringUTF(title.c_str()); + const auto jbody = env->NewStringUTF(body.c_str()); + const auto jurl = env->NewStringUTF(url.c_str()); + + env->CallVoidMethod(updateResult, setTag, jtag); + env->CallVoidMethod(updateResult, setTitle, jtitle); + env->CallVoidMethod(updateResult, setBody, jbody); + env->CallVoidMethod(updateResult, setUrl, jurl); + + // TODO(crueter): Handling for multiple assets? + // Maybe another data class x( + for (const Common::Net::Asset &a : assets) { + const auto jaurl = env->NewStringUTF(a.path.c_str()); + env->CallVoidMethod(updateResult, addAsset, jaurl); + env->DeleteLocalRef(jaurl); + } + + env->DeleteLocalRef(jtag); + env->DeleteLocalRef(jtitle); + env->DeleteLocalRef(jbody); + env->DeleteLocalRef(jurl); + + env->DeleteLocalRef(updateResultClass); + + return updateResult; } + #endif JNIEXPORT jstring JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_getBuildVersion( diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index 7052a7718a..d143a12f4e 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -1783,5 +1783,6 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. External Content Add Folder + %1$d%% diff --git a/src/common/CMakeLists.txt b/src/common/CMakeLists.txt index 2846058df9..fb2bdda146 100644 --- a/src/common/CMakeLists.txt +++ b/src/common/CMakeLists.txt @@ -147,7 +147,8 @@ add_library( zstd_compression.h fs/ryujinx_compat.h fs/ryujinx_compat.cpp fs/symlink.h fs/symlink.cpp - httplib.h) + httplib.h + net/net.h net/net.cpp) if(WIN32) target_sources(common PRIVATE windows/timer_resolution.cpp @@ -245,7 +246,7 @@ else() target_link_libraries(common PUBLIC Boost::headers) endif() -target_link_libraries(common PUBLIC Boost::filesystem Boost::context httplib::httplib) +target_link_libraries(common PUBLIC Boost::filesystem Boost::context httplib::httplib nlohmann_json::nlohmann_json) if (lz4_ADDED) target_include_directories(common PRIVATE ${lz4_SOURCE_DIR}/lib) diff --git a/src/common/net/net.cpp b/src/common/net/net.cpp new file mode 100644 index 0000000000..1d836cbd1d --- /dev/null +++ b/src/common/net/net.cpp @@ -0,0 +1,287 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include +#include +#include + +#include +#include "common/scm_rev.h" +#include "net.h" + +#include "common/logging.h" + +#include "common/httplib.h" + +#ifdef YUZU_BUNDLED_OPENSSL +#include +#endif + +#define QT_TR_NOOP(x) x + +namespace Common::Net { + +std::vector Release::GetPlatformAssets() const { + // TODO(crueter): Need better handling for this as a whole. +#ifdef NIGHTLY_BUILD + std::vector result; + boost::algorithm::split(result, tag, boost::is_any_of(".")); + if (result.size() != 2) + return {}; + const auto ref = result.at(1); +#else + const auto ref = tag; +#endif + + std::vector found_assets; + + // FIXME: This is mildly inefficient. + // Finds assets based on a hierarchy of regex search strings. + const auto find_asset = [&found_assets, ref, this](const std::string& name, + const std::vector& suffixes) { + for (const std::string& asset : assets) { + for (const auto& suffix : suffixes) { + if (asset.ends_with(suffix)) { + const std::string_view asset_sv = asset; + const size_t pos = asset_sv.find_last_of('/'); + const std::string_view filename = + (pos != std::string_view::npos) ? asset_sv.substr(pos + 1) : asset_sv; + + found_assets.emplace_back(Asset{ + .name = name, + .url = host, + .path = asset, + .filename = std::string{filename}, + }); + return; + } + } + } + }; + +#ifdef _WIN32 +#ifdef ARCHITECTURE_x86_64 + find_asset("Standard", {"amd64-msvc-standard.exe", "amd64-msvc-standard.zip", "mingw-amd64-gcc-standard.exe", "mingw-amd64-gcc-standard.zip"}); + find_asset("PGO", {"mingw-amd64-clang-pgo.exe", "mingw-amd64-clang-pgo.zip"}); +#elif defined(ARCHITECTURE_arm64) + find_asset("Standard", {"mingw-arm64-clang-standard.exe", "mingw-arm64-clang-standard.zip"}); + find_asset("PGO", {"mingw-arm64-clang-pgo.exe", "mingw-arm64-clang-pgo.zip"}); +#endif +#elif defined(__APPLE__) +#ifdef ARCHITECTURE_arm64 + find_asset("Standard", {".dmg", ".tar.gz"}); +#endif +#elif defined(__ANDROID__) +#ifdef ARCHITECTURE_x86_64 + find_asset("Standard", {"chromeos.apk"}); +#elif defined(ARCHITECTURE_arm64) +#ifdef YUZU_LEGACY + find_asset("Standard", {"legacy.apk"}); +#elif defined(GENSHIN_SPOOF) + find_asset("Standard", {"optimized.apk"}); +#else + find_asset("Standard", {"standard.apk"}); +#endif +#endif +#endif + return found_assets; +} + +static inline u64 ParseIsoTimestamp(const std::string& iso) { + if (iso.empty()) + return 0; + + std::string buf = iso; + if (buf.back() == 'Z') + buf.pop_back(); + + std::tm tm{}; + std::istringstream ss(buf); + ss >> std::get_time(&tm, "%Y-%m-%dT%H:%M:%S"); + if (ss.fail()) + return 0; + +#ifdef _WIN32 + return static_cast(_mkgmtime(&tm)); +#else + return static_cast(timegm(&tm)); +#endif +} + +std::optional Release::FromJson(const nlohmann::json& json, const std::string& host, + const std::string& repo) { + Release rel; + if (!json.is_object()) + return std::nullopt; + + rel.tag = json.value("tag_name", std::string{}); + if (rel.tag.empty()) + return std::nullopt; + + rel.title = json.value("name", rel.tag); + rel.id = json.value("id", std::hash{}(rel.title)); + + rel.published = ParseIsoTimestamp(json.value("published_at", std::string{})); + rel.prerelease = json.value("prerelease", false); + + auto body = json.value("body", rel.title); + boost::replace_all(body, "\\r", ""); + boost::replace_all(body, "\\n", "\n"); + rel.body = body; + + rel.host = host; + + const auto release_base = + fmt::format("{}/{}/releases", Common::g_build_auto_update_website, repo); + const auto fallback_html = fmt::format("{}/tag/{}", release_base, rel.tag); + rel.html_url = json.value("html_url", fallback_html); + + // This is our own "fake" API. + if (json.contains("base")) { + const auto base = json.value("base", fmt::format("https://{}", Common::g_build_auto_update_api)); + rel.base_download_url = fmt::format("{}/{}", base, rel.tag); + + // Assets are easy :) + rel.assets = json.value("assets", std::vector{}); + } else { + const auto base_download_url = fmt::format("/{}/releases/download/{}", repo, rel.tag); + + rel.base_download_url = base_download_url; + + // assets are a bit more complex here. :( + std::vector assets; + const nlohmann::json& arr = json["assets"]; + for (const auto &obj : arr) { + const auto url = obj.value("browser_download_url", std::string{}); + assets.emplace_back(url); + } + + rel.assets = assets; + } + + return rel; +} + +std::optional Release::FromJson(const std::string_view& json, const std::string& host, + const std::string& repo) { + try { + return FromJson(nlohmann::json::parse(json), host, repo); + } catch (std::exception& e) { + LOG_WARNING(Common, "Failed to parse JSON: {}", e.what()); + } + + return {}; +} + +std::vector Release::ListFromJson(const nlohmann::json& json, const std::string& host, + const std::string& repo) { + if (!json.is_array()) + return {}; + + std::vector releases; + for (const auto& obj : json) { + auto rel = Release::FromJson(obj, host, repo); + if (rel) + releases.emplace_back(rel.value()); + } + return releases; +} + +std::vector Release::ListFromJson(const std::string_view& json, const std::string& host, + const std::string& repo) { + try { + return ListFromJson(nlohmann::json::parse(json), host, repo); + } catch (std::exception& e) { + LOG_WARNING(Common, "Failed to parse JSON: {}", e.what()); + } + + return {}; +} + +std::optional MakeRequest(const std::string& url, const std::string& path) { + try { + constexpr std::size_t timeout_seconds = 15; + + std::unique_ptr client = std::make_unique(url); + client->set_connection_timeout(timeout_seconds); + client->set_read_timeout(timeout_seconds); + client->set_write_timeout(timeout_seconds); + +#ifdef YUZU_BUNDLED_OPENSSL + client->load_ca_cert_store(kCert, sizeof(kCert)); +#endif + + if (client == nullptr) { + LOG_ERROR(Common, "Invalid URL {}{}", url, path); + return {}; + } + + httplib::Request request{ + .method = "GET", + .path = path, + }; + + client->set_follow_location(true); + httplib::Result result = client->send(request); + + if (!result) { + LOG_ERROR(Common, "GET to {}{} returned null", url, path); + return {}; + } + + const auto& response = result.value(); + if (response.status >= 400) { + LOG_ERROR(Common, "GET to {}{} returned error status code: {}", url, path, + response.status); + return {}; + } + if (!response.headers.contains("content-type")) { + LOG_ERROR(Common, "GET to {}{} returned no content", url, path); + return {}; + } + + return response.body; + } catch (std::exception& e) { + LOG_ERROR(Common, "GET to {}{} failed during update check: {}", url, path, e.what()); + return std::nullopt; + } +} + +std::vector GetReleases() { + const auto body = GetReleasesBody(); + + if (!body) { + LOG_WARNING(Common, "Failed to get stable releases"); + return {}; + } + + const std::string_view body_str = body.value(); + const auto url = fmt::format("https://{}", Common::g_build_auto_update_stable_api); + return Release::ListFromJson(body_str, url, Common::g_build_auto_update_stable_repo); +} + +std::optional GetLatestRelease() { + const auto releases_path = Common::g_build_auto_update_api_path; + const auto url = fmt::format("https://{}", Common::g_build_auto_update_api); + + const auto body = MakeRequest(url, releases_path); + if (!body) { + LOG_WARNING(Common, "Failed to get latest release"); + return std::nullopt; + } + + const std::string_view body_str = body.value(); + return Release::FromJson(body_str, url, Common::g_build_auto_update_repo); +} + +std::optional GetReleasesBody() { + const auto releases_path = + fmt::format("/{}/{}/releases", Common::g_build_auto_update_stable_api_path, + Common::g_build_auto_update_stable_repo); + const auto url = fmt::format("https://{}", Common::g_build_auto_update_stable_api); + + return MakeRequest(url, releases_path); +} + +} // namespace Common::Net diff --git a/src/common/net/net.h b/src/common/net/net.h new file mode 100644 index 0000000000..b07caabe20 --- /dev/null +++ b/src/common/net/net.h @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include +#include "common/common_types.h" + +namespace Common::Net { + +typedef struct { + std::string name; + std::string url; + std::string path; + std::string filename; +} Asset; + +typedef struct Release { + std::string title; + std::string body; + std::string tag; + std::string base_download_url; + std::string html_url; + std::string host; + + std::vector assets; + + u64 id; + u64 published; + bool prerelease; + + // Get the relevant list of assets for the current platform. + std::vector GetPlatformAssets() const; + + static std::optional FromJson(const nlohmann::json& json, const std::string &host, const std::string& repo); + static std::optional FromJson(const std::string_view& json, const std::string &host, const std::string& repo); + static std::vector ListFromJson(const nlohmann::json &json, const std::string &host, const std::string &repo); + static std::vector ListFromJson(const std::string_view &json, const std::string &host, const std::string &repo); +} Release; + +// Make a request via httplib, and return the response body if applicable. +std::optional MakeRequest(const std::string &url, const std::string &path); + +// Get all of the latest stable releases. +std::vector GetReleases(); + +// Get all of the latest stable releases as text. +std::optional GetReleasesBody(); + +// Get the latest release of the current channel. +std::optional GetLatestRelease(); + +} diff --git a/src/common/scm_rev.cpp.in b/src/common/scm_rev.cpp.in index cc7b092270..21e58e24b5 100644 --- a/src/common/scm_rev.cpp.in +++ b/src/common/scm_rev.cpp.in @@ -22,6 +22,9 @@ #define BUILD_AUTO_UPDATE_API "@BUILD_AUTO_UPDATE_API@" #define BUILD_AUTO_UPDATE_API_PATH "@BUILD_AUTO_UPDATE_API_PATH@" #define BUILD_AUTO_UPDATE_REPO "@BUILD_AUTO_UPDATE_REPO@" +#define BUILD_AUTO_UPDATE_STABLE_API "@BUILD_AUTO_UPDATE_STABLE_API@" +#define BUILD_AUTO_UPDATE_STABLE_API_PATH "@BUILD_AUTO_UPDATE_STABLE_API_PATH@" +#define BUILD_AUTO_UPDATE_STABLE_REPO "@BUILD_AUTO_UPDATE_STABLE_REPO@" #define IS_NIGHTLY_BUILD @IS_NIGHTLY_BUILD@ namespace Common { @@ -45,5 +48,8 @@ constexpr const char g_build_auto_update_website[] = BUILD_AUTO_UPDATE_WEBSITE; constexpr const char g_build_auto_update_api[] = BUILD_AUTO_UPDATE_API; constexpr const char g_build_auto_update_api_path[] = BUILD_AUTO_UPDATE_API_PATH; constexpr const char g_build_auto_update_repo[] = BUILD_AUTO_UPDATE_REPO; +constexpr const char g_build_auto_update_stable_api[] = BUILD_AUTO_UPDATE_STABLE_API; +constexpr const char g_build_auto_update_stable_api_path[] = BUILD_AUTO_UPDATE_STABLE_API_PATH; +constexpr const char g_build_auto_update_stable_repo[] = BUILD_AUTO_UPDATE_STABLE_REPO; } // namespace Common diff --git a/src/common/scm_rev.h b/src/common/scm_rev.h index 07f941fb93..8f0a022964 100644 --- a/src/common/scm_rev.h +++ b/src/common/scm_rev.h @@ -28,5 +28,8 @@ extern const char g_build_auto_update_website[]; extern const char g_build_auto_update_api[]; extern const char g_build_auto_update_api_path[]; extern const char g_build_auto_update_repo[]; +extern const char g_build_auto_update_stable_api[]; +extern const char g_build_auto_update_stable_api_path[]; +extern const char g_build_auto_update_stable_repo[]; } // namespace Common diff --git a/src/core/hle/service/bcat/news/builtin_news.cpp b/src/core/hle/service/bcat/news/builtin_news.cpp index 66cf5bb697..2a7756a155 100644 --- a/src/core/hle/service/bcat/news/builtin_news.cpp +++ b/src/core/hle/service/bcat/news/builtin_news.cpp @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later +#include "common/net/net.h" +#include "common/scm_rev.h" #include "core/hle/service/bcat/news/builtin_news.h" #include "core/hle/service/bcat/news/msgpack.h" #include "core/hle/service/bcat/news/news_storage.h" @@ -22,10 +24,8 @@ #include #include #include -#include #include #include -#include #include #ifdef YUZU_BUNDLED_OPENSSL @@ -35,9 +35,6 @@ namespace Service::News { namespace { -// TODO(crueter): COMPILE DEFINITION -constexpr const char* GitHubAPI_EdenReleases = "/api/v1/repos/eden-emu/eden/releases"; - // Cached logo data std::vector default_logo_small; std::vector default_logo_large; @@ -66,24 +63,6 @@ u32 HashToNewsId(std::string_view key) { return static_cast(std::hash{}(key) & 0x7FFFFFFF); } -u64 ParseIsoTimestamp(const std::string& iso) { - if (iso.empty()) return 0; - - std::string buf = iso; - if (buf.back() == 'Z') buf.pop_back(); - - std::tm tm{}; - std::istringstream ss(buf); - ss >> std::get_time(&tm, "%Y-%m-%dT%H:%M:%S"); - if (ss.fail()) return 0; - -#ifdef _WIN32 - return static_cast(_mkgmtime(&tm)); -#else - return static_cast(timegm(&tm)); -#endif -} - std::vector TryLoadFromDisk(const std::filesystem::path& path) { if (!std::filesystem::exists(path)) return {}; @@ -100,8 +79,9 @@ std::vector TryLoadFromDisk(const std::filesystem::path& path) { return data; } +// TODO(crueter): Migrate to use Common::Net std::vector DownloadImage(const std::string& url_path, const std::filesystem::path& cache_path) { - LOG_INFO(Service_BCAT, "Downloading image: https://eden-emu.dev{}", url_path); + LOG_DEBUG(Service_BCAT, "Downloading image: https://eden-emu.dev{}", url_path); try { httplib::Client cli("https://eden-emu.dev"); cli.set_follow_location(true); @@ -226,67 +206,6 @@ void WriteCachedJson(std::string_view json) { (void)Common::FS::WriteStringToFile(path, Common::FS::FileType::TextFile, json); } -std::optional DownloadReleasesJson() { - try { -#ifdef YUZU_BUNDLED_OPENSSL - const auto url = "https://git.eden-emu.dev"; -#else - const auto url = "git.eden-emu.dev"; -#endif - - // TODO(crueter): This is duplicated between frontend and here. - constexpr auto path = GitHubAPI_EdenReleases; - - constexpr std::size_t timeout_seconds = 15; - - std::unique_ptr client = std::make_unique(url); - client->set_connection_timeout(timeout_seconds); - client->set_read_timeout(timeout_seconds); - client->set_write_timeout(timeout_seconds); - -#ifdef YUZU_BUNDLED_OPENSSL - client->load_ca_cert_store(kCert, sizeof(kCert)); -#endif - - if (client == nullptr) { - LOG_ERROR(Service_BCAT, "Invalid URL {}{}", url, path); - return {}; - } - - httplib::Request request{ - .method = "GET", - .path = path, - }; - - client->set_follow_location(true); - httplib::Result result = client->send(request); - - if (!result) { - LOG_ERROR(Service_BCAT, "GET to {}{} returned null", url, path); - return {}; - } else if (result->status < 400) { - return result->body; - } - - if (result->status >= 400) { - LOG_ERROR(Service_BCAT, - "GET to {}{} returned error status code: {}", - url, - path, - result->status); - return {}; - } - - if (!result->headers.contains("content-type")) { - LOG_ERROR(Service_BCAT, "GET to {}{} returned no content", url, path); - return {}; - } - } catch (...) { - LOG_WARNING(Service_BCAT, " failed to download releases"); - } - return std::nullopt; -} - // idk but News App does not render Markdown or HTML, so remove some formatting. std::string SanitizeMarkdown(std::string_view markdown) { std::string result; @@ -342,9 +261,7 @@ std::string SanitizeMarkdown(std::string_view markdown) { return text; } -std::string FormatBody(const nlohmann::json& release, std::string_view title) { - std::string body = release.value("body", std::string{}); - +std::string FormatBody(std::string body, const std::string_view &title) { if (body.empty()) { return std::string(title); } @@ -375,52 +292,32 @@ std::string FormatBody(const nlohmann::json& release, std::string_view title) { return body; } -void ImportReleases(std::string_view json_text) { - nlohmann::json root; - try { - root = nlohmann::json::parse(json_text); - } catch (...) { - LOG_WARNING(Service_BCAT, "failed to parse JSON"); - return; - } - - if (!root.is_array()) return; - +void ImportReleases(const std::vector &releases) { std::vector news_ids; - for (const auto& rel : root) { - if (!rel.is_object()) continue; - std::string title = rel.value("name", rel.value("tag_name", std::string{})); - if (title.empty()) continue; - - const u64 release_id = rel.value("id", 0); - const u32 news_id = release_id ? static_cast(release_id & 0x7FFFFFFF) : HashToNewsId(title); + for (const auto& rel : releases) { + const u32 news_id = u32(rel.id & 0x7FFFFFFF); news_ids.push_back(news_id); } PreloadNewsImages(news_ids); - for (const auto& rel : root) { - if (!rel.is_object()) continue; + for (const auto& rel : releases) { + const std::string title = rel.title; + const std::string body = rel.body; + const std::string html_url = rel.html_url; - std::string title = rel.value("name", rel.value("tag_name", std::string{})); - if (title.empty()) continue; - - const u64 release_id = rel.value("id", 0); - const u32 news_id = release_id ? static_cast(release_id & 0x7FFFFFFF) : HashToNewsId(title); - const u64 published = ParseIsoTimestamp(rel.value("published_at", std::string{})); + const u32 news_id = u32(rel.id & 0x7FFFFFFF); + const u64 published = rel.published; const u64 pickup_limit = published + 600000000; - const u32 priority = rel.value("prerelease", false) ? 1500 : 2500; + const u32 priority = rel.prerelease ? 1500 : 2500; - std::string author = "eden"; - if (rel.contains("author") && rel["author"].is_object()) { - author = rel["author"].value("login", "eden"); - } + std::string author = "Eden"; - auto payload = BuildMsgpack(title, FormatBody(rel, title), title, published, + auto payload = BuildMsgpack(title, FormatBody(body, title), title, published, pickup_limit, priority, {"en"}, author, {}, - rel.value("html_url", std::string{}), news_id); + html_url, news_id); - const std::string news_id_str = fmt::format("LA{:020}", news_id); + const std::string news_id_str = fmt::format("LA{:020}", rel.id); GithubNewsMeta meta{ .news_id = news_id_str, @@ -565,15 +462,21 @@ void EnsureBuiltinNewsLoaded() { LoadDefaultLogos(); if (const auto cached = ReadCachedJson()) { - ImportReleases(*cached); - LOG_DEBUG(Service_BCAT, "news: {} entries loaded from cache", NewsStorage::Instance().ListAll().size()); + const std::string_view body = cached.value(); + const auto releases = Common::Net::Release::ListFromJson(body, Common::g_build_auto_update_stable_api, Common::g_build_auto_update_stable_repo); + ImportReleases(releases); + + LOG_INFO(Service_BCAT, "news: {} entries loaded from cache", NewsStorage::Instance().ListAll().size()); } std::thread([] { - if (const auto fresh = DownloadReleasesJson()) { - WriteCachedJson(*fresh); - ImportReleases(*fresh); - LOG_DEBUG(Service_BCAT, "news: {} entries updated from Forgejo", NewsStorage::Instance().ListAll().size()); + if (const auto fresh = Common::Net::GetReleasesBody()) { + const std::string_view body = fresh.value(); + WriteCachedJson(body); + const auto releases = Common::Net::Release::ListFromJson(body, Common::g_build_auto_update_stable_api, Common::g_build_auto_update_stable_repo); + ImportReleases(releases); + + LOG_INFO(Service_BCAT, "news: {} entries updated from Forgejo", NewsStorage::Instance().ListAll().size()); } }).detach(); }); diff --git a/src/frontend_common/update_checker.cpp b/src/frontend_common/update_checker.cpp index 71a94f38d4..cccced5f60 100644 --- a/src/frontend_common/update_checker.cpp +++ b/src/frontend_common/update_checker.cpp @@ -5,146 +5,45 @@ // Licensed under GPLv2 or any later version // Refer to the license.txt file included. +#ifdef NIGHTLY_BUILD #include #include +#endif #include -#include "common/logging.h" +#include "common/net/net.h" #include "common/scm_rev.h" #include "update_checker.h" -#include "common/httplib.h" +#include "common/logging.h" -#ifdef YUZU_BUNDLED_OPENSSL -#include -#endif +std::optional UpdateChecker::GetUpdate() { + const auto latest = Common::Net::GetLatestRelease(); + if (!latest) return std::nullopt; -#include -#include -#include + LOG_INFO(Frontend, "Received update {}", latest->title); -std::optional UpdateChecker::GetResponse(std::string url, std::string path) -{ - try { - constexpr std::size_t timeout_seconds = 15; +#ifdef NIGHTLY_BUILD + std::vector result; - std::unique_ptr client = std::make_unique(url); - client->set_connection_timeout(timeout_seconds); - client->set_read_timeout(timeout_seconds); - client->set_write_timeout(timeout_seconds); - -#ifdef YUZU_BUNDLED_OPENSSL - client->load_ca_cert_store(kCert, sizeof(kCert)); -#endif - - if (client == nullptr) { - LOG_ERROR(Frontend, "Invalid URL {}{}", url, path); - return {}; - } - - httplib::Request request{ - .method = "GET", - .path = path, - }; - - client->set_follow_location(true); - httplib::Result result = client->send(request); - - if (!result) { - LOG_ERROR(Frontend, "GET to {}{} returned null", url, path); - return {}; - } - - const auto &response = result.value(); - if (response.status >= 400) { - LOG_ERROR(Frontend, - "GET to {}{} returned error status code: {}", - url, - path, - response.status); - return {}; - } - if (!response.headers.contains("content-type")) { - LOG_ERROR(Frontend, "GET to {}{} returned no content", url, path); - return {}; - } - - return response.body; - } catch (std::exception &e) { - LOG_ERROR(Frontend, - "GET to {}{} failed during update check: {}", - url, - path, - e.what()); + boost::split(result, latest->tag, boost::is_any_of(".")); + if (result.size() != 2) return std::nullopt; - } -} -std::optional UpdateChecker::GetLatestRelease() { -#ifdef YUZU_BUNDLED_OPENSSL - const auto update_check_url = fmt::format("https://{}", Common::g_build_auto_update_api); + const std::string tag = result[1]; + + boost::split(result, std::string{Common::g_build_version}, boost::is_any_of("-")); + if (result.empty()) + return std::nullopt; + + const std::string build = result[0]; #else - const auto update_check_url = std::string{Common::g_build_auto_update_api}; + const std::string tag = latest->tag; + const std::string build = Common::g_build_version; #endif - auto update_check_path = std::string{Common::g_build_auto_update_api_path}; - try { - const auto response = UpdateChecker::GetResponse(update_check_url, update_check_path); + if (tag != build) + return latest; - if (!response) - return {}; - - const std::string latest_tag = nlohmann::json::parse(response.value()).at("tag_name"); - const std::string latest_name = nlohmann::json::parse(response.value()).at("name"); - - return Update{latest_tag, latest_name}; - } catch (nlohmann::detail::out_of_range&) { - LOG_ERROR(Frontend, - "Parsing JSON response from {}{} failed during update check: " - "nlohmann::detail::out_of_range", - update_check_url, - update_check_path); - return {}; - } catch (nlohmann::detail::type_error&) { - LOG_ERROR(Frontend, - "Parsing JSON response from {}{} failed during update check: " - "nlohmann::detail::type_error", - update_check_url, - update_check_path); - return {}; - } -} - -std::optional UpdateChecker::GetUpdate() { - const std::optional latest_release_tag = - UpdateChecker::GetLatestRelease(); - - if (!latest_release_tag) - goto empty; - - { - std::string tag, build; - if (Common::g_is_nightly_build) { - std::vector result; - - boost::split(result, latest_release_tag->tag, boost::is_any_of(".")); - if (result.size() != 2) - goto empty; - tag = result[1]; - - boost::split(result, std::string{Common::g_build_version}, boost::is_any_of("-")); - if (result.empty()) - goto empty; - build = result[0]; - } else { - tag = latest_release_tag->tag; - build = Common::g_build_version; - } - - if (tag != build) - return latest_release_tag.value(); - } - -empty: return std::nullopt; } diff --git a/src/frontend_common/update_checker.h b/src/frontend_common/update_checker.h index 307c38f202..624d874fb8 100644 --- a/src/frontend_common/update_checker.h +++ b/src/frontend_common/update_checker.h @@ -8,16 +8,10 @@ #pragma once #include -#include +#include "common/net/net.h" namespace UpdateChecker { -typedef struct { - std::string tag; - std::string name; -} Update; +std::optional GetUpdate(); -std::optional GetResponse(std::string url, std::string path); -std::optional GetLatestRelease(); -std::optional GetUpdate(); } // namespace UpdateChecker diff --git a/src/yuzu/CMakeLists.txt b/src/yuzu/CMakeLists.txt index 1ed1fdff2a..3adce1dd8b 100644 --- a/src/yuzu/CMakeLists.txt +++ b/src/yuzu/CMakeLists.txt @@ -245,6 +245,8 @@ add_executable(yuzu render/performance_overlay.h render/performance_overlay.cpp render/performance_overlay.ui libqt_common.h libqt_common.cpp + updater/update_dialog.h updater/update_dialog.cpp updater/update_dialog.ui + ) set_target_properties(yuzu PROPERTIES OUTPUT_NAME "eden") diff --git a/src/yuzu/configuration/configure_per_game_addons.cpp b/src/yuzu/configuration/configure_per_game_addons.cpp index 7d0e15accc..bc21c70a7c 100644 --- a/src/yuzu/configuration/configure_per_game_addons.cpp +++ b/src/yuzu/configuration/configure_per_game_addons.cpp @@ -330,7 +330,6 @@ void ConfigurePerGameAddons::LoadConfiguration() { if (is_external_update) { first_item->setData(static_cast(patch.numeric_version), NUMERIC_VERSION); } else if (is_mod) { - // qDebug() << patch.location; first_item->setData(QString::fromStdString(patch.location), PATCH_LOCATION); } diff --git a/src/yuzu/main_window.cpp b/src/yuzu/main_window.cpp index 2e7cef0953..7549325567 100644 --- a/src/yuzu/main_window.cpp +++ b/src/yuzu/main_window.cpp @@ -8,6 +8,7 @@ #include "frontend_common/settings_generator.h" #include "qt_common/qt_string_lookup.h" #include "render/performance_overlay.h" +#include "updater/update_dialog.h" #if defined(QT_STATICPLUGIN) && !defined(__APPLE__) #undef VMA_IMPLEMENTATION #endif @@ -539,7 +540,7 @@ MainWindow::MainWindow(bool has_broken_vulkan) #ifdef ENABLE_UPDATE_CHECKER if (UISettings::values.check_for_updates) { update_future = QtConcurrent::run( - []() -> std::optional { return UpdateChecker::GetUpdate(); }); + []() -> std::optional { return UpdateChecker::GetUpdate(); }); update_watcher.connect(&update_watcher, &QFutureWatcher::finished, this, &MainWindow::OnEmulatorUpdateAvailable); update_watcher.setFuture(update_future); @@ -4218,23 +4219,12 @@ void MainWindow::OnCaptureScreenshot() { #ifdef ENABLE_UPDATE_CHECKER void MainWindow::OnEmulatorUpdateAvailable() { - std::optional version = update_future.result(); + std::optional version = update_future.result(); if (!version) return; - QMessageBox update_prompt(this); - update_prompt.setWindowTitle(tr("Update Available")); - update_prompt.setIcon(QMessageBox::Information); - update_prompt.addButton(QMessageBox::Yes); - update_prompt.addButton(QMessageBox::Ignore); - update_prompt.setText(tr("Download %1?").arg(QString::fromStdString(version->name))); - update_prompt.exec(); - if (update_prompt.button(QMessageBox::Yes) == update_prompt.clickedButton()) { - auto const full_url = - fmt::format("{}/{}/releases/tag/", std::string{Common::g_build_auto_update_website}, - std::string{Common::g_build_auto_update_repo}); - QDesktopServices::openUrl(QUrl(QString::fromStdString(full_url + version->tag))); - } + UpdateDialog dialog(version.value(), this); + dialog.exec(); } #endif diff --git a/src/yuzu/main_window.h b/src/yuzu/main_window.h index e85aadaa3d..b676c0534a 100644 --- a/src/yuzu/main_window.h +++ b/src/yuzu/main_window.h @@ -493,8 +493,8 @@ private: std::shared_ptr input_subsystem; #ifdef ENABLE_UPDATE_CHECKER - QFuture> update_future; - QFutureWatcher> update_watcher; + QFuture> update_future; + QFutureWatcher> update_watcher; #endif MultiplayerState* multiplayer_state = nullptr; diff --git a/src/yuzu/updater/update_dialog.cpp b/src/yuzu/updater/update_dialog.cpp new file mode 100644 index 0000000000..c2c0556e9c --- /dev/null +++ b/src/yuzu/updater/update_dialog.cpp @@ -0,0 +1,178 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include +#include +#include +#include "common/logging.h" +#include "qt_common/abstract/frontend.h" +#include "qt_common/abstract/progress.h" +#include "ui_update_dialog.h" +#include "update_dialog.h" + +#include "common/httplib.h" + +#ifdef YUZU_BUNDLED_OPENSSL +#include +#endif + +#include + +#undef GetSaveFileName + +UpdateDialog::UpdateDialog(const Common::Net::Release& release, QWidget* parent) + : QDialog(parent), ui(new Ui::UpdateDialog) { + ui->setupUi(this); + + ui->version->setText( + tr("%1 is available for download.").arg(QString::fromStdString(release.title))); + ui->url->setText( + tr("View on Forgejo").arg(QString::fromStdString(release.html_url))); + + std::string text{release.body}; + if (auto pos = text.find("# Packages"); pos != std::string::npos) { + text = text.substr(0, pos); + } + + ui->body->setMarkdown(QString::fromStdString(text)); + + // TODO(crueter): Find a way to set default + const auto assets = release.GetPlatformAssets(); + + if (assets.empty()) { + ui->groupBox->setHidden(true); + connect(this, &QDialog::accepted, this, [release]() { + QDesktopServices::openUrl(QUrl{QString::fromStdString(release.html_url)}); + }); + } else if (assets.size() == 1) { + m_asset = assets[0]; + + connect(this, &QDialog::accepted, this, &UpdateDialog::Download); + } else { + u32 i = 0; + for (const Common::Net::Asset& a : assets) { + QRadioButton* r = new QRadioButton(tr(a.name.c_str()), this); + connect(r, &QRadioButton::toggled, this, [a, this](bool checked) { + if (checked) + m_asset = a; + }); + + if (i == 0) + r->setChecked(true); + ++i; + + ui->radioButtons->addWidget(r); + } + + connect(this, &QDialog::accepted, this, &UpdateDialog::Download); + } +} + +UpdateDialog::~UpdateDialog() { + delete ui; +} + +void UpdateDialog::Download() { + const auto filename = QtCommon::Frontend::GetSaveFileName( + tr("New Version Location"), + qApp->applicationDirPath() % QStringLiteral("/") % QString::fromStdString(m_asset.filename), + tr("All Files (*.*)")); + + if (filename.isEmpty()) + return; + + QSaveFile file(filename); + if (!file.open(QIODevice::Truncate | QIODevice::WriteOnly)) { + LOG_WARNING(Frontend, "Could not open file {}", filename.toStdString()); + QtCommon::Frontend::Critical(tr("Failed to save file"), + tr("Could not open file %1 for writing.").arg(filename)); + return; + } + + // TODO(crueter): Move to net.cpp + constexpr std::size_t timeout_seconds = 15; + + std::unique_ptr client = std::make_unique(m_asset.url); + client->set_connection_timeout(timeout_seconds); + client->set_read_timeout(timeout_seconds); + client->set_write_timeout(timeout_seconds); + +#ifdef YUZU_BUNDLED_OPENSSL + client->load_ca_cert_store(kCert, sizeof(kCert)); +#endif + + if (client == nullptr) { + LOG_ERROR(Frontend, "Invalid URL {}{}", m_asset.url, m_asset.path); + return; + } + + auto progress = + QtCommon::Frontend::newProgressDialog(tr("Downloading..."), tr("Cancel"), 0, 100); + progress->show(); + + QGuiApplication::processEvents(); + + // Progress dialog. + auto progress_callback = [&](size_t processed_size, size_t total_size) { + QGuiApplication::processEvents(); + progress->setValue(static_cast((processed_size * 100) / total_size)); + return !progress->wasCanceled(); + }; + + // Write file in chunks. + auto content_receiver = [&file, filename](const char* t_data, size_t data_length) -> bool { + if (file.write(t_data, data_length) == -1) { + LOG_WARNING(Frontend, "Could not write {} bytes to file {}", data_length, + filename.toStdString()); + QtCommon::Frontend::Critical(tr("Failed to save file"), + tr("Could not write to file %1.").arg(filename)); + return false; + } + + return true; + }; + + // Now send off request + auto result = client->Get(m_asset.path, content_receiver, progress_callback); + progress->close(); + + // commit to file + if (!file.commit()) { + LOG_WARNING(Frontend, "Could not commit to file {}", filename.toStdString()); + QtCommon::Frontend::Critical(tr("Failed to save file"), + tr("Could not commit to file %1.").arg(filename)); + } + + if (!result) { + LOG_ERROR(Frontend, "GET to {}{} returned null", m_asset.url, m_asset.path); + return; + } + + const auto& response = result.value(); + if (response.status >= 400) { + LOG_ERROR(Frontend, "GET to {}{} returned error status code: {}", m_asset.url, m_asset.path, + response.status); + QtCommon::Frontend::Critical(tr("Failed to download file"), + tr("Could not download from %1%2\nError code: %3") + .arg(QString::fromStdString(m_asset.url), + QString::fromStdString(m_asset.path), + QString::number(response.status))); + return; + } + if (!response.headers.contains("content-type")) { + LOG_ERROR(Frontend, "GET to {}{} returned no content", m_asset.url, m_asset.path); + return; + } + + // Download is complete. User may choose to open in the file manager. + auto button = + QtCommon::Frontend::Question(tr("Download Complete"), + tr("Successfully downloaded %1. Would you like to open it?") + .arg(QString::fromStdString(m_asset.filename)), + QtCommon::Frontend::Yes | QtCommon::Frontend::No); + + if (button == QtCommon::Frontend::Yes) { + QDesktopServices::openUrl(QUrl::fromLocalFile(filename)); + } +} diff --git a/src/yuzu/updater/update_dialog.h b/src/yuzu/updater/update_dialog.h new file mode 100644 index 0000000000..692481b710 --- /dev/null +++ b/src/yuzu/updater/update_dialog.h @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include "common/net/net.h" + +class QRadioButton; +namespace Ui { +class UpdateDialog; +} + +class UpdateDialog : public QDialog { + Q_OBJECT + +public: + explicit UpdateDialog(const Common::Net::Release &release, QWidget* parent = nullptr); + ~UpdateDialog(); + +private slots: + void Download(); + +private: + Ui::UpdateDialog* ui; + QList m_buttons; + Common::Net::Asset m_asset; +}; diff --git a/src/yuzu/updater/update_dialog.ui b/src/yuzu/updater/update_dialog.ui new file mode 100644 index 0000000000..1d991c6f57 --- /dev/null +++ b/src/yuzu/updater/update_dialog.ui @@ -0,0 +1,112 @@ + + + UpdateDialog + + + + 0 + 0 + 655 + 551 + + + + Update Available + + + + + + <a href="%1">View on Forgejo</a> + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + true + + + + + + + Would you like to install this update? + + + + + + + Qt::Orientation::Horizontal + + + QDialogButtonBox::StandardButton::No|QDialogButtonBox::StandardButton::Yes + + + + + + + Available Versions + + + + + + + + %1 is available for download. + + + + + + + Qt::ScrollBarPolicy::ScrollBarAlwaysOff + + + true + + + true + + + + + + + + + buttonBox + accepted() + UpdateDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + UpdateDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + +