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 + + + + +