diff options
Diffstat (limited to 'src')
37 files changed, 1553 insertions, 35 deletions
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index a88551fbc..f69d00a2b 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -13,3 +13,6 @@ endif() if (ENABLE_QT) add_subdirectory(yuzu) endif() +if (ENABLE_WEB_SERVICE) + add_subdirectory(web_service) +endif() diff --git a/src/common/CMakeLists.txt b/src/common/CMakeLists.txt index 6a3f1fe08..8985e4367 100644 --- a/src/common/CMakeLists.txt +++ b/src/common/CMakeLists.txt @@ -41,6 +41,8 @@ configure_file("${CMAKE_CURRENT_SOURCE_DIR}/scm_rev.cpp.in" "${CMAKE_CURRENT_SOU add_library(common STATIC alignment.h assert.h + detached_tasks.cpp + detached_tasks.h bit_field.h bit_set.h cityhash.cpp @@ -87,6 +89,7 @@ add_library(common STATIC timer.cpp timer.h vector_math.h + web_result.h ) if(ARCHITECTURE_x86_64) diff --git a/src/common/detached_tasks.cpp b/src/common/detached_tasks.cpp new file mode 100644 index 000000000..a347d9e02 --- /dev/null +++ b/src/common/detached_tasks.cpp @@ -0,0 +1,41 @@ +// Copyright 2018 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#include <thread> +#include "common/assert.h" +#include "common/detached_tasks.h" + +namespace Common { + +DetachedTasks* DetachedTasks::instance = nullptr; + +DetachedTasks::DetachedTasks() { + ASSERT(instance == nullptr); + instance = this; +} + +void DetachedTasks::WaitForAllTasks() { + std::unique_lock<std::mutex> lock(mutex); + cv.wait(lock, [this]() { return count == 0; }); +} + +DetachedTasks::~DetachedTasks() { + std::unique_lock<std::mutex> lock(mutex); + ASSERT(count == 0); + instance = nullptr; +} + +void DetachedTasks::AddTask(std::function<void()> task) { + std::unique_lock<std::mutex> lock(instance->mutex); + ++instance->count; + std::thread([task{std::move(task)}]() { + task(); + std::unique_lock<std::mutex> lock(instance->mutex); + --instance->count; + std::notify_all_at_thread_exit(instance->cv, std::move(lock)); + }) + .detach(); +} + +} // namespace Common diff --git a/src/common/detached_tasks.h b/src/common/detached_tasks.h new file mode 100644 index 000000000..5dd8fc27b --- /dev/null +++ b/src/common/detached_tasks.h @@ -0,0 +1,40 @@ +// Copyright 2018 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#pragma once + +#include <condition_variable> +#include <functional> + +namespace Common { + +/** + * A background manager which ensures that all detached task is finished before program exits. + * + * Some tasks, telemetry submission for example, prefer executing asynchronously and don't care + * about the result. These tasks are suitable for std::thread::detach(). However, this is unsafe if + * the task is launched just before the program exits (which is a common case for telemetry), so we + * need to block on these tasks on program exit. + * + * To make detached task safe, a single DetachedTasks object should be placed in the main(), and + * call WaitForAllTasks() after all program execution but before global/static variable destruction. + * Any potentially unsafe detached task should be executed via DetachedTasks::AddTask. + */ +class DetachedTasks { +public: + DetachedTasks(); + ~DetachedTasks(); + void WaitForAllTasks(); + + static void AddTask(std::function<void()> task); + +private: + static DetachedTasks* instance; + + std::condition_variable cv; + std::mutex mutex; + int count = 0; +}; + +} // namespace Common diff --git a/src/common/web_result.h b/src/common/web_result.h new file mode 100644 index 000000000..969926674 --- /dev/null +++ b/src/common/web_result.h @@ -0,0 +1,24 @@ +// Copyright 2018 yuzu Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#pragma once + +#include <string> + +namespace Common { +struct WebResult { + enum class Code : u32 { + Success, + InvalidURL, + CredentialsMissing, + LibError, + HttpError, + WrongContent, + NoWebservice, + }; + Code result_code; + std::string result_string; + std::string returned_data; +}; +} // namespace Common diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index a98dbb9ab..e4a676e91 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -396,6 +396,10 @@ create_target_directory_groups(core) target_link_libraries(core PUBLIC common PRIVATE audio_core video_core) target_link_libraries(core PUBLIC Boost::boost PRIVATE fmt lz4_static mbedtls opus unicorn open_source_archives) +if (ENABLE_WEB_SERVICE) + add_definitions(-DENABLE_WEB_SERVICE) + target_link_libraries(core PUBLIC json-headers web_service) +endif() if (ARCHITECTURE_x86_64) target_sources(core PRIVATE diff --git a/src/core/settings.h b/src/core/settings.h index 0318d019c..1808f5937 100644 --- a/src/core/settings.h +++ b/src/core/settings.h @@ -155,6 +155,12 @@ struct Values { // Debugging bool use_gdbstub; u16 gdbstub_port; + + // WebService + bool enable_telemetry; + std::string web_api_url; + std::string yuzu_username; + std::string yuzu_token; } extern values; void Apply(); diff --git a/src/core/telemetry_session.cpp b/src/core/telemetry_session.cpp index b0df154ca..f29fff1e7 100644 --- a/src/core/telemetry_session.cpp +++ b/src/core/telemetry_session.cpp @@ -6,6 +6,8 @@ #include "common/common_types.h" #include "common/file_util.h" +#include <mbedtls/ctr_drbg.h> +#include <mbedtls/entropy.h> #include "core/core.h" #include "core/file_sys/control_metadata.h" #include "core/file_sys/patch_manager.h" @@ -13,10 +15,31 @@ #include "core/settings.h" #include "core/telemetry_session.h" +#ifdef ENABLE_WEB_SERVICE +#include "web_service/telemetry_json.h" +#include "web_service/verify_login.h" +#endif + namespace Core { static u64 GenerateTelemetryId() { u64 telemetry_id{}; + + mbedtls_entropy_context entropy; + mbedtls_entropy_init(&entropy); + mbedtls_ctr_drbg_context ctr_drbg; + std::string personalization = "yuzu Telemetry ID"; + + mbedtls_ctr_drbg_init(&ctr_drbg); + ASSERT(mbedtls_ctr_drbg_seed(&ctr_drbg, mbedtls_entropy_func, &entropy, + reinterpret_cast<const unsigned char*>(personalization.c_str()), + personalization.size()) == 0); + ASSERT(mbedtls_ctr_drbg_random(&ctr_drbg, reinterpret_cast<unsigned char*>(&telemetry_id), + sizeof(u64)) == 0); + + mbedtls_ctr_drbg_free(&ctr_drbg); + mbedtls_entropy_free(&entropy); + return telemetry_id; } @@ -25,14 +48,21 @@ u64 GetTelemetryId() { const std::string filename{FileUtil::GetUserPath(FileUtil::UserPath::ConfigDir) + "telemetry_id"}; - if (FileUtil::Exists(filename)) { + bool generate_new_id = !FileUtil::Exists(filename); + if (!generate_new_id) { FileUtil::IOFile file(filename, "rb"); if (!file.IsOpen()) { LOG_ERROR(Core, "failed to open telemetry_id: {}", filename); return {}; } file.ReadBytes(&telemetry_id, sizeof(u64)); - } else { + if (telemetry_id == 0) { + LOG_ERROR(Frontend, "telemetry_id is 0. Generating a new one.", telemetry_id); + generate_new_id = true; + } + } + + if (generate_new_id) { FileUtil::IOFile file(filename, "wb"); if (!file.IsOpen()) { LOG_ERROR(Core, "failed to open telemetry_id: {}", filename); @@ -59,23 +89,20 @@ u64 RegenerateTelemetryId() { return new_telemetry_id; } -std::future<bool> VerifyLogin(std::string username, std::string token, std::function<void()> func) { +bool VerifyLogin(const std::string& username, const std::string& token) { #ifdef ENABLE_WEB_SERVICE - return WebService::VerifyLogin(username, token, Settings::values.verify_endpoint_url, func); + return WebService::VerifyLogin(Settings::values.web_api_url, username, token); #else - return std::async(std::launch::async, [func{std::move(func)}]() { - func(); - return false; - }); + return false; #endif } TelemetrySession::TelemetrySession() { #ifdef ENABLE_WEB_SERVICE if (Settings::values.enable_telemetry) { - backend = std::make_unique<WebService::TelemetryJson>( - Settings::values.telemetry_endpoint_url, Settings::values.yuzu_username, - Settings::values.yuzu_token); + backend = std::make_unique<WebService::TelemetryJson>(Settings::values.web_api_url, + Settings::values.yuzu_username, + Settings::values.yuzu_token); } else { backend = std::make_unique<Telemetry::NullVisitor>(); } @@ -94,7 +121,8 @@ TelemetrySession::TelemetrySession() { u64 program_id{}; const Loader::ResultStatus res{System::GetInstance().GetAppLoader().ReadProgramId(program_id)}; if (res == Loader::ResultStatus::Success) { - AddField(Telemetry::FieldType::Session, "ProgramId", program_id); + const std::string formatted_program_id{fmt::format("{:016X}", program_id)}; + AddField(Telemetry::FieldType::Session, "ProgramId", formatted_program_id); std::string name; System::GetInstance().GetAppLoader().ReadTitle(name); diff --git a/src/core/telemetry_session.h b/src/core/telemetry_session.h index dbc4f8bd4..cec271df0 100644 --- a/src/core/telemetry_session.h +++ b/src/core/telemetry_session.h @@ -4,7 +4,6 @@ #pragma once -#include <future> #include <memory> #include "common/telemetry.h" @@ -31,6 +30,8 @@ public: field_collection.AddField(type, name, std::move(value)); } + static void FinalizeAsyncJob(); + private: Telemetry::FieldCollection field_collection; ///< Tracks all added fields for the session std::unique_ptr<Telemetry::VisitorInterface> backend; ///< Backend interface that logs fields @@ -55,6 +56,6 @@ u64 RegenerateTelemetryId(); * @param func A function that gets exectued when the verification is finished * @returns Future with bool indicating whether the verification succeeded */ -std::future<bool> VerifyLogin(std::string username, std::string token, std::function<void()> func); +bool VerifyLogin(const std::string& username, const std::string& token); } // namespace Core diff --git a/src/video_core/renderer_opengl/gl_rasterizer.cpp b/src/video_core/renderer_opengl/gl_rasterizer.cpp index 60dcdc184..a91bc6dee 100644 --- a/src/video_core/renderer_opengl/gl_rasterizer.cpp +++ b/src/video_core/renderer_opengl/gl_rasterizer.cpp @@ -252,6 +252,7 @@ DrawParameters RasterizerOpenGL::SetupDraw() { params.count = regs.vertex_buffer.count; params.vertex_first = regs.vertex_buffer.first; } + return params; } void RasterizerOpenGL::SetupShaders() { diff --git a/src/web_service/CMakeLists.txt b/src/web_service/CMakeLists.txt new file mode 100644 index 000000000..1c83e9c34 --- /dev/null +++ b/src/web_service/CMakeLists.txt @@ -0,0 +1,16 @@ +add_library(web_service STATIC + telemetry_json.cpp + telemetry_json.h + verify_login.cpp + verify_login.h + web_backend.cpp + web_backend.h +) + +create_target_directory_groups(web_service) + +get_directory_property(OPENSSL_LIBS + DIRECTORY ${CMAKE_SOURCE_DIR}/externals/libressl + DEFINITION OPENSSL_LIBS) +target_compile_definitions(web_service PUBLIC -DCPPHTTPLIB_OPENSSL_SUPPORT) +target_link_libraries(web_service PRIVATE common json-headers ${OPENSSL_LIBS} httplib lurlparser) diff --git a/src/web_service/telemetry_json.cpp b/src/web_service/telemetry_json.cpp new file mode 100644 index 000000000..033ea1ea4 --- /dev/null +++ b/src/web_service/telemetry_json.cpp @@ -0,0 +1,99 @@ +// Copyright 2017 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#include <thread> +#include "common/assert.h" +#include "common/detached_tasks.h" +#include "web_service/telemetry_json.h" +#include "web_service/web_backend.h" + +namespace WebService { + +TelemetryJson::TelemetryJson(const std::string& host, const std::string& username, + const std::string& token) + : host(std::move(host)), username(std::move(username)), token(std::move(token)) {} +TelemetryJson::~TelemetryJson() = default; + +template <class T> +void TelemetryJson::Serialize(Telemetry::FieldType type, const std::string& name, T value) { + sections[static_cast<u8>(type)][name] = value; +} + +void TelemetryJson::SerializeSection(Telemetry::FieldType type, const std::string& name) { + TopSection()[name] = sections[static_cast<unsigned>(type)]; +} + +void TelemetryJson::Visit(const Telemetry::Field<bool>& field) { + Serialize(field.GetType(), field.GetName(), field.GetValue()); +} + +void TelemetryJson::Visit(const Telemetry::Field<double>& field) { + Serialize(field.GetType(), field.GetName(), field.GetValue()); +} + +void TelemetryJson::Visit(const Telemetry::Field<float>& field) { + Serialize(field.GetType(), field.GetName(), field.GetValue()); +} + +void TelemetryJson::Visit(const Telemetry::Field<u8>& field) { + Serialize(field.GetType(), field.GetName(), field.GetValue()); +} + +void TelemetryJson::Visit(const Telemetry::Field<u16>& field) { + Serialize(field.GetType(), field.GetName(), field.GetValue()); +} + +void TelemetryJson::Visit(const Telemetry::Field<u32>& field) { + Serialize(field.GetType(), field.GetName(), field.GetValue()); +} + +void TelemetryJson::Visit(const Telemetry::Field<u64>& field) { + Serialize(field.GetType(), field.GetName(), field.GetValue()); +} + +void TelemetryJson::Visit(const Telemetry::Field<s8>& field) { + Serialize(field.GetType(), field.GetName(), field.GetValue()); +} + +void TelemetryJson::Visit(const Telemetry::Field<s16>& field) { + Serialize(field.GetType(), field.GetName(), field.GetValue()); +} + +void TelemetryJson::Visit(const Telemetry::Field<s32>& field) { + Serialize(field.GetType(), field.GetName(), field.GetValue()); +} + +void TelemetryJson::Visit(const Telemetry::Field<s64>& field) { + Serialize(field.GetType(), field.GetName(), field.GetValue()); +} + +void TelemetryJson::Visit(const Telemetry::Field<std::string>& field) { + Serialize(field.GetType(), field.GetName(), field.GetValue()); +} + +void TelemetryJson::Visit(const Telemetry::Field<const char*>& field) { + Serialize(field.GetType(), field.GetName(), std::string(field.GetValue())); +} + +void TelemetryJson::Visit(const Telemetry::Field<std::chrono::microseconds>& field) { + Serialize(field.GetType(), field.GetName(), field.GetValue().count()); +} + +void TelemetryJson::Complete() { + SerializeSection(Telemetry::FieldType::App, "App"); + SerializeSection(Telemetry::FieldType::Session, "Session"); + SerializeSection(Telemetry::FieldType::Performance, "Performance"); + SerializeSection(Telemetry::FieldType::UserFeedback, "UserFeedback"); + SerializeSection(Telemetry::FieldType::UserConfig, "UserConfig"); + SerializeSection(Telemetry::FieldType::UserSystem, "UserSystem"); + + auto content = TopSection().dump(); + // Send the telemetry async but don't handle the errors since they were written to the log + Common::DetachedTasks::AddTask( + [host{this->host}, username{this->username}, token{this->token}, content]() { + Client{host, username, token}.PostJson("/telemetry", content, true); + }); +} + +} // namespace WebService diff --git a/src/web_service/telemetry_json.h b/src/web_service/telemetry_json.h new file mode 100644 index 000000000..0fe6f9a3e --- /dev/null +++ b/src/web_service/telemetry_json.h @@ -0,0 +1,58 @@ +// Copyright 2017 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#pragma once + +#include <array> +#include <string> +#include <json.hpp> +#include "common/telemetry.h" +#include "common/web_result.h" + +namespace WebService { + +/** + * Implementation of VisitorInterface that serialized telemetry into JSON, and submits it to the + * yuzu web service + */ +class TelemetryJson : public Telemetry::VisitorInterface { +public: + TelemetryJson(const std::string& host, const std::string& username, const std::string& token); + ~TelemetryJson(); + + void Visit(const Telemetry::Field<bool>& field) override; + void Visit(const Telemetry::Field<double>& field) override; + void Visit(const Telemetry::Field<float>& field) override; + void Visit(const Telemetry::Field<u8>& field) override; + void Visit(const Telemetry::Field<u16>& field) override; + void Visit(const Telemetry::Field<u32>& field) override; + void Visit(const Telemetry::Field<u64>& field) override; + void Visit(const Telemetry::Field<s8>& field) override; + void Visit(const Telemetry::Field<s16>& field) override; + void Visit(const Telemetry::Field<s32>& field) override; + void Visit(const Telemetry::Field<s64>& field) override; + void Visit(const Telemetry::Field<std::string>& field) override; + void Visit(const Telemetry::Field<const char*>& field) override; + void Visit(const Telemetry::Field<std::chrono::microseconds>& field) override; + + void Complete() override; + +private: + nlohmann::json& TopSection() { + return sections[static_cast<u8>(Telemetry::FieldType::None)]; + } + + template <class T> + void Serialize(Telemetry::FieldType type, const std::string& name, T value); + + void SerializeSection(Telemetry::FieldType type, const std::string& name); + + nlohmann::json output; + std::array<nlohmann::json, 7> sections; + std::string host; + std::string username; + std::string token; +}; + +} // namespace WebService diff --git a/src/web_service/verify_login.cpp b/src/web_service/verify_login.cpp new file mode 100644 index 000000000..124aa3863 --- /dev/null +++ b/src/web_service/verify_login.cpp @@ -0,0 +1,27 @@ +// Copyright 2017 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#include <json.hpp> +#include "web_service/verify_login.h" +#include "web_service/web_backend.h" + +namespace WebService { + +bool VerifyLogin(const std::string& host, const std::string& username, const std::string& token) { + Client client(host, username, token); + auto reply = client.GetJson("/profile", false).returned_data; + if (reply.empty()) { + return false; + } + nlohmann::json json = nlohmann::json::parse(reply); + const auto iter = json.find("username"); + + if (iter == json.end()) { + return username.empty(); + } + + return username == *iter; +} + +} // namespace WebService diff --git a/src/web_service/verify_login.h b/src/web_service/verify_login.h new file mode 100644 index 000000000..39db32dbb --- /dev/null +++ b/src/web_service/verify_login.h @@ -0,0 +1,22 @@ +// Copyright 2017 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#pragma once + +#include <functional> +#include <future> +#include <string> + +namespace WebService { + +/** + * Checks if username and token is valid + * @param host the web API URL + * @param username yuzu username to use for authentication. + * @param token yuzu token to use for authentication. + * @returns a bool indicating whether the verification succeeded + */ +bool VerifyLogin(const std::string& host, const std::string& username, const std::string& token); + +} // namespace WebService diff --git a/src/web_service/web_backend.cpp b/src/web_service/web_backend.cpp new file mode 100644 index 000000000..787b0fbcb --- /dev/null +++ b/src/web_service/web_backend.cpp @@ -0,0 +1,149 @@ +// Copyright 2017 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#include <cstdlib> +#include <string> +#include <thread> +#include <LUrlParser.h> +#include "common/logging/log.h" +#include "common/web_result.h" +#include "core/settings.h" +#include "web_service/web_backend.h" + +namespace WebService { + +constexpr std::array<const char, 1> API_VERSION{'1'}; + +constexpr u32 HTTP_PORT = 80; +constexpr u32 HTTPS_PORT = 443; + +constexpr u32 TIMEOUT_SECONDS = 30; + +Client::JWTCache Client::jwt_cache{}; + +Client::Client(const std::string& host, const std::string& username, const std::string& token) + : host(host), username(username), token(token) { + std::lock_guard<std::mutex> lock(jwt_cache.mutex); + if (username == jwt_cache.username && token == jwt_cache.token) { + jwt = jwt_cache.jwt; + } +} + +Common::WebResult Client::GenericJson(const std::string& method, const std::string& path, + const std::string& data, const std::string& jwt, + const std::string& username, const std::string& token) { + if (cli == nullptr) { + auto parsedUrl = LUrlParser::clParseURL::ParseURL(host); + int port; + if (parsedUrl.m_Scheme == "http") { + if (!parsedUrl.GetPort(&port)) { + port = HTTP_PORT; + } + cli = + std::make_unique<httplib::Client>(parsedUrl.m_Host.c_str(), port, TIMEOUT_SECONDS); + } else if (parsedUrl.m_Scheme == "https") { + if (!parsedUrl.GetPort(&port)) { + port = HTTPS_PORT; + } + cli = std::make_unique<httplib::SSLClient>(parsedUrl.m_Host.c_str(), port, + TIMEOUT_SECONDS); + } else { + LOG_ERROR(WebService, "Bad URL scheme {}", parsedUrl.m_Scheme); + return Common::WebResult{Common::WebResult::Code::InvalidURL, "Bad URL scheme"}; + } + } + if (cli == nullptr) { + LOG_ERROR(WebService, "Invalid URL {}", host + path); + return Common::WebResult{Common::WebResult::Code::InvalidURL, "Invalid URL"}; + } + + httplib::Headers params; + if (!jwt.empty()) { + params = { + {std::string("Authorization"), fmt::format("Bearer {}", jwt)}, + }; + } else if (!username.empty()) { + params = { + {std::string("x-username"), username}, + {std::string("x-token"), token}, + }; + } + + params.emplace(std::string("api-version"), std::string(API_VERSION.begin(), API_VERSION.end())); + if (method != "GET") { + params.emplace(std::string("Content-Type"), std::string("application/json")); + }; + + httplib::Request request; + request.method = method; + request.path = path; + request.headers = params; + request.body = data; + + httplib::Response response; + + if (!cli->send(request, response)) { + LOG_ERROR(WebService, "{} to {} returned null", method, host + path); + return Common::WebResult{Common::WebResult::Code::LibError, "Null response"}; + } + + if (response.status >= 400) { + LOG_ERROR(WebService, "{} to {} returned error status code: {}", method, host + path, + response.status); + return Common::WebResult{Common::WebResult::Code::HttpError, + std::to_string(response.status)}; + } + + auto content_type = response.headers.find("content-type"); + + if (content_type == response.headers.end()) { + LOG_ERROR(WebService, "{} to {} returned no content", method, host + path); + return Common::WebResult{Common::WebResult::Code::WrongContent, ""}; + } + + if (content_type->second.find("application/json") == std::string::npos && + content_type->second.find("text/html; charset=utf-8") == std::string::npos) { + LOG_ERROR(WebService, "{} to {} returned wrong content: {}", method, host + path, + content_type->second); + return Common::WebResult{Common::WebResult::Code::WrongContent, "Wrong content"}; + } + return Common::WebResult{Common::WebResult::Code::Success, "", response.body}; +} + +void Client::UpdateJWT() { + if (!username.empty() && !token.empty()) { + auto result = GenericJson("POST", "/jwt/internal", "", "", username, token); + if (result.result_code != Common::WebResult::Code::Success) { + LOG_ERROR(WebService, "UpdateJWT failed"); + } else { + std::lock_guard<std::mutex> lock(jwt_cache.mutex); + jwt_cache.username = username; + jwt_cache.token = token; + jwt_cache.jwt = jwt = result.returned_data; + } + } +} + +Common::WebResult Client::GenericJson(const std::string& method, const std::string& path, + const std::string& data, bool allow_anonymous) { + if (jwt.empty()) { + UpdateJWT(); + } + + if (jwt.empty() && !allow_anonymous) { + LOG_ERROR(WebService, "Credentials must be provided for authenticated requests"); + return Common::WebResult{Common::WebResult::Code::CredentialsMissing, "Credentials needed"}; + } + + auto result = GenericJson(method, path, data, jwt); + if (result.result_string == "401") { + // Try again with new JWT + UpdateJWT(); + result = GenericJson(method, path, data, jwt); + } + + return result; +} + +} // namespace WebService diff --git a/src/web_service/web_backend.h b/src/web_service/web_backend.h new file mode 100644 index 000000000..d75fbcc15 --- /dev/null +++ b/src/web_service/web_backend.h @@ -0,0 +1,92 @@ +// Copyright 2017 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#pragma once + +#include <functional> +#include <mutex> +#include <string> +#include <tuple> +#include <httplib.h> +#include "common/common_types.h" +#include "common/web_result.h" + +namespace httplib { +class Client; +} + +namespace WebService { + +class Client { +public: + Client(const std::string& host, const std::string& username, const std::string& token); + + /** + * Posts JSON to the specified path. + * @param path the URL segment after the host address. + * @param data String of JSON data to use for the body of the POST request. + * @param allow_anonymous If true, allow anonymous unauthenticated requests. + * @return the result of the request. + */ + Common::WebResult PostJson(const std::string& path, const std::string& data, + bool allow_anonymous) { + return GenericJson("POST", path, data, allow_anonymous); + } + + /** + * Gets JSON from the specified path. + * @param path the URL segment after the host address. + * @param allow_anonymous If true, allow anonymous unauthenticated requests. + * @return the result of the request. + */ + Common::WebResult GetJson(const std::string& path, bool allow_anonymous) { + return GenericJson("GET", path, "", allow_anonymous); + } + + /** + * Deletes JSON to the specified path. + * @param path the URL segment after the host address. + * @param data String of JSON data to use for the body of the DELETE request. + * @param allow_anonymous If true, allow anonymous unauthenticated requests. + * @return the result of the request. + */ + Common::WebResult DeleteJson(const std::string& path, const std::string& data, + bool allow_anonymous) { + return GenericJson("DELETE", path, data, allow_anonymous); + } + +private: + /// A generic function handles POST, GET and DELETE request together + Common::WebResult GenericJson(const std::string& method, const std::string& path, + const std::string& data, bool allow_anonymous); + + /** + * A generic function with explicit authentication method specified + * JWT is used if the jwt parameter is not empty + * username + token is used if jwt is empty but username and token are not empty + * anonymous if all of jwt, username and token are empty + */ + Common::WebResult GenericJson(const std::string& method, const std::string& path, + const std::string& data, const std::string& jwt = "", + const std::string& username = "", const std::string& token = ""); + + // Retrieve a new JWT from given username and token + void UpdateJWT(); + + std::string host; + std::string username; + std::string token; + std::string jwt; + std::unique_ptr<httplib::Client> cli; + + struct JWTCache { + std::mutex mutex; + std::string username; + std::string token; + std::string jwt; + }; + static JWTCache jwt_cache; +}; + +} // namespace WebService diff --git a/src/yuzu/CMakeLists.txt b/src/yuzu/CMakeLists.txt index f48b69809..04464ad5e 100644 --- a/src/yuzu/CMakeLists.txt +++ b/src/yuzu/CMakeLists.txt @@ -29,6 +29,8 @@ add_executable(yuzu configuration/configure_input.h configuration/configure_system.cpp configuration/configure_system.h + configuration/configure_web.cpp + configuration/configure_web.h debugger/graphics/graphics_breakpoint_observer.cpp debugger/graphics/graphics_breakpoint_observer.h debugger/graphics/graphics_breakpoints.cpp @@ -42,6 +44,7 @@ add_executable(yuzu debugger/profiler.h debugger/wait_tree.cpp debugger/wait_tree.h + discord.h game_list.cpp game_list.h game_list_p.h @@ -57,6 +60,8 @@ add_executable(yuzu util/spinbox.h util/util.cpp util/util.h + compatdb.cpp + compatdb.h yuzu.rc ) @@ -70,8 +75,10 @@ set(UIS configuration/configure_graphics.ui configuration/configure_input.ui configuration/configure_system.ui + configuration/configure_web.ui hotkeys.ui main.ui + compatdb.ui ) file(GLOB COMPAT_LIST @@ -113,6 +120,19 @@ target_link_libraries(yuzu PRIVATE common core input_common video_core) target_link_libraries(yuzu PRIVATE Boost::boost glad Qt5::OpenGL Qt5::Widgets) target_link_libraries(yuzu PRIVATE ${PLATFORM_LIBRARIES} Threads::Threads) +if (YUZU_ENABLE_COMPATIBILITY_REPORTING) + add_definitions(-DYUZU_ENABLE_COMPATIBILITY_REPORTING) +endif() + +if (USE_DISCORD_PRESENCE) + target_sources(yuzu PUBLIC + discord_impl.cpp + discord_impl.h + ) + target_link_libraries(yuzu PRIVATE discord-rpc) + target_compile_definitions(yuzu PRIVATE -DUSE_DISCORD_PRESENCE) +endif() + if(UNIX AND NOT APPLE) install(TARGETS yuzu RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}/bin") endif() diff --git a/src/yuzu/compatdb.cpp b/src/yuzu/compatdb.cpp new file mode 100644 index 000000000..91e754274 --- /dev/null +++ b/src/yuzu/compatdb.cpp @@ -0,0 +1,65 @@ +// Copyright 2017 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#include <QButtonGroup> +#include <QMessageBox> +#include <QPushButton> +#include "common/logging/log.h" +#include "common/telemetry.h" +#include "core/core.h" +#include "core/telemetry_session.h" +#include "ui_compatdb.h" +#include "yuzu/compatdb.h" + +CompatDB::CompatDB(QWidget* parent) + : QWizard(parent, Qt::WindowTitleHint | Qt::WindowCloseButtonHint | Qt::WindowSystemMenuHint), + ui{std::make_unique<Ui::CompatDB>()} { + ui->setupUi(this); + connect(ui->radioButton_Perfect, &QRadioButton::clicked, this, &CompatDB::EnableNext); + connect(ui->radioButton_Great, &QRadioButton::clicked, this, &CompatDB::EnableNext); + connect(ui->radioButton_Okay, &QRadioButton::clicked, this, &CompatDB::EnableNext); + connect(ui->radioButton_Bad, &QRadioButton::clicked, this, &CompatDB::EnableNext); + connect(ui->radioButton_IntroMenu, &QRadioButton::clicked, this, &CompatDB::EnableNext); + connect(ui->radioButton_WontBoot, &QRadioButton::clicked, this, &CompatDB::EnableNext); + connect(button(NextButton), &QPushButton::clicked, this, &CompatDB::Submit); +} + +CompatDB::~CompatDB() = default; + +enum class CompatDBPage { + Intro = 0, + Selection = 1, + Final = 2, +}; + +void CompatDB::Submit() { + QButtonGroup* compatibility = new QButtonGroup(this); + compatibility->addButton(ui->radioButton_Perfect, 0); + compatibility->addButton(ui->radioButton_Great, 1); + compatibility->addButton(ui->radioButton_Okay, 2); + compatibility->addButton(ui->radioButton_Bad, 3); + compatibility->addButton(ui->radioButton_IntroMenu, 4); + compatibility->addButton(ui->radioButton_WontBoot, 5); + switch ((static_cast<CompatDBPage>(currentId()))) { + case CompatDBPage::Selection: + if (compatibility->checkedId() == -1) { + button(NextButton)->setEnabled(false); + } + break; + case CompatDBPage::Final: + LOG_DEBUG(Frontend, "Compatibility Rating: {}", compatibility->checkedId()); + Core::Telemetry().AddField(Telemetry::FieldType::UserFeedback, "Compatibility", + compatibility->checkedId()); + // older versions of QT don't support the "NoCancelButtonOnLastPage" option, this is a + // workaround + button(QWizard::CancelButton)->setVisible(false); + break; + default: + LOG_ERROR(Frontend, "Unexpected page: {}", currentId()); + } +} + +void CompatDB::EnableNext() { + button(NextButton)->setEnabled(true); +} diff --git a/src/yuzu/compatdb.h b/src/yuzu/compatdb.h new file mode 100644 index 000000000..ca0dd11d6 --- /dev/null +++ b/src/yuzu/compatdb.h @@ -0,0 +1,26 @@ +// Copyright 2017 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#pragma once + +#include <memory> +#include <QWizard> + +namespace Ui { +class CompatDB; +} + +class CompatDB : public QWizard { + Q_OBJECT + +public: + explicit CompatDB(QWidget* parent = nullptr); + ~CompatDB(); + +private: + std::unique_ptr<Ui::CompatDB> ui; + + void Submit(); + void EnableNext(); +}; diff --git a/src/yuzu/compatdb.ui b/src/yuzu/compatdb.ui new file mode 100644 index 000000000..fed402176 --- /dev/null +++ b/src/yuzu/compatdb.ui @@ -0,0 +1,215 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>CompatDB</class> + <widget class="QWizard" name="CompatDB"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>600</width> + <height>482</height> + </rect> + </property> + <property name="minimumSize"> + <size> + <width>500</width> + <height>410</height> + </size> + </property> + <property name="windowTitle"> + <string>Report Compatibility</string> + </property> + <property name="options"> + <set>QWizard::DisabledBackButtonOnLastPage|QWizard::HelpButtonOnRight|QWizard::NoBackButtonOnStartPage</set> + </property> + <widget class="QWizardPage" name="wizard_Info"> + <property name="title"> + <string>Report Game Compatibility</string> + </property> + <attribute name="pageId"> + <string notr="true">0</string> + </attribute> + <layout class="QVBoxLayout" name="verticalLayout"> + <item> + <widget class="QLabel" name="lbl_Spiel"> + <property name="text"> + <string><html><head/><body><p><span style=" font-size:10pt;">Should you choose to submit a test case to the </span><a href="https://yuzu-emu.org/game/"><span style=" font-size:10pt; text-decoration: underline; color:#0000ff;">yuzu Compatibility List</span></a><span style=" font-size:10pt;">, The following information will be collected and displayed on the site:</span></p><ul style="margin-top: 0px; margin-bottom: 0px; margin-left: 0px; margin-right: 0px; -qt-list-indent: 1;"><li style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Hardware Information (CPU / GPU / Operating System)</li><li style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Which version of yuzu you are running</li><li style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">The connected yuzu account</li></ul></body></html></string> + </property> + <property name="wordWrap"> + <bool>true</bool> + </property> + <property name="openExternalLinks"> + <bool>true</bool> + </property> + </widget> + </item> + <item> + <spacer name="verticalSpacer_2"> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>20</width> + <height>0</height> + </size> + </property> + </spacer> + </item> + </layout> + </widget> + <widget class="QWizardPage" name="wizard_Report"> + <property name="title"> + <string>Report Game Compatibility</string> + </property> + <attribute name="pageId"> + <string notr="true">1</string> + </attribute> + <layout class="QFormLayout" name="formLayout"> + <item row="2" column="0"> + <widget class="QRadioButton" name="radioButton_Perfect"> + <property name="text"> + <string>Perfect</string> + </property> + </widget> + </item> + <item row="2" column="1"> + <widget class="QLabel" name="lbl_Perfect"> + <property name="text"> + <string><html><head/><body><p>Game functions flawlessly with no audio or graphical glitches.</p></body></html></string> + </property> + <property name="wordWrap"> + <bool>true</bool> + </property> + </widget> + </item> + <item row="4" column="0"> + <widget class="QRadioButton" name="radioButton_Great"> + <property name="text"> + <string>Great </string> + </property> + </widget> + </item> + <item row="4" column="1"> + <widget class="QLabel" name="lbl_Great"> + <property name="text"> + <string><html><head/><body><p>Game functions with minor graphical or audio glitches and is playable from start to finish. May require some workarounds.</p></body></html></string> + </property> + <property name="wordWrap"> + <bool>true</bool> + </property> + </widget> + </item> + <item row="5" column="0"> + <widget class="QRadioButton" name="radioButton_Okay"> + <property name="text"> + <string>Okay</string> + </property> + </widget> + </item> + <item row="5" column="1"> + <widget class="QLabel" name="lbl_Okay"> + <property name="text"> + <string><html><head/><body><p>Game functions with major graphical or audio glitches, but game is playable from start to finish with workarounds.</p></body></html></string> + </property> + <property name="wordWrap"> + <bool>true</bool> + </property> + </widget> + </item> + <item row="6" column="0"> + <widget class="QRadioButton" name="radioButton_Bad"> + <property name="text"> + <string>Bad</string> + </property> + </widget> + </item> + <item row="6" column="1"> + <widget class="QLabel" name="lbl_Bad"> + <property name="text"> + <string><html><head/><body><p>Game functions, but with major graphical or audio glitches. Unable to progress in specific areas due to glitches even with workarounds.</p></body></html></string> + </property> + <property name="wordWrap"> + <bool>true</bool> + </property> + </widget> + </item> + <item row="7" column="0"> + <widget class="QRadioButton" name="radioButton_IntroMenu"> + <property name="text"> + <string>Intro/Menu</string> + </property> + </widget> + </item> + <item row="7" column="1"> + <widget class="QLabel" name="lbl_IntroMenu"> + <property name="text"> + <string><html><head/><body><p>Game is completely unplayable due to major graphical or audio glitches. Unable to progress past the Start Screen.</p></body></html></string> + </property> + <property name="wordWrap"> + <bool>true</bool> + </property> + </widget> + </item> + <item row="8" column="0"> + <widget class="QRadioButton" name="radioButton_WontBoot"> + <property name="text"> + <string>Won't Boot</string> + </property> + <property name="checkable"> + <bool>true</bool> + </property> + <property name="checked"> + <bool>false</bool> + </property> + </widget> + </item> + <item row="8" column="1"> + <widget class="QLabel" name="lbl_WontBoot"> + <property name="text"> + <string><html><head/><body><p>The game crashes when attempting to startup.</p></body></html></string> + </property> + </widget> + </item> + <item row="0" column="0" colspan="2"> + <widget class="QLabel" name="lbl_Independent"> + <property name="font"> + <font> + <pointsize>10</pointsize> + </font> + </property> + <property name="text"> + <string><html><head/><body><p>Independent of speed or performance, how well does this game play from start to finish on this version of yuzu?</p></body></html></string> + </property> + <property name="wordWrap"> + <bool>true</bool> + </property> + </widget> + </item> + <item row="1" column="0" colspan="2"> + <spacer name="verticalSpacer"> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>20</width> + <height>0</height> + </size> + </property> + </spacer> + </item> + </layout> + </widget> + <widget class="QWizardPage" name="wizard_ThankYou"> + <property name="title"> + <string>Thank you for your submission!</string> + </property> + <attribute name="pageId"> + <string notr="true">2</string> + </attribute> + </widget> + </widget> + <resources/> + <connections/> +</ui> diff --git a/src/yuzu/configuration/config.cpp b/src/yuzu/configuration/config.cpp index d229225b4..650dd03c0 100644 --- a/src/yuzu/configuration/config.cpp +++ b/src/yuzu/configuration/config.cpp @@ -136,8 +136,18 @@ void Config::ReadValues() { Settings::values.gdbstub_port = qt_config->value("gdbstub_port", 24689).toInt(); qt_config->endGroup(); + qt_config->beginGroup("WebService"); + Settings::values.enable_telemetry = qt_config->value("enable_telemetry", true).toBool(); + Settings::values.web_api_url = + qt_config->value("web_api_url", "https://api.yuzu-emu.org").toString().toStdString(); + Settings::values.yuzu_username = qt_config->value("yuzu_username").toString().toStdString(); + Settings::values.yuzu_token = qt_config->value("yuzu_token").toString().toStdString(); + qt_config->endGroup(); + qt_config->beginGroup("UI"); UISettings::values.theme = qt_config->value("theme", UISettings::themes[0].second).toString(); + UISettings::values.enable_discord_presence = + qt_config->value("enable_discord_presence", true).toBool(); qt_config->beginGroup("UIGameList"); UISettings::values.show_unknown = qt_config->value("show_unknown", true).toBool(); @@ -261,8 +271,16 @@ void Config::SaveValues() { qt_config->setValue("gdbstub_port", Settings::values.gdbstub_port); qt_config->endGroup(); + qt_config->beginGroup("WebService"); + qt_config->setValue("enable_telemetry", Settings::values.enable_telemetry); + qt_config->setValue("web_api_url", QString::fromStdString(Settings::values.web_api_url)); + qt_config->setValue("yuzu_username", QString::fromStdString(Settings::values.yuzu_username)); + qt_config->setValue("yuzu_token", QString::fromStdString(Settings::values.yuzu_token)); + qt_config->endGroup(); + qt_config->beginGroup("UI"); qt_config->setValue("theme", UISettings::values.theme); + qt_config->setValue("enable_discord_presence", UISettings::values.enable_discord_presence); qt_config->beginGroup("UIGameList"); qt_config->setValue("show_unknown", UISettings::values.show_unknown); diff --git a/src/yuzu/configuration/configure.ui b/src/yuzu/configuration/configure.ui index 20f120134..9b297df28 100644 --- a/src/yuzu/configuration/configure.ui +++ b/src/yuzu/configuration/configure.ui @@ -54,6 +54,11 @@ <string>Debug</string> </attribute> </widget> + <widget class="ConfigureWeb" name="webTab"> + <attribute name="title"> + <string>Web</string> + </attribute> + </widget> </widget> </item> <item> @@ -108,6 +113,12 @@ <header>configuration/configure_graphics.h</header> <container>1</container> </customwidget> + <customwidget> + <class>ConfigureWeb</class> + <extends>QWidget</extends> + <header>configuration/configure_web.h</header> + <container>1</container> + </customwidget> </customwidgets> <resources/> <connections> diff --git a/src/yuzu/configuration/configure_dialog.cpp b/src/yuzu/configuration/configure_dialog.cpp index daa4cc0d9..3905423e9 100644 --- a/src/yuzu/configuration/configure_dialog.cpp +++ b/src/yuzu/configuration/configure_dialog.cpp @@ -27,5 +27,6 @@ void ConfigureDialog::applyConfiguration() { ui->graphicsTab->applyConfiguration(); ui->audioTab->applyConfiguration(); ui->debugTab->applyConfiguration(); + ui->webTab->applyConfiguration(); Settings::Apply(); } diff --git a/src/yuzu/configuration/configure_web.cpp b/src/yuzu/configuration/configure_web.cpp new file mode 100644 index 000000000..3c2ccb76f --- /dev/null +++ b/src/yuzu/configuration/configure_web.cpp @@ -0,0 +1,119 @@ +// Copyright 2017 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#include <QIcon> +#include <QMessageBox> +#include <QtConcurrent/QtConcurrentRun> +#include "core/settings.h" +#include "core/telemetry_session.h" +#include "ui_configure_web.h" +#include "yuzu/configuration/configure_web.h" +#include "yuzu/ui_settings.h" + +ConfigureWeb::ConfigureWeb(QWidget* parent) + : QWidget(parent), ui(std::make_unique<Ui::ConfigureWeb>()) { + ui->setupUi(this); + connect(ui->button_regenerate_telemetry_id, &QPushButton::clicked, this, + &ConfigureWeb::RefreshTelemetryID); + connect(ui->button_verify_login, &QPushButton::clicked, this, &ConfigureWeb::VerifyLogin); + connect(&verify_watcher, &QFutureWatcher<bool>::finished, this, &ConfigureWeb::OnLoginVerified); + +#ifndef USE_DISCORD_PRESENCE + ui->discord_group->setVisible(false); +#endif + this->setConfiguration(); +} + +ConfigureWeb::~ConfigureWeb() = default; + +void ConfigureWeb::setConfiguration() { + ui->web_credentials_disclaimer->setWordWrap(true); + ui->telemetry_learn_more->setOpenExternalLinks(true); + ui->telemetry_learn_more->setText( + tr("<a href='https://yuzu-emu.org/help/feature/telemetry/'><span style=\"text-decoration: " + "underline; color:#039be5;\">Learn more</span></a>")); + + ui->web_signup_link->setOpenExternalLinks(true); + ui->web_signup_link->setText( + tr("<a href='https://profile.yuzu-emu.org/'><span style=\"text-decoration: underline; " + "color:#039be5;\">Sign up</span></a>")); + ui->web_token_info_link->setOpenExternalLinks(true); + ui->web_token_info_link->setText( + tr("<a href='https://yuzu-emu.org/wiki/yuzu-web-service/'><span style=\"text-decoration: " + "underline; color:#039be5;\">What is my token?</span></a>")); + + ui->toggle_telemetry->setChecked(Settings::values.enable_telemetry); + ui->edit_username->setText(QString::fromStdString(Settings::values.yuzu_username)); + ui->edit_token->setText(QString::fromStdString(Settings::values.yuzu_token)); + // Connect after setting the values, to avoid calling OnLoginChanged now + connect(ui->edit_token, &QLineEdit::textChanged, this, &ConfigureWeb::OnLoginChanged); + connect(ui->edit_username, &QLineEdit::textChanged, this, &ConfigureWeb::OnLoginChanged); + ui->label_telemetry_id->setText( + tr("Telemetry ID: 0x%1").arg(QString::number(Core::GetTelemetryId(), 16).toUpper())); + user_verified = true; + + ui->toggle_discordrpc->setChecked(UISettings::values.enable_discord_presence); +} + +void ConfigureWeb::applyConfiguration() { + Settings::values.enable_telemetry = ui->toggle_telemetry->isChecked(); + UISettings::values.enable_discord_presence = ui->toggle_discordrpc->isChecked(); + if (user_verified) { + Settings::values.yuzu_username = ui->edit_username->text().toStdString(); + Settings::values.yuzu_token = ui->edit_token->text().toStdString(); + } else { + QMessageBox::warning(this, tr("Username and token not verified"), + tr("Username and token were not verified. The changes to your " + "username and/or token have not been saved.")); + } +} + +void ConfigureWeb::RefreshTelemetryID() { + const u64 new_telemetry_id{Core::RegenerateTelemetryId()}; + ui->label_telemetry_id->setText( + tr("Telemetry ID: 0x%1").arg(QString::number(new_telemetry_id, 16).toUpper())); +} + +void ConfigureWeb::OnLoginChanged() { + if (ui->edit_username->text().isEmpty() && ui->edit_token->text().isEmpty()) { + user_verified = true; + ui->label_username_verified->setPixmap(QIcon::fromTheme("checked").pixmap(16)); + ui->label_token_verified->setPixmap(QIcon::fromTheme("checked").pixmap(16)); + } else { + user_verified = false; + ui->label_username_verified->setPixmap(QIcon::fromTheme("failed").pixmap(16)); + ui->label_token_verified->setPixmap(QIcon::fromTheme("failed").pixmap(16)); + } +} + +void ConfigureWeb::VerifyLogin() { + ui->button_verify_login->setDisabled(true); + ui->button_verify_login->setText(tr("Verifying")); + verify_watcher.setFuture( + QtConcurrent::run([this, username = ui->edit_username->text().toStdString(), + token = ui->edit_token->text().toStdString()]() { + return Core::VerifyLogin(username, token); + })); +} + +void ConfigureWeb::OnLoginVerified() { + ui->button_verify_login->setEnabled(true); + ui->button_verify_login->setText(tr("Verify")); + if (verify_watcher.result()) { + user_verified = true; + ui->label_username_verified->setPixmap(QIcon::fromTheme("checked").pixmap(16)); + ui->label_token_verified->setPixmap(QIcon::fromTheme("checked").pixmap(16)); + } else { + ui->label_username_verified->setPixmap(QIcon::fromTheme("failed").pixmap(16)); + ui->label_token_verified->setPixmap(QIcon::fromTheme("failed").pixmap(16)); + QMessageBox::critical( + this, tr("Verification failed"), + tr("Verification failed. Check that you have entered your username and token " + "correctly, and that your internet connection is working.")); + } +} + +void ConfigureWeb::retranslateUi() { + ui->retranslateUi(this); +} diff --git a/src/yuzu/configuration/configure_web.h b/src/yuzu/configuration/configure_web.h new file mode 100644 index 000000000..7741ab95d --- /dev/null +++ b/src/yuzu/configuration/configure_web.h @@ -0,0 +1,38 @@ +// Copyright 2017 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#pragma once + +#include <memory> +#include <QFutureWatcher> +#include <QWidget> + +namespace Ui { +class ConfigureWeb; +} + +class ConfigureWeb : public QWidget { + Q_OBJECT + +public: + explicit ConfigureWeb(QWidget* parent = nullptr); + ~ConfigureWeb(); + + void applyConfiguration(); + void retranslateUi(); + +public slots: + void RefreshTelemetryID(); + void OnLoginChanged(); + void VerifyLogin(); + void OnLoginVerified(); + +private: + void setConfiguration(); + + bool user_verified = true; + QFutureWatcher<bool> verify_watcher; + + std::unique_ptr<Ui::ConfigureWeb> ui; +}; diff --git a/src/yuzu/configuration/configure_web.ui b/src/yuzu/configuration/configure_web.ui new file mode 100644 index 000000000..2f4b9dd73 --- /dev/null +++ b/src/yuzu/configuration/configure_web.ui @@ -0,0 +1,206 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>ConfigureWeb</class> + <widget class="QWidget" name="ConfigureWeb"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>926</width> + <height>561</height> + </rect> + </property> + <property name="windowTitle"> + <string>Form</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout"> + <item> + <layout class="QVBoxLayout" name="verticalLayout_3"> + <item> + <widget class="QGroupBox" name="groupBoxWebConfig"> + <property name="title"> + <string>yuzu Web Service</string> + </property> + <layout class="QVBoxLayout" name="verticalLayoutYuzuWebService"> + <item> + <widget class="QLabel" name="web_credentials_disclaimer"> + <property name="text"> + <string>By providing your username and token, you agree to allow yuzu to collect additional usage data, which may include user identifying information.</string> + </property> + </widget> + </item> + <item> + <layout class="QGridLayout" name="gridLayoutYuzuUsername"> + <item row="2" column="3"> + <widget class="QPushButton" name="button_verify_login"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="layoutDirection"> + <enum>Qt::RightToLeft</enum> + </property> + <property name="text"> + <string>Verify</string> + </property> + </widget> + </item> + <item row="2" column="0"> + <widget class="QLabel" name="web_signup_link"> + <property name="text"> + <string>Sign up</string> + </property> + </widget> + </item> + <item row="0" column="1" colspan="3"> + <widget class="QLineEdit" name="edit_username"> + <property name="maxLength"> + <number>36</number> + </property> + </widget> + </item> + <item row="1" column="0"> + <widget class="QLabel" name="label_token"> + <property name="text"> + <string>Token: </string> + </property> + </widget> + </item> + <item row="1" column="4"> + <widget class="QLabel" name="label_token_verified"> + </widget> + </item> + <item row="0" column="0"> + <widget class="QLabel" name="label_username"> + <property name="text"> + <string>Username: </string> + </property> + </widget> + </item> + <item row="0" column="4"> + <widget class="QLabel" name="label_username_verified"> + </widget> + </item> + <item row="1" column="1" colspan="3"> + <widget class="QLineEdit" name="edit_token"> + <property name="maxLength"> + <number>36</number> + </property> + <property name="echoMode"> + <enum>QLineEdit::Password</enum> + </property> + </widget> + </item> + <item row="2" column="1"> + <widget class="QLabel" name="web_token_info_link"> + <property name="text"> + <string>What is my token?</string> + </property> + </widget> + </item> + <item row="2" column="2"> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + </layout> + </widget> + </item> + <item> + <widget class="QGroupBox" name="groupBox"> + <property name="title"> + <string>Telemetry</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout_2"> + <item> + <widget class="QCheckBox" name="toggle_telemetry"> + <property name="text"> + <string>Share anonymous usage data with the yuzu team</string> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="telemetry_learn_more"> + <property name="text"> + <string>Learn more</string> + </property> + </widget> + </item> + <item> + <layout class="QGridLayout" name="gridLayoutTelemetryId"> + <item row="0" column="0"> + <widget class="QLabel" name="label_telemetry_id"> + <property name="text"> + <string>Telemetry ID:</string> + </property> + </widget> + </item> + <item row="0" column="1"> + <widget class="QPushButton" name="button_regenerate_telemetry_id"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="layoutDirection"> + <enum>Qt::RightToLeft</enum> + </property> + <property name="text"> + <string>Regenerate</string> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </widget> + </item> + </layout> + </item> + <item> + <widget class="QGroupBox" name="discord_group"> + <property name="title"> + <string>Discord Presence</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout_21"> + <item> + <widget class="QCheckBox" name="toggle_discordrpc"> + <property name="text"> + <string>Show Current Game in your Discord Status</string> + </property> + </widget> + </item> + </layout> + </widget> + </item> + <item> + <spacer name="verticalSpacer"> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>20</width> + <height>40</height> + </size> + </property> + </spacer> + </item> + </layout> + </widget> + <resources/> + <connections/> +</ui> diff --git a/src/yuzu/discord.h b/src/yuzu/discord.h new file mode 100644 index 000000000..a867cc4d6 --- /dev/null +++ b/src/yuzu/discord.h @@ -0,0 +1,25 @@ +// Copyright 2018 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#pragma once + +namespace DiscordRPC { + +class DiscordInterface { +public: + virtual ~DiscordInterface() = default; + + virtual void Pause() = 0; + virtual void Update() = 0; +}; + +class NullImpl : public DiscordInterface { +public: + ~NullImpl() = default; + + void Pause() override {} + void Update() override {} +}; + +} // namespace DiscordRPC diff --git a/src/yuzu/discord_impl.cpp b/src/yuzu/discord_impl.cpp new file mode 100644 index 000000000..9d87a41eb --- /dev/null +++ b/src/yuzu/discord_impl.cpp @@ -0,0 +1,52 @@ +// Copyright 2018 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#include <chrono> +#include <string> +#include <discord_rpc.h> +#include "common/common_types.h" +#include "core/core.h" +#include "core/loader/loader.h" +#include "yuzu/discord_impl.h" +#include "yuzu/ui_settings.h" + +namespace DiscordRPC { + +DiscordImpl::DiscordImpl() { + DiscordEventHandlers handlers{}; + + // The number is the client ID for yuzu, it's used for images and the + // application name + Discord_Initialize("471872241299226636", &handlers, 1, nullptr); +} + +DiscordImpl::~DiscordImpl() { + Discord_ClearPresence(); + Discord_Shutdown(); +} + +void DiscordImpl::Pause() { + Discord_ClearPresence(); +} + +void DiscordImpl::Update() { + s64 start_time = std::chrono::duration_cast<std::chrono::seconds>( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + std::string title; + if (Core::System::GetInstance().IsPoweredOn()) + Core::System::GetInstance().GetAppLoader().ReadTitle(title); + DiscordRichPresence presence{}; + presence.largeImageKey = "yuzu_logo"; + presence.largeImageText = "yuzu is an emulator for the Nintendo Switch"; + if (Core::System::GetInstance().IsPoweredOn()) { + presence.state = title.c_str(); + presence.details = "Currently in game"; + } else { + presence.details = "Not in game"; + } + presence.startTimestamp = start_time; + Discord_UpdatePresence(&presence); +} +} // namespace DiscordRPC diff --git a/src/yuzu/discord_impl.h b/src/yuzu/discord_impl.h new file mode 100644 index 000000000..4bfda8cdf --- /dev/null +++ b/src/yuzu/discord_impl.h @@ -0,0 +1,20 @@ +// Copyright 2018 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#pragma once + +#include "yuzu/discord.h" + +namespace DiscordRPC { + +class DiscordImpl : public DiscordInterface { +public: + DiscordImpl(); + ~DiscordImpl() override; + + void Pause() override; + void Update() override; +}; + +} // namespace DiscordRPC diff --git a/src/yuzu/main.cpp b/src/yuzu/main.cpp index 27015d02c..ad62a82d0 100644 --- a/src/yuzu/main.cpp +++ b/src/yuzu/main.cpp @@ -35,6 +35,7 @@ static FileSys::VirtualFile VfsDirectoryCreateFileWrapper(const FileSys::Virtual #include <QtWidgets> #include <fmt/format.h> #include "common/common_paths.h" +#include "common/detached_tasks.h" #include "common/file_util.h" #include "common/logging/backend.h" #include "common/logging/filter.h" @@ -65,6 +66,7 @@ static FileSys::VirtualFile VfsDirectoryCreateFileWrapper(const FileSys::Virtual #include "video_core/debug_utils/debug_utils.h" #include "yuzu/about_dialog.h" #include "yuzu/bootmanager.h" +#include "yuzu/compatdb.h" #include "yuzu/compatibility_list.h" #include "yuzu/configuration/config.h" #include "yuzu/configuration/configure_dialog.h" @@ -73,12 +75,17 @@ static FileSys::VirtualFile VfsDirectoryCreateFileWrapper(const FileSys::Virtual #include "yuzu/debugger/graphics/graphics_surface.h" #include "yuzu/debugger/profiler.h" #include "yuzu/debugger/wait_tree.h" +#include "yuzu/discord.h" #include "yuzu/game_list.h" #include "yuzu/game_list_p.h" #include "yuzu/hotkeys.h" #include "yuzu/main.h" #include "yuzu/ui_settings.h" +#ifdef USE_DISCORD_PRESENCE +#include "yuzu/discord_impl.h" +#endif + #ifdef QT_STATICPLUGIN Q_IMPORT_PLUGIN(QWindowsIntegrationPlugin); #endif @@ -102,23 +109,22 @@ enum class CalloutFlag : uint32_t { DRDDeprecation = 0x2, }; -static void ShowCalloutMessage(const QString& message, CalloutFlag flag) { - if (UISettings::values.callout_flags & static_cast<uint32_t>(flag)) { +void GMainWindow::ShowTelemetryCallout() { + if (UISettings::values.callout_flags & static_cast<uint32_t>(CalloutFlag::Telemetry)) { return; } - UISettings::values.callout_flags |= static_cast<uint32_t>(flag); - - QMessageBox msg; - msg.setText(message); - msg.setStandardButtons(QMessageBox::Ok); - msg.setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); - msg.setStyleSheet("QLabel{min-width: 900px;}"); - msg.exec(); + UISettings::values.callout_flags |= static_cast<uint32_t>(CalloutFlag::Telemetry); + const QString telemetry_message = + tr("<a href='https://yuzu-emu.org/help/feature/telemetry/'>Anonymous " + "data is collected</a> to help improve yuzu. " + "<br/><br/>Would you like to share your usage data with us?"); + if (QMessageBox::question(this, tr("Telemetry"), telemetry_message) != QMessageBox::Yes) { + Settings::values.enable_telemetry = false; + Settings::Apply(); + } } -void GMainWindow::ShowCallouts() {} - const int GMainWindow::max_recent_files_item; static void InitializeLogging() { @@ -145,6 +151,9 @@ GMainWindow::GMainWindow() default_theme_paths = QIcon::themeSearchPaths(); UpdateUITheme(); + SetDiscordEnabled(UISettings::values.enable_discord_presence); + discord_rpc->Update(); + InitializeWidgets(); InitializeDebugWidgets(); InitializeRecentFileMenuActions(); @@ -168,7 +177,7 @@ GMainWindow::GMainWindow() game_list->PopulateAsync(UISettings::values.gamedir, UISettings::values.gamedir_deepscan); // Show one-time "callout" messages to the user - ShowCallouts(); + ShowTelemetryCallout(); QStringList args = QApplication::arguments(); if (args.length() >= 2) { @@ -183,6 +192,9 @@ GMainWindow::~GMainWindow() { } void GMainWindow::InitializeWidgets() { +#ifdef YUZU_ENABLE_COMPATIBILITY_REPORTING + ui.action_Report_Compatibility->setVisible(true); +#endif render_window = new GRenderWindow(this, emu_thread.get()); render_window->hide(); @@ -411,6 +423,8 @@ void GMainWindow::ConnectMenuEvents() { connect(ui.action_Start, &QAction::triggered, this, &GMainWindow::OnStartGame); connect(ui.action_Pause, &QAction::triggered, this, &GMainWindow::OnPauseGame); connect(ui.action_Stop, &QAction::triggered, this, &GMainWindow::OnStopGame); + connect(ui.action_Report_Compatibility, &QAction::triggered, this, + &GMainWindow::OnMenuReportCompatibility); connect(ui.action_Restart, &QAction::triggered, this, [this] { BootGame(QString(game_path)); }); connect(ui.action_Configure, &QAction::triggered, this, &GMainWindow::OnConfigure); @@ -647,6 +661,7 @@ void GMainWindow::BootGame(const QString& filename) { } void GMainWindow::ShutdownGame() { + discord_rpc->Pause(); emu_thread->RequestStop(); emit EmulationStopping(); @@ -655,6 +670,8 @@ void GMainWindow::ShutdownGame() { emu_thread->wait(); emu_thread = nullptr; + discord_rpc->Update(); + // The emulation is stopped, so closing the window or not does not matter anymore disconnect(render_window, &GRenderWindow::Closed, this, &GMainWindow::OnStopGame); @@ -664,6 +681,7 @@ void GMainWindow::ShutdownGame() { ui.action_Pause->setEnabled(false); ui.action_Stop->setEnabled(false); ui.action_Restart->setEnabled(false); + ui.action_Report_Compatibility->setEnabled(false); render_window->hide(); game_list->show(); game_list->setFilterFocus(); @@ -1147,6 +1165,9 @@ void GMainWindow::OnStartGame() { ui.action_Pause->setEnabled(true); ui.action_Stop->setEnabled(true); ui.action_Restart->setEnabled(true); + ui.action_Report_Compatibility->setEnabled(true); + + discord_rpc->Update(); } void GMainWindow::OnPauseGame() { @@ -1161,6 +1182,20 @@ void GMainWindow::OnStopGame() { ShutdownGame(); } +void GMainWindow::OnMenuReportCompatibility() { + if (!Settings::values.yuzu_token.empty() && !Settings::values.yuzu_username.empty()) { + CompatDB compatdb{this}; + compatdb.exec(); + } else { + QMessageBox::critical( + this, tr("Missing yuzu Account"), + tr("In order to submit a game compatibility test case, you must link your yuzu " + "account.<br><br/>To link your yuzu account, go to Emulation > Configuration " + "> " + "Web.")); + } +} + void GMainWindow::ToggleFullscreen() { if (!emulation_running) { return; @@ -1224,11 +1259,14 @@ void GMainWindow::ToggleWindowMode() { void GMainWindow::OnConfigure() { ConfigureDialog configureDialog(this, hotkey_registry); auto old_theme = UISettings::values.theme; + const bool old_discord_presence = UISettings::values.enable_discord_presence; auto result = configureDialog.exec(); if (result == QDialog::Accepted) { configureDialog.applyConfiguration(); if (UISettings::values.theme != old_theme) UpdateUITheme(); + if (UISettings::values.enable_discord_presence != old_discord_presence) + SetDiscordEnabled(UISettings::values.enable_discord_presence); game_list->PopulateAsync(UISettings::values.gamedir, UISettings::values.gamedir_deepscan); config->Save(); } @@ -1443,11 +1481,25 @@ void GMainWindow::UpdateUITheme() { emit UpdateThemedIcons(); } +void GMainWindow::SetDiscordEnabled(bool state) { +#ifdef USE_DISCORD_PRESENCE + if (state) { + discord_rpc = std::make_unique<DiscordRPC::DiscordImpl>(); + } else { + discord_rpc = std::make_unique<DiscordRPC::NullImpl>(); + } +#else + discord_rpc = std::make_unique<DiscordRPC::NullImpl>(); +#endif + discord_rpc->Update(); +} + #ifdef main #undef main #endif int main(int argc, char* argv[]) { + Common::DetachedTasks detached_tasks; MicroProfileOnThreadCreate("Frontend"); SCOPE_EXIT({ MicroProfileShutdown(); }); @@ -1465,5 +1517,7 @@ int main(int argc, char* argv[]) { GMainWindow main_window; // After settings have been loaded by GMainWindow, apply the filter main_window.show(); - return app.exec(); + int result = app.exec(); + detached_tasks.WaitForAllTasks(); + return result; } diff --git a/src/yuzu/main.h b/src/yuzu/main.h index 8ee9242b1..fe0e9a50a 100644 --- a/src/yuzu/main.h +++ b/src/yuzu/main.h @@ -41,6 +41,10 @@ enum class EmulatedDirectoryTarget { SDMC, }; +namespace DiscordRPC { +class DiscordInterface; +} + class GMainWindow : public QMainWindow { Q_OBJECT @@ -61,6 +65,8 @@ public: GMainWindow(); ~GMainWindow() override; + std::unique_ptr<DiscordRPC::DiscordInterface> discord_rpc; + signals: /** @@ -99,7 +105,8 @@ private: void BootGame(const QString& filename); void ShutdownGame(); - void ShowCallouts(); + void ShowTelemetryCallout(); + void SetDiscordEnabled(bool state); /** * Stores the filename in the recently loaded files list. @@ -135,6 +142,7 @@ private slots: void OnStartGame(); void OnPauseGame(); void OnStopGame(); + void OnMenuReportCompatibility(); /// Called whenever a user selects a game in the game list widget. void OnGameListLoadFile(QString game_path); void OnGameListOpenFolder(u64 program_id, GameListOpenTarget target); diff --git a/src/yuzu/main.ui b/src/yuzu/main.ui index 3879d4813..cb1664b21 100644 --- a/src/yuzu/main.ui +++ b/src/yuzu/main.ui @@ -45,7 +45,7 @@ <x>0</x> <y>0</y> <width>1081</width> - <height>19</height> + <height>21</height> </rect> </property> <widget class="QMenu" name="menu_File"> @@ -101,6 +101,8 @@ <property name="title"> <string>&Help</string> </property> + <addaction name="action_Report_Compatibility"/> + <addaction name="separator"/> <addaction name="action_About"/> </widget> <addaction name="menu_File"/> @@ -239,6 +241,18 @@ <string>Restart</string> </property> </action> + <action name="action_Report_Compatibility"> + <property name="enabled"> + <bool>false</bool> + </property> + <property name="text"> + <string>Report Compatibility</string> + </property> + <property name="visible"> + <bool>false</bool> + </property> + </action> </widget> <resources/> + <connections/> </ui> diff --git a/src/yuzu/ui_settings.h b/src/yuzu/ui_settings.h index 2d679004f..2e617d52a 100644 --- a/src/yuzu/ui_settings.h +++ b/src/yuzu/ui_settings.h @@ -38,6 +38,9 @@ struct Values { bool confirm_before_closing; bool first_start; + // Discord RPC + bool enable_discord_presence; + QString roms_path; QString symbols_path; QString gamedir; diff --git a/src/yuzu_cmd/config.cpp b/src/yuzu_cmd/config.cpp index a478b0a56..9d934e220 100644 --- a/src/yuzu_cmd/config.cpp +++ b/src/yuzu_cmd/config.cpp @@ -138,6 +138,14 @@ void Config::ReadValues() { Settings::values.use_gdbstub = sdl2_config->GetBoolean("Debugging", "use_gdbstub", false); Settings::values.gdbstub_port = static_cast<u16>(sdl2_config->GetInteger("Debugging", "gdbstub_port", 24689)); + + // Web Service + Settings::values.enable_telemetry = + sdl2_config->GetBoolean("WebService", "enable_telemetry", true); + Settings::values.web_api_url = + sdl2_config->Get("WebService", "web_api_url", "https://api.yuzu-emu.org"); + Settings::values.yuzu_username = sdl2_config->Get("WebService", "yuzu_username", ""); + Settings::values.yuzu_token = sdl2_config->Get("WebService", "yuzu_token", ""); } void Config::Reload() { diff --git a/src/yuzu_cmd/default_ini.h b/src/yuzu_cmd/default_ini.h index d35c441e9..762396e3b 100644 --- a/src/yuzu_cmd/default_ini.h +++ b/src/yuzu_cmd/default_ini.h @@ -202,12 +202,10 @@ gdbstub_port=24689 # Whether or not to enable telemetry # 0: No, 1 (default): Yes enable_telemetry = -# Endpoint URL for submitting telemetry data -telemetry_endpoint_url = -# Endpoint URL to verify the username and token -verify_endpoint_url = +# URL for Web API +web_api_url = https://api.yuzu-emu.org # Username and token for yuzu Web Service -# See https://services.citra-emu.org/ for more info +# See https://profile.yuzu-emu.org/ for more info yuzu_username = yuzu_token = )"; diff --git a/src/yuzu_cmd/yuzu.cpp b/src/yuzu_cmd/yuzu.cpp index b2559b717..1d951ca3f 100644 --- a/src/yuzu_cmd/yuzu.cpp +++ b/src/yuzu_cmd/yuzu.cpp @@ -10,6 +10,7 @@ #include <fmt/ostream.h> #include "common/common_paths.h" +#include "common/detached_tasks.h" #include "common/file_util.h" #include "common/logging/backend.h" #include "common/logging/filter.h" @@ -78,6 +79,7 @@ static void InitializeLogging() { /// Application entry point int main(int argc, char** argv) { + Common::DetachedTasks detached_tasks; Config config; int option_index = 0; @@ -213,5 +215,6 @@ int main(int argc, char** argv) { system.RunLoop(); } + detached_tasks.WaitForAllTasks(); return 0; } |