From 7273540d43aacb7acf153d5de45cf6eaa6680d4e Mon Sep 17 00:00:00 2001 From: crueter Date: Thu, 9 Apr 2026 00:09:03 -0400 Subject: [PATCH] WIP: [frontend] Built-in auto updater 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. Untested but should auto-mount the DMG on macOS and open up the zip in Windows? Needs work on Android, but I don't feel like doing it Signed-off-by: crueter --- CMakeLists.txt | 2 +- CMakeModules/GenerateSCMRev.cmake | 17 +- src/common/CMakeLists.txt | 3 +- src/common/net/net.cpp | 269 ++++++++++++++++++ src/common/net/net.h | 51 ++++ src/common/scm_rev.cpp.in | 6 + src/common/scm_rev.h | 3 + .../hle/service/bcat/news/builtin_news.cpp | 163 ++--------- src/frontend_common/update_checker.cpp | 182 ++---------- src/frontend_common/update_checker.h | 10 +- src/yuzu/CMakeLists.txt | 2 + .../configure_per_game_addons.cpp | 1 - src/yuzu/main_window.cpp | 32 ++- src/yuzu/main_window.h | 4 +- src/yuzu/update_dialog.cpp | 171 +++++++++++ src/yuzu/update_dialog.h | 23 ++ src/yuzu/update_dialog.ui | 106 +++++++ 17 files changed, 718 insertions(+), 327 deletions(-) create mode 100644 src/common/net/net.cpp create mode 100644 src/common/net/net.h create mode 100644 src/yuzu/update_dialog.cpp create mode 100644 src/yuzu/update_dialog.h create mode 100644 src/yuzu/update_dialog.ui diff --git a/CMakeLists.txt b/CMakeLists.txt index 42717c496d..28de5dec47 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -486,7 +486,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 947a4963ee..878faaef25 100644 --- a/CMakeModules/GenerateSCMRev.cmake +++ b/CMakeModules/GenerateSCMRev.cmake @@ -33,20 +33,21 @@ 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 API endpoint +# TODO(crueter): deduplicate, if possible +set(BUILD_AUTO_UPDATE_WEBSITE "https://git.eden-emu.dev") +set(BUILD_AUTO_UPDATE_API "git.eden-emu.dev") +set(BUILD_AUTO_UPDATE_API_PATH "/api/v1/repos/") +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/") + if (NIGHTLY_BUILD) - set(BUILD_AUTO_UPDATE_WEBSITE "https://git.eden-emu.dev") - set(BUILD_AUTO_UPDATE_API "git.eden-emu.dev") - set(BUILD_AUTO_UPDATE_API_PATH "/api/v1/repos/") set(BUILD_AUTO_UPDATE_REPO "eden-ci/nightly") set(REPO_NAME "Eden Nightly") else() - set(BUILD_AUTO_UPDATE_WEBSITE "https://git.eden-emu.dev") - set(BUILD_AUTO_UPDATE_API "git.eden-emu.dev") - set(BUILD_AUTO_UPDATE_API_PATH "/api/v1/repos/") - set(BUILD_AUTO_UPDATE_REPO "eden-emu/eden") + set(BUILD_AUTO_UPDATE_REPO "${BUILD_AUTO_UPDATE_STABLE_REPO}") set(REPO_NAME "Eden") endif() diff --git a/src/common/CMakeLists.txt b/src/common/CMakeLists.txt index 2846058df9..c7c8bce356 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 diff --git a/src/common/net/net.cpp b/src/common/net/net.cpp new file mode 100644 index 0000000000..1a8e8f0337 --- /dev/null +++ b/src/common/net/net.cpp @@ -0,0 +1,269 @@ +#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::GetAssets() const { +#ifdef _WIN32 + static constexpr const std::string prefix = "Eden-Windows"; +#elif defined(__linux__) + static constexpr const std::string prefix = "Eden-Linux"; +#elif defined(__APPLE__) + static constexpr const std::string prefix = "Eden-macOS"; +#elif defined(__ANDROID__) + static constexpr const std::string prefix = "Eden-Android"; +#else + LOG_DEBUG(Common, "Unsupported platform for auto-update"); +#endif + + std::vector suffixes; + std::vector assets; + + // 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 + + const auto make_asset = [this, ref](const std::string& name, + const std::string& suffix) -> Asset { + const auto filename = fmt::format("{}-{}{}", prefix, ref, suffix); + return Asset{.name = name, + .url = host, + .path = fmt::format("{}/{}", base_download_url, filename), + .filename = filename}; + }; + + // TODO(crueter): Handle setup when that becomes a thing + // TODO(crueter): Descriptions? Android? + return { +#ifdef _WIN32 +#ifdef ARCHITECTURE_x86_64 + make_asset(QT_TR_NOOP("Standard"), "-amd64-msvc-standard.zip"), + make_asset(QT_TR_NOOP("MinGW"), "-mingw-amd64-gcc-standard.zip"), + make_asset(QT_TR_NOOP("PGO"), "-mingw-amd64-clang-pgo.zip") +#elif defined(ARCHITECTURE_arm64) + make_asset(QT_TR_NOOP("Standard"), "-mingw-arm64-clang-standard.zip"), + make_asset(QT_TR_NOOP("PGO"), "-mingw-arm64-clang-pgo.zip") +#endif +#elif defined(__linux__) +// TODO(crueter): Linux doesn't need this...? +#ifdef ARCHITECTURE_x86_64 + make_asset("Standard", "-amd64-gcc-standard.AppImage"), + make_asset("PGO", "-amd64-clang-pgo.AppImage"), +#endif +#elif defined(__APPLE__) +#ifdef ARCHITECTURE_arm64 + make_asset(QT_TR_NOOP("Standard"), ".dmg"), +#endif +#elif defined(__ANDROID__) +#ifdef ARCHITECTURE_x86_64 + make_asset("ChromeOS", "-chromeos.apk"), +#elif defined(ARCHITECTURE_arm64) +#ifdef YUZU_LEGACY + make_asset("Standard", "-legacy.apk"), +#else + make_asset("Standard", "-standard.apk"), + make_asset("Genshin Spoof", "-optimized.apk"), +#endif +#endif +#endif + }; +} + +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); + + rel.body = json.value("body", rel.title); + 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); + + const auto base_download_url = fmt::format("/{}/releases/download/{}", repo, rel.tag); + + rel.base_download_url = base_download_url; + + 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 = + fmt::format("{}/{}/releases/latest", Common::g_build_auto_update_api_path, + Common::g_build_auto_update_repo); + 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..ff8a37b292 --- /dev/null +++ b/src/common/net/net.h @@ -0,0 +1,51 @@ +#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; + + u64 id; + u64 published; + bool prerelease; + + // Get the relevant list of assets for the current platform. + std::vector GetAssets() 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..bd7ff0d221 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,17 +462,19 @@ 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); - std::thread([] { - if (const auto fresh = DownloadReleasesJson()) { - WriteCachedJson(*fresh); - ImportReleases(*fresh); - LOG_DEBUG(Service_BCAT, "news: {} entries updated from Forgejo", NewsStorage::Instance().ListAll().size()); - } - }).detach(); + LOG_INFO(Service_BCAT, "news: {} entries loaded from cache", NewsStorage::Instance().ListAll().size()); + } else 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()); + } }); } diff --git a/src/frontend_common/update_checker.cpp b/src/frontend_common/update_checker.cpp index 1f01c25a26..cccced5f60 100644 --- a/src/frontend_common/update_checker.cpp +++ b/src/frontend_common/update_checker.cpp @@ -5,181 +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(bool include_prereleases) { -#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 = fmt::format("{}{}", std::string{Common::g_build_auto_update_api_path}, - std::string{Common::g_build_auto_update_repo}); - try { - if (include_prereleases) { // This can return either a prerelease or a stable release, - // whichever is more recent. - const auto update_check_tags_path = update_check_path + "/tags"; - const auto update_check_releases_path = update_check_path + "/releases"; + if (tag != build) + return latest; - const auto tags_response = UpdateChecker::GetResponse(update_check_url, update_check_tags_path); - const auto releases_response = UpdateChecker::GetResponse(update_check_url, update_check_releases_path); - - if (!tags_response || !releases_response) - return {}; - - const std::string latest_tag - = nlohmann::json::parse(tags_response.value()).at(0).at("name"); - const std::string latest_name = - nlohmann::json::parse(releases_response.value()).at(0).at("name"); - - const bool latest_tag_has_release = releases_response.value().find( - fmt::format("\"{}\"", latest_tag)) - != std::string::npos; - - // If there is a newer tag, but that tag has no associated release, don't prompt the - // user to update. - if (!latest_tag_has_release) - return {}; - - return Update{latest_tag, latest_name}; - } else { // This is a stable release, only check for other stable releases. - update_check_path += "/releases/latest"; - const auto response = UpdateChecker::GetResponse(update_check_url, update_check_path); - - 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 bool is_prerelease = ((strstr(Common::g_build_version, "pre-alpha") != NULL) || - (strstr(Common::g_build_version, "alpha") != NULL) || - (strstr(Common::g_build_version, "beta") != NULL) || - (strstr(Common::g_build_version, "rc") != NULL)); - const std::optional latest_release_tag = - UpdateChecker::GetLatestRelease(is_prerelease); - - 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 fb6c25c3f3..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(bool include_prereleases); -std::optional GetUpdate(); } // namespace UpdateChecker diff --git a/src/yuzu/CMakeLists.txt b/src/yuzu/CMakeLists.txt index 1ed1fdff2a..38d9bcf412 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 + update_dialog.h update_dialog.cpp 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..05251f0308 100644 --- a/src/yuzu/main_window.cpp +++ b/src/yuzu/main_window.cpp @@ -3,11 +3,13 @@ // Qt on macOS doesn't define VMA shit #include +#include #include "common/settings.h" #include "common/settings_enums.h" #include "frontend_common/settings_generator.h" #include "qt_common/qt_string_lookup.h" #include "render/performance_overlay.h" +#include "update_dialog.h" #if defined(QT_STATICPLUGIN) && !defined(__APPLE__) #undef VMA_IMPLEMENTATION #endif @@ -539,7 +541,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 +4220,23 @@ 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(); + + // 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->title))); + // update_prompt.exec(); + // if (update_prompt.button(QMessageBox::Yes) == update_prompt.clickedButton()) { + // QDesktopServices::openUrl(QUrl(QString::fromStdString(version->html_url))); + // } } #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/update_dialog.cpp b/src/yuzu/update_dialog.cpp new file mode 100644 index 0000000000..e6ac3f064e --- /dev/null +++ b/src/yuzu/update_dialog.cpp @@ -0,0 +1,171 @@ +#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 + +#include "common/httplib.h" + +#ifdef YUZU_BUNDLED_OPENSSL +#include + +#include +#endif + +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 + u32 i = 0; + for (const Common::Net::Asset& a : release.GetAssets()) { + QRadioButton* r = new QRadioButton(tr(a.name.c_str()), this); + if (i == 0) r->setChecked(true); + ++i; + + r->setProperty("url", QString::fromStdString(a.url)); + r->setProperty("path", QString::fromStdString(a.path)); + r->setProperty("filename", QString::fromStdString(a.filename)); + + ui->radioButtons->addWidget(r); + m_buttons.append(r); + } + + connect(this, &QDialog::accepted, this, &UpdateDialog::Download); +} + +UpdateDialog::~UpdateDialog() { + delete ui; +} + +void UpdateDialog::Download() { + std::string url, path, asset_filename; + for (QRadioButton* r : std::as_const(m_buttons)) { + if (r->isChecked()) { + url = r->property("url").toString().toStdString(); + path = r->property("path").toString().toStdString(); + asset_filename = r->property("filename").toString().toStdString(); + break; + } + } + + if (url.empty()) + return; + + const auto filename = QtCommon::Frontend::GetSaveFileName( + tr("New Version Location"), + qApp->applicationDirPath() % QStringLiteral("/") % QString::fromStdString(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(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; + } + + 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(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", 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); + QtCommon::Frontend::Critical( + tr("Failed to download file"), + tr("Could not download from %1%2\nError code: %3") + .arg(QString::fromStdString(url), QString::fromStdString(path), QString::number(response.status))); + return; + } + if (!response.headers.contains("content-type")) { + LOG_ERROR(Frontend, "GET to {}{} returned no content", url, path); + return; + } + + // Download is complete. User may choose to open in the file manager. + // TODO(crueter): Auto-extract for zip, auto-open for DMG + // e.g. download to tmp directory? + + auto button = + QtCommon::Frontend::Question(tr("Download Complete"), + tr("Successfully downloaded %1. Would you like to open it?") + .arg(QString::fromStdString(asset_filename)), + QtCommon::Frontend::Yes | QtCommon::Frontend::No); + + if (button == QtCommon::Frontend::Yes) { + QDesktopServices::openUrl(QUrl::fromLocalFile(filename)); + } +} diff --git a/src/yuzu/update_dialog.h b/src/yuzu/update_dialog.h new file mode 100644 index 0000000000..6cb5eaff3d --- /dev/null +++ b/src/yuzu/update_dialog.h @@ -0,0 +1,23 @@ +#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; +}; diff --git a/src/yuzu/update_dialog.ui b/src/yuzu/update_dialog.ui new file mode 100644 index 0000000000..85fc415b51 --- /dev/null +++ b/src/yuzu/update_dialog.ui @@ -0,0 +1,106 @@ + + + UpdateDialog + + + + 0 + 0 + 655 + 551 + + + + Update Available + + + + + + <a href="%1">View on Forgejo</a> + + + true + + + + + + + Would you like to install this update? + + + + + + + Qt::Orientation::Horizontal + + + QDialogButtonBox::StandardButton::No|QDialogButtonBox::StandardButton::Yes + + + + + + + Available Versions + + + + + + + + Qt::ScrollBarPolicy::ScrollBarAlwaysOff + + + true + + + + + + + %1 is available for download. + + + + + + + + + buttonBox + accepted() + UpdateDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + UpdateDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + +