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 <crueter@eden-emu.dev>
This commit is contained in:
crueter 2026-04-09 00:09:03 -04:00
parent 3d0eb4b5d7
commit 7273540d43
No known key found for this signature in database
GPG key ID: 425ACD2D4830EBC6
17 changed files with 718 additions and 327 deletions

View file

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

View file

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

View file

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

269
src/common/net/net.cpp Normal file
View file

@ -0,0 +1,269 @@
#include <optional>
#include <boost/algorithm/string/classification.hpp>
#include <boost/algorithm/string/replace.hpp>
#include <boost/algorithm/string/split.hpp>
#include <fmt/format.h>
#include "common/scm_rev.h"
#include "net.h"
#include "common/logging.h"
#include "common/httplib.h"
#ifdef YUZU_BUNDLED_OPENSSL
#include <openssl/cert.h>
#endif
#define QT_TR_NOOP(x) x
namespace Common::Net {
std::vector<Asset> 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<std::string> suffixes;
std::vector<Asset> assets;
// TODO(crueter): Need better handling for this as a whole.
#ifdef NIGHTLY_BUILD
std::vector<std::string> 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<u64>(_mkgmtime(&tm));
#else
return static_cast<u64>(timegm(&tm));
#endif
}
std::optional<Release> 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<std::string>{}(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> 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> Release::ListFromJson(const nlohmann::json& json, const std::string& host,
const std::string& repo) {
if (!json.is_array())
return {};
std::vector<Release> 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> 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<std::string> MakeRequest(const std::string& url, const std::string& path) {
try {
constexpr std::size_t timeout_seconds = 15;
std::unique_ptr<httplib::Client> client = std::make_unique<httplib::Client>(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<Release> 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<Release> 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<std::string> 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

51
src/common/net/net.h Normal file
View file

@ -0,0 +1,51 @@
#pragma once
#include <optional>
#include <string>
#include <vector>
#include <nlohmann/json.hpp>
#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<Asset> GetAssets() const;
static std::optional<Release> FromJson(const nlohmann::json& json, const std::string &host, const std::string& repo);
static std::optional<Release> FromJson(const std::string_view& json, const std::string &host, const std::string& repo);
static std::vector<Release> ListFromJson(const nlohmann::json &json, const std::string &host, const std::string &repo);
static std::vector<Release> 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<std::string> MakeRequest(const std::string &url, const std::string &path);
// Get all of the latest stable releases.
std::vector<Release> GetReleases();
// Get all of the latest stable releases as text.
std::optional<std::string> GetReleasesBody();
// Get the latest release of the current channel.
std::optional<Release> GetLatestRelease();
}

View file

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

View file

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

View file

@ -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 <filesystem>
#include <fstream>
#include <future>
#include <iomanip>
#include <mutex>
#include <optional>
#include <sstream>
#include <thread>
#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<u8> default_logo_small;
std::vector<u8> default_logo_large;
@ -66,24 +63,6 @@ u32 HashToNewsId(std::string_view key) {
return static_cast<u32>(std::hash<std::string_view>{}(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<u64>(_mkgmtime(&tm));
#else
return static_cast<u64>(timegm(&tm));
#endif
}
std::vector<u8> TryLoadFromDisk(const std::filesystem::path& path) {
if (!std::filesystem::exists(path)) return {};
@ -100,8 +79,9 @@ std::vector<u8> TryLoadFromDisk(const std::filesystem::path& path) {
return data;
}
// TODO(crueter): Migrate to use Common::Net
std::vector<u8> 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<std::string> 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<httplib::Client> client = std::make_unique<httplib::Client>(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<Common::Net::Release> &releases) {
std::vector<u32> 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<u32>(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<u32>(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());
}
});
}

View file

@ -5,181 +5,45 @@
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
#ifdef NIGHTLY_BUILD
#include <boost/algorithm/string/classification.hpp>
#include <boost/algorithm/string/split.hpp>
#endif
#include <fmt/format.h>
#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 <openssl/cert.h>
#endif
std::optional<Common::Net::Release> UpdateChecker::GetUpdate() {
const auto latest = Common::Net::GetLatestRelease();
if (!latest) return std::nullopt;
#include <nlohmann/json.hpp>
#include <optional>
#include <string>
LOG_INFO(Frontend, "Received update {}", latest->title);
std::optional<std::string> UpdateChecker::GetResponse(std::string url, std::string path)
{
try {
constexpr std::size_t timeout_seconds = 15;
#ifdef NIGHTLY_BUILD
std::vector<std::string> result;
std::unique_ptr<httplib::Client> client = std::make_unique<httplib::Client>(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::Update> 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::Update> 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<UpdateChecker::Update> 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<std::string> 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;
}

View file

@ -8,16 +8,10 @@
#pragma once
#include <optional>
#include <string>
#include "common/net/net.h"
namespace UpdateChecker {
typedef struct {
std::string tag;
std::string name;
} Update;
std::optional<Common::Net::Release> GetUpdate();
std::optional<std::string> GetResponse(std::string url, std::string path);
std::optional<Update> GetLatestRelease(bool include_prereleases);
std::optional<Update> GetUpdate();
} // namespace UpdateChecker

View file

@ -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")

View file

@ -330,7 +330,6 @@ void ConfigurePerGameAddons::LoadConfiguration() {
if (is_external_update) {
first_item->setData(static_cast<quint32>(patch.numeric_version), NUMERIC_VERSION);
} else if (is_mod) {
// qDebug() << patch.location;
first_item->setData(QString::fromStdString(patch.location), PATCH_LOCATION);
}

View file

@ -3,11 +3,13 @@
// Qt on macOS doesn't define VMA shit
#include <boost/algorithm/string/split.hpp>
#include <qttranslation.h>
#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<UpdateChecker::Update> { return UpdateChecker::GetUpdate(); });
[]() -> std::optional<Common::Net::Release> { return UpdateChecker::GetUpdate(); });
update_watcher.connect(&update_watcher, &QFutureWatcher<QString>::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<UpdateChecker::Update> version = update_future.result();
std::optional<Common::Net::Release> 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

View file

@ -493,8 +493,8 @@ private:
std::shared_ptr<InputCommon::InputSubsystem> input_subsystem;
#ifdef ENABLE_UPDATE_CHECKER
QFuture<std::optional<UpdateChecker::Update>> update_future;
QFutureWatcher<std::optional<UpdateChecker::Update>> update_watcher;
QFuture<std::optional<Common::Net::Release>> update_future;
QFutureWatcher<std::optional<Common::Net::Release>> update_watcher;
#endif
MultiplayerState* multiplayer_state = nullptr;

171
src/yuzu/update_dialog.cpp Normal file
View file

@ -0,0 +1,171 @@
#include <QRadioButton>
#include <QStandardPaths>
#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 <QSaveFile>
#include "common/httplib.h"
#ifdef YUZU_BUNDLED_OPENSSL
#include <openssl/cert.h>
#include <QDesktopServices>
#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("<a href=\"%1\">View on Forgejo</a>").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<httplib::Client> client = std::make_unique<httplib::Client>(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<int>((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));
}
}

23
src/yuzu/update_dialog.h Normal file
View file

@ -0,0 +1,23 @@
#pragma once
#include <QDialog>
#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<QRadioButton *> m_buttons;
};

106
src/yuzu/update_dialog.ui Normal file
View file

@ -0,0 +1,106 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>UpdateDialog</class>
<widget class="QDialog" name="UpdateDialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>655</width>
<height>551</height>
</rect>
</property>
<property name="windowTitle">
<string>Update Available</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="1">
<widget class="QLabel" name="url">
<property name="text">
<string>&lt;a href=&quot;%1&quot;&gt;View on Forgejo&lt;/a&gt;</string>
</property>
<property name="openExternalLinks">
<bool>true</bool>
</property>
</widget>
</item>
<item row="3" column="0" colspan="2">
<widget class="QLabel" name="label_2">
<property name="text">
<string>Would you like to install this update?</string>
</property>
</widget>
</item>
<item row="4" column="0" colspan="2">
<widget class="QDialogButtonBox" name="buttonBox">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::StandardButton::No|QDialogButtonBox::StandardButton::Yes</set>
</property>
</widget>
</item>
<item row="2" column="0" colspan="2">
<widget class="QGroupBox" name="groupBox">
<property name="title">
<string>Available Versions</string>
</property>
<layout class="QVBoxLayout" name="radioButtons"/>
</widget>
</item>
<item row="1" column="0" colspan="2">
<widget class="QTextEdit" name="body">
<property name="horizontalScrollBarPolicy">
<enum>Qt::ScrollBarPolicy::ScrollBarAlwaysOff</enum>
</property>
<property name="readOnly">
<bool>true</bool>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="version">
<property name="text">
<string>%1 is available for download.</string>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections>
<connection>
<sender>buttonBox</sender>
<signal>accepted()</signal>
<receiver>UpdateDialog</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>248</x>
<y>254</y>
</hint>
<hint type="destinationlabel">
<x>157</x>
<y>274</y>
</hint>
</hints>
</connection>
<connection>
<sender>buttonBox</sender>
<signal>rejected()</signal>
<receiver>UpdateDialog</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>316</x>
<y>260</y>
</hint>
<hint type="destinationlabel">
<x>286</x>
<y>274</y>
</hint>
</hints>
</connection>
</connections>
</ui>