mirror of
https://github.com/yuzu-mirror/yuzu.git
synced 2024-11-18 05:09:58 +00:00
Port web_service from Citra
This commit is contained in:
parent
5f30f95e94
commit
4d139943f2
45 changed files with 1577 additions and 39 deletions
|
@ -10,3 +10,7 @@ TRAVIS_JOB_ID
|
||||||
TRAVIS_JOB_NUMBER
|
TRAVIS_JOB_NUMBER
|
||||||
TRAVIS_REPO_SLUG
|
TRAVIS_REPO_SLUG
|
||||||
TRAVIS_TAG
|
TRAVIS_TAG
|
||||||
|
|
||||||
|
# yuzu specific flags
|
||||||
|
ENABLE_COMPATIBILITY_REPORTING
|
||||||
|
USE_DISCORD_PRESENCE
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
#!/bin/bash -ex
|
#!/bin/bash -ex
|
||||||
|
|
||||||
mkdir -p "$HOME/.ccache"
|
mkdir -p "$HOME/.ccache"
|
||||||
docker run --env-file .travis/common/travis-ci.env -v $(pwd):/yuzu -v "$HOME/.ccache":/root/.ccache ubuntu:18.04 /bin/bash /yuzu/.travis/linux/docker.sh
|
docker run -e ENABLE_COMPATIBILITY_REPORTING --env-file .travis/common/travis-ci.env -v $(pwd):/yuzu -v "$HOME/.ccache":/root/.ccache ubuntu:18.04 /bin/bash /yuzu/.travis/linux/docker.sh
|
||||||
|
|
|
@ -6,7 +6,7 @@ apt-get install --no-install-recommends -y build-essential git libqt5opengl5-dev
|
||||||
cd /yuzu
|
cd /yuzu
|
||||||
|
|
||||||
mkdir build && cd build
|
mkdir build && cd build
|
||||||
cmake .. -DYUZU_USE_BUNDLED_UNICORN=ON -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_COMPILER=/usr/lib/ccache/gcc -DCMAKE_CXX_COMPILER=/usr/lib/ccache/g++ -DENABLE_COMPATIBILITY_LIST_DOWNLOAD=ON -G Ninja
|
cmake .. -DYUZU_USE_BUNDLED_UNICORN=ON -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_COMPILER=/usr/lib/ccache/gcc -DCMAKE_CXX_COMPILER=/usr/lib/ccache/g++ -DYUZU_ENABLE_COMPATIBILITY_REPORTING=${ENABLE_COMPATIBILITY_REPORTING:-"OFF"} -DENABLE_COMPATIBILITY_LIST_DOWNLOAD=ON -DUSE_DISCORD_PRESENCE=ON -G Ninja
|
||||||
ninja
|
ninja
|
||||||
|
|
||||||
ccache -s
|
ccache -s
|
||||||
|
|
|
@ -9,7 +9,7 @@ export PATH="/usr/local/opt/ccache/libexec:$PATH"
|
||||||
|
|
||||||
mkdir build && cd build
|
mkdir build && cd build
|
||||||
cmake --version
|
cmake --version
|
||||||
cmake .. -DYUZU_USE_BUNDLED_UNICORN=ON -DCMAKE_BUILD_TYPE=Release -DENABLE_COMPATIBILITY_LIST_DOWNLOAD=ON
|
cmake .. -DYUZU_USE_BUNDLED_UNICORN=ON -DCMAKE_BUILD_TYPE=Release -DENABLE_COMPATIBILITY_LIST_DOWNLOAD=ON -DYUZU_ENABLE_COMPATIBILITY_REPORTING=${ENABLE_COMPATIBILITY_REPORTING:-"OFF"} -DUSE_DISCORD_PRESENCE=ON
|
||||||
make -j4
|
make -j4
|
||||||
|
|
||||||
ccache -s
|
ccache -s
|
||||||
|
|
|
@ -15,10 +15,14 @@ CMAKE_DEPENDENT_OPTION(YUZU_USE_BUNDLED_SDL2 "Download bundled SDL2 binaries" ON
|
||||||
option(ENABLE_QT "Enable the Qt frontend" ON)
|
option(ENABLE_QT "Enable the Qt frontend" ON)
|
||||||
CMAKE_DEPENDENT_OPTION(YUZU_USE_BUNDLED_QT "Download bundled Qt binaries" ON "ENABLE_QT;MSVC" OFF)
|
CMAKE_DEPENDENT_OPTION(YUZU_USE_BUNDLED_QT "Download bundled Qt binaries" ON "ENABLE_QT;MSVC" OFF)
|
||||||
|
|
||||||
|
option(ENABLE_WEB_SERVICE "Enable web services (telemetry, etc.)" ON)
|
||||||
|
|
||||||
option(YUZU_USE_BUNDLED_UNICORN "Build/Download bundled Unicorn" ON)
|
option(YUZU_USE_BUNDLED_UNICORN "Build/Download bundled Unicorn" ON)
|
||||||
|
|
||||||
option(ENABLE_CUBEB "Enables the cubeb audio backend" ON)
|
option(ENABLE_CUBEB "Enables the cubeb audio backend" ON)
|
||||||
|
|
||||||
|
option(USE_DISCORD_PRESENCE "Enables Discord Rich Presence" OFF)
|
||||||
|
|
||||||
if(NOT EXISTS ${CMAKE_SOURCE_DIR}/.git/hooks/pre-commit)
|
if(NOT EXISTS ${CMAKE_SOURCE_DIR}/.git/hooks/pre-commit)
|
||||||
message(STATUS "Copying pre-commit hook")
|
message(STATUS "Copying pre-commit hook")
|
||||||
file(COPY hooks/pre-commit
|
file(COPY hooks/pre-commit
|
||||||
|
@ -321,6 +325,13 @@ if (ENABLE_QT)
|
||||||
find_package(Qt5 REQUIRED COMPONENTS Widgets OpenGL ${QT_PREFIX_HINT})
|
find_package(Qt5 REQUIRED COMPONENTS Widgets OpenGL ${QT_PREFIX_HINT})
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
if (ENABLE_WEB_SERVICE)
|
||||||
|
add_definitions(-DENABLE_WEB_SERVICE)
|
||||||
|
endif()
|
||||||
|
if (YUZU_ENABLE_COMPATIBILITY_REPORTING)
|
||||||
|
add_definitions(-DYUZU_ENABLE_COMPATIBILITY_REPORTING)
|
||||||
|
endif()
|
||||||
|
|
||||||
# Platform-specific library requirements
|
# Platform-specific library requirements
|
||||||
# ======================================
|
# ======================================
|
||||||
|
|
||||||
|
|
|
@ -39,11 +39,12 @@ before_build:
|
||||||
- mkdir %BUILD_TYPE%_build
|
- mkdir %BUILD_TYPE%_build
|
||||||
- cd %BUILD_TYPE%_build
|
- cd %BUILD_TYPE%_build
|
||||||
- ps: |
|
- ps: |
|
||||||
|
$COMPAT = if ($env:ENABLE_COMPATIBILITY_REPORTING -eq $null) {0} else {$env:ENABLE_COMPATIBILITY_REPORTING}
|
||||||
if ($env:BUILD_TYPE -eq 'msvc') {
|
if ($env:BUILD_TYPE -eq 'msvc') {
|
||||||
# redirect stderr and change the exit code to prevent powershell from cancelling the build if cmake prints a warning
|
# redirect stderr and change the exit code to prevent powershell from cancelling the build if cmake prints a warning
|
||||||
cmd /C 'cmake -G "Visual Studio 15 2017 Win64" -DYUZU_USE_BUNDLED_QT=1 -DYUZU_USE_BUNDLED_SDL2=1 -DYUZU_USE_BUNDLED_UNICORN=1 -DENABLE_COMPATIBILITY_LIST_DOWNLOAD=ON .. 2>&1 && exit 0'
|
cmd /C 'cmake -G "Visual Studio 15 2017 Win64" -DYUZU_USE_BUNDLED_QT=1 -DYUZU_USE_BUNDLED_SDL2=1 -DYUZU_USE_BUNDLED_UNICORN=1 -DENABLE_COMPATIBILITY_LIST_DOWNLOAD=ON -DYUZU_ENABLE_COMPATIBILITY_REPORTING=${COMPAT} -DUSE_DISCORD_PRESENCE=ON .. 2>&1 && exit 0'
|
||||||
} else {
|
} else {
|
||||||
C:\msys64\usr\bin\bash.exe -lc "cmake -G 'MSYS Makefiles' -DYUZU_BUILD_UNICORN=1 -DCMAKE_BUILD_TYPE=Release -DENABLE_COMPATIBILITY_LIST_DOWNLOAD=ON .. 2>&1"
|
C:\msys64\usr\bin\bash.exe -lc "cmake -G 'MSYS Makefiles' -DYUZU_BUILD_UNICORN=1 -DCMAKE_BUILD_TYPE=Release -DENABLE_COMPATIBILITY_LIST_DOWNLOAD=ON -DYUZU_ENABLE_COMPATIBILITY_REPORTING=${COMPAT} -DUSE_DISCORD_PRESENCE=ON .. 2>&1"
|
||||||
}
|
}
|
||||||
- cd ..
|
- cd ..
|
||||||
|
|
||||||
|
|
1
externals/discord-rpc
vendored
Submodule
1
externals/discord-rpc
vendored
Submodule
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit e32d001809c4aad56cef2a5321b54442d830174f
|
1
externals/libressl
vendored
Submodule
1
externals/libressl
vendored
Submodule
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit 7d01cb01cb1a926ecb4c9c98b107ef3c26f59dfb
|
|
@ -13,3 +13,6 @@ endif()
|
||||||
if (ENABLE_QT)
|
if (ENABLE_QT)
|
||||||
add_subdirectory(yuzu)
|
add_subdirectory(yuzu)
|
||||||
endif()
|
endif()
|
||||||
|
if (ENABLE_WEB_SERVICE)
|
||||||
|
add_subdirectory(web_service)
|
||||||
|
endif()
|
||||||
|
|
|
@ -41,6 +41,8 @@ configure_file("${CMAKE_CURRENT_SOURCE_DIR}/scm_rev.cpp.in" "${CMAKE_CURRENT_SOU
|
||||||
add_library(common STATIC
|
add_library(common STATIC
|
||||||
alignment.h
|
alignment.h
|
||||||
assert.h
|
assert.h
|
||||||
|
detached_tasks.cpp
|
||||||
|
detached_tasks.h
|
||||||
bit_field.h
|
bit_field.h
|
||||||
bit_set.h
|
bit_set.h
|
||||||
cityhash.cpp
|
cityhash.cpp
|
||||||
|
@ -87,6 +89,7 @@ add_library(common STATIC
|
||||||
timer.cpp
|
timer.cpp
|
||||||
timer.h
|
timer.h
|
||||||
vector_math.h
|
vector_math.h
|
||||||
|
web_result.h
|
||||||
)
|
)
|
||||||
|
|
||||||
if(ARCHITECTURE_x86_64)
|
if(ARCHITECTURE_x86_64)
|
||||||
|
|
41
src/common/detached_tasks.cpp
Normal file
41
src/common/detached_tasks.cpp
Normal file
|
@ -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
|
39
src/common/detached_tasks.h
Normal file
39
src/common/detached_tasks.h
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
// 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
|
24
src/common/web_result.h
Normal file
24
src/common/web_result.h
Normal file
|
@ -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 Commo
|
|
@ -394,6 +394,9 @@ create_target_directory_groups(core)
|
||||||
|
|
||||||
target_link_libraries(core PUBLIC common PRIVATE audio_core video_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)
|
target_link_libraries(core PUBLIC Boost::boost PRIVATE fmt lz4_static mbedtls opus unicorn open_source_archives)
|
||||||
|
if (ENABLE_WEB_SERVICE)
|
||||||
|
target_link_libraries(core PUBLIC json-headers web_service)
|
||||||
|
endif()
|
||||||
|
|
||||||
if (ARCHITECTURE_x86_64)
|
if (ARCHITECTURE_x86_64)
|
||||||
target_sources(core PRIVATE
|
target_sources(core PRIVATE
|
||||||
|
|
|
@ -155,6 +155,12 @@ struct Values {
|
||||||
// Debugging
|
// Debugging
|
||||||
bool use_gdbstub;
|
bool use_gdbstub;
|
||||||
u16 gdbstub_port;
|
u16 gdbstub_port;
|
||||||
|
|
||||||
|
// WebService
|
||||||
|
bool enable_telemetry;
|
||||||
|
std::string web_api_url;
|
||||||
|
std::string yuzu_username;
|
||||||
|
std::string yuzu_token;
|
||||||
} extern values;
|
} extern values;
|
||||||
|
|
||||||
void Apply();
|
void Apply();
|
||||||
|
|
|
@ -6,6 +6,8 @@
|
||||||
#include "common/common_types.h"
|
#include "common/common_types.h"
|
||||||
#include "common/file_util.h"
|
#include "common/file_util.h"
|
||||||
|
|
||||||
|
#include <mbedtls/ctr_drbg.h>
|
||||||
|
#include <mbedtls/entropy.h>
|
||||||
#include "core/core.h"
|
#include "core/core.h"
|
||||||
#include "core/file_sys/control_metadata.h"
|
#include "core/file_sys/control_metadata.h"
|
||||||
#include "core/file_sys/patch_manager.h"
|
#include "core/file_sys/patch_manager.h"
|
||||||
|
@ -13,10 +15,30 @@
|
||||||
#include "core/settings.h"
|
#include "core/settings.h"
|
||||||
#include "core/telemetry_session.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 {
|
namespace Core {
|
||||||
|
|
||||||
static u64 GenerateTelemetryId() {
|
static u64 GenerateTelemetryId() {
|
||||||
u64 telemetry_id{};
|
u64 telemetry_id{};
|
||||||
|
|
||||||
|
mbedtls_entropy_context entropy;
|
||||||
|
mbedtls_entropy_init(&entropy);
|
||||||
|
mbedtls_ctr_drbg_context ctr_drbg;
|
||||||
|
const char* personalization = "yuzu Telemetry ID";
|
||||||
|
|
||||||
|
mbedtls_ctr_drbg_init(&ctr_drbg);
|
||||||
|
mbedtls_ctr_drbg_seed(&ctr_drbg, mbedtls_entropy_func, &entropy,
|
||||||
|
(const unsigned char*)personalization, strlen(personalization));
|
||||||
|
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;
|
return telemetry_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -25,14 +47,21 @@ u64 GetTelemetryId() {
|
||||||
const std::string filename{FileUtil::GetUserPath(FileUtil::UserPath::ConfigDir) +
|
const std::string filename{FileUtil::GetUserPath(FileUtil::UserPath::ConfigDir) +
|
||||||
"telemetry_id"};
|
"telemetry_id"};
|
||||||
|
|
||||||
if (FileUtil::Exists(filename)) {
|
bool generate_new_id = !FileUtil::Exists(filename);
|
||||||
|
if (!generate_new_id) {
|
||||||
FileUtil::IOFile file(filename, "rb");
|
FileUtil::IOFile file(filename, "rb");
|
||||||
if (!file.IsOpen()) {
|
if (!file.IsOpen()) {
|
||||||
LOG_ERROR(Core, "failed to open telemetry_id: {}", filename);
|
LOG_ERROR(Core, "failed to open telemetry_id: {}", filename);
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
file.ReadBytes(&telemetry_id, sizeof(u64));
|
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");
|
FileUtil::IOFile file(filename, "wb");
|
||||||
if (!file.IsOpen()) {
|
if (!file.IsOpen()) {
|
||||||
LOG_ERROR(Core, "failed to open telemetry_id: {}", filename);
|
LOG_ERROR(Core, "failed to open telemetry_id: {}", filename);
|
||||||
|
@ -59,22 +88,19 @@ u64 RegenerateTelemetryId() {
|
||||||
return new_telemetry_id;
|
return new_telemetry_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::future<bool> VerifyLogin(std::string username, std::string token, std::function<void()> func) {
|
bool VerifyLogin(std::string username, std::string token) {
|
||||||
#ifdef ENABLE_WEB_SERVICE
|
#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
|
#else
|
||||||
return std::async(std::launch::async, [func{std::move(func)}]() {
|
|
||||||
func();
|
|
||||||
return false;
|
return false;
|
||||||
});
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
TelemetrySession::TelemetrySession() {
|
TelemetrySession::TelemetrySession() {
|
||||||
#ifdef ENABLE_WEB_SERVICE
|
#ifdef ENABLE_WEB_SERVICE
|
||||||
if (Settings::values.enable_telemetry) {
|
if (Settings::values.enable_telemetry) {
|
||||||
backend = std::make_unique<WebService::TelemetryJson>(
|
backend = std::make_unique<WebService::TelemetryJson>(Settings::values.web_api_url,
|
||||||
Settings::values.telemetry_endpoint_url, Settings::values.yuzu_username,
|
Settings::values.yuzu_username,
|
||||||
Settings::values.yuzu_token);
|
Settings::values.yuzu_token);
|
||||||
} else {
|
} else {
|
||||||
backend = std::make_unique<Telemetry::NullVisitor>();
|
backend = std::make_unique<Telemetry::NullVisitor>();
|
||||||
|
@ -94,7 +120,8 @@ TelemetrySession::TelemetrySession() {
|
||||||
u64 program_id{};
|
u64 program_id{};
|
||||||
const Loader::ResultStatus res{System::GetInstance().GetAppLoader().ReadProgramId(program_id)};
|
const Loader::ResultStatus res{System::GetInstance().GetAppLoader().ReadProgramId(program_id)};
|
||||||
if (res == Loader::ResultStatus::Success) {
|
if (res == Loader::ResultStatus::Success) {
|
||||||
AddField(Telemetry::FieldType::Session, "ProgramId", program_id);
|
std::string formatted_program_id{fmt::format("{:016X}", program_id)};
|
||||||
|
AddField(Telemetry::FieldType::Session, "ProgramId", formatted_program_id);
|
||||||
|
|
||||||
std::string name;
|
std::string name;
|
||||||
System::GetInstance().GetAppLoader().ReadTitle(name);
|
System::GetInstance().GetAppLoader().ReadTitle(name);
|
||||||
|
|
|
@ -4,7 +4,6 @@
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <future>
|
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include "common/telemetry.h"
|
#include "common/telemetry.h"
|
||||||
|
|
||||||
|
@ -31,6 +30,8 @@ public:
|
||||||
field_collection.AddField(type, name, std::move(value));
|
field_collection.AddField(type, name, std::move(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void FinalizeAsyncJob();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
Telemetry::FieldCollection field_collection; ///< Tracks all added fields for the session
|
Telemetry::FieldCollection field_collection; ///< Tracks all added fields for the session
|
||||||
std::unique_ptr<Telemetry::VisitorInterface> backend; ///< Backend interface that logs fields
|
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
|
* @param func A function that gets exectued when the verification is finished
|
||||||
* @returns Future with bool indicating whether the verification succeeded
|
* @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(std::string username, std::string token);
|
||||||
|
|
||||||
} // namespace Core
|
} // namespace Core
|
||||||
|
|
16
src/web_service/CMakeLists.txt
Normal file
16
src/web_service/CMakeLists.txt
Normal file
|
@ -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)
|
||||||
|
add_definitions(-DCPPHTTPLIB_OPENSSL_SUPPORT)
|
||||||
|
target_link_libraries(web_service PUBLIC common json-headers ${OPENSSL_LIBS} httplib lurlparser)
|
18
src/web_service/json.h
Normal file
18
src/web_service/json.h
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
// Copyright 2018 Citra Emulator Project
|
||||||
|
// Licensed under GPLv2 or any later version
|
||||||
|
// Refer to the license.txt file included.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
// This hack is needed to support json.hpp on platforms where the C++17 stdlib
|
||||||
|
// lacks std::string_view. See https://github.com/nlohmann/json/issues/735.
|
||||||
|
// clang-format off
|
||||||
|
#if !__has_include(<string_view>) && __has_include(<experimental/string_view>)
|
||||||
|
# include <experimental/string_view>
|
||||||
|
# define string_view experimental::string_view
|
||||||
|
# include <json.hpp>
|
||||||
|
# undef string_view
|
||||||
|
#else
|
||||||
|
# include <json.hpp>
|
||||||
|
#endif
|
||||||
|
// clang-format on
|
94
src/web_service/telemetry_json.cpp
Normal file
94
src/web_service/telemetry_json.cpp
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
// 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 {
|
||||||
|
|
||||||
|
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
|
59
src/web_service/telemetry_json.h
Normal file
59
src/web_service/telemetry_json.h
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
// 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 "common/telemetry.h"
|
||||||
|
#include "common/web_result.h"
|
||||||
|
#include "web_service/json.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)
|
||||||
|
: host(host), username(username), token(token) {}
|
||||||
|
~TelemetryJson() = default;
|
||||||
|
|
||||||
|
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
|
27
src/web_service/verify_login.cpp
Normal file
27
src/web_service/verify_login.cpp
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
// Copyright 2017 Citra Emulator Project
|
||||||
|
// Licensed under GPLv2 or any later version
|
||||||
|
// Refer to the license.txt file included.
|
||||||
|
|
||||||
|
#include "web_service/json.h"
|
||||||
|
#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
|
22
src/web_service/verify_login.h
Normal file
22
src/web_service/verify_login.h
Normal file
|
@ -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
|
147
src/web_service/web_backend.cpp
Normal file
147
src/web_service/web_backend.cpp
Normal file
|
@ -0,0 +1,147 @@
|
||||||
|
// 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 {
|
||||||
|
|
||||||
|
static constexpr char API_VERSION[]{"1"};
|
||||||
|
|
||||||
|
constexpr int HTTP_PORT = 80;
|
||||||
|
constexpr int HTTPS_PORT = 443;
|
||||||
|
|
||||||
|
constexpr int 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) {
|
||||||
|
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));
|
||||||
|
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 {
|
||||||
|
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
|
91
src/web_service/web_backend.h
Normal file
91
src/web_service/web_backend.h
Normal file
|
@ -0,0 +1,91 @@
|
||||||
|
// 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>
|
||||||
|
#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::string username;
|
||||||
|
std::string token;
|
||||||
|
std::string jwt;
|
||||||
|
};
|
||||||
|
static JWTCache jwt_cache;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace WebService
|
|
@ -29,6 +29,8 @@ add_executable(yuzu
|
||||||
configuration/configure_input.h
|
configuration/configure_input.h
|
||||||
configuration/configure_system.cpp
|
configuration/configure_system.cpp
|
||||||
configuration/configure_system.h
|
configuration/configure_system.h
|
||||||
|
configuration/configure_web.cpp
|
||||||
|
configuration/configure_web.h
|
||||||
debugger/graphics/graphics_breakpoint_observer.cpp
|
debugger/graphics/graphics_breakpoint_observer.cpp
|
||||||
debugger/graphics/graphics_breakpoint_observer.h
|
debugger/graphics/graphics_breakpoint_observer.h
|
||||||
debugger/graphics/graphics_breakpoints.cpp
|
debugger/graphics/graphics_breakpoints.cpp
|
||||||
|
@ -42,6 +44,7 @@ add_executable(yuzu
|
||||||
debugger/profiler.h
|
debugger/profiler.h
|
||||||
debugger/wait_tree.cpp
|
debugger/wait_tree.cpp
|
||||||
debugger/wait_tree.h
|
debugger/wait_tree.h
|
||||||
|
discord.h
|
||||||
game_list.cpp
|
game_list.cpp
|
||||||
game_list.h
|
game_list.h
|
||||||
game_list_p.h
|
game_list_p.h
|
||||||
|
@ -57,6 +60,8 @@ add_executable(yuzu
|
||||||
util/spinbox.h
|
util/spinbox.h
|
||||||
util/util.cpp
|
util/util.cpp
|
||||||
util/util.h
|
util/util.h
|
||||||
|
compatdb.cpp
|
||||||
|
compatdb.h
|
||||||
yuzu.rc
|
yuzu.rc
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -70,8 +75,10 @@ set(UIS
|
||||||
configuration/configure_graphics.ui
|
configuration/configure_graphics.ui
|
||||||
configuration/configure_input.ui
|
configuration/configure_input.ui
|
||||||
configuration/configure_system.ui
|
configuration/configure_system.ui
|
||||||
|
configuration/configure_web.ui
|
||||||
hotkeys.ui
|
hotkeys.ui
|
||||||
main.ui
|
main.ui
|
||||||
|
compatdb.ui
|
||||||
)
|
)
|
||||||
|
|
||||||
file(GLOB COMPAT_LIST
|
file(GLOB COMPAT_LIST
|
||||||
|
@ -113,6 +120,15 @@ 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 Boost::boost glad Qt5::OpenGL Qt5::Widgets)
|
||||||
target_link_libraries(yuzu PRIVATE ${PLATFORM_LIBRARIES} Threads::Threads)
|
target_link_libraries(yuzu PRIVATE ${PLATFORM_LIBRARIES} Threads::Threads)
|
||||||
|
|
||||||
|
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)
|
if(UNIX AND NOT APPLE)
|
||||||
install(TARGETS yuzu RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}/bin")
|
install(TARGETS yuzu RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}/bin")
|
||||||
endif()
|
endif()
|
||||||
|
|
61
src/yuzu/compatdb.cpp
Normal file
61
src/yuzu/compatdb.cpp
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
// 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);
|
||||||
|
}
|
27
src/yuzu/compatdb.h
Normal file
27
src/yuzu/compatdb.h
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
private slots:
|
||||||
|
void Submit();
|
||||||
|
void EnableNext();
|
||||||
|
};
|
215
src/yuzu/compatdb.ui
Normal file
215
src/yuzu/compatdb.ui
Normal file
|
@ -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>
|
|
@ -136,8 +136,18 @@ void Config::ReadValues() {
|
||||||
Settings::values.gdbstub_port = qt_config->value("gdbstub_port", 24689).toInt();
|
Settings::values.gdbstub_port = qt_config->value("gdbstub_port", 24689).toInt();
|
||||||
qt_config->endGroup();
|
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");
|
qt_config->beginGroup("UI");
|
||||||
UISettings::values.theme = qt_config->value("theme", UISettings::themes[0].second).toString();
|
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");
|
qt_config->beginGroup("UIGameList");
|
||||||
UISettings::values.show_unknown = qt_config->value("show_unknown", true).toBool();
|
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->setValue("gdbstub_port", Settings::values.gdbstub_port);
|
||||||
qt_config->endGroup();
|
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->beginGroup("UI");
|
||||||
qt_config->setValue("theme", UISettings::values.theme);
|
qt_config->setValue("theme", UISettings::values.theme);
|
||||||
|
qt_config->setValue("enable_discord_presence", UISettings::values.enable_discord_presence);
|
||||||
|
|
||||||
qt_config->beginGroup("UIGameList");
|
qt_config->beginGroup("UIGameList");
|
||||||
qt_config->setValue("show_unknown", UISettings::values.show_unknown);
|
qt_config->setValue("show_unknown", UISettings::values.show_unknown);
|
||||||
|
|
|
@ -54,6 +54,11 @@
|
||||||
<string>Debug</string>
|
<string>Debug</string>
|
||||||
</attribute>
|
</attribute>
|
||||||
</widget>
|
</widget>
|
||||||
|
<widget class="ConfigureWeb" name="webTab">
|
||||||
|
<attribute name="title">
|
||||||
|
<string>Web</string>
|
||||||
|
</attribute>
|
||||||
|
</widget>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
<item>
|
||||||
|
@ -108,6 +113,12 @@
|
||||||
<header>configuration/configure_graphics.h</header>
|
<header>configuration/configure_graphics.h</header>
|
||||||
<container>1</container>
|
<container>1</container>
|
||||||
</customwidget>
|
</customwidget>
|
||||||
|
<customwidget>
|
||||||
|
<class>ConfigureWeb</class>
|
||||||
|
<extends>QWidget</extends>
|
||||||
|
<header>configuration/configure_web.h</header>
|
||||||
|
<container>1</container>
|
||||||
|
</customwidget>
|
||||||
</customwidgets>
|
</customwidgets>
|
||||||
<resources/>
|
<resources/>
|
||||||
<connections>
|
<connections>
|
||||||
|
|
|
@ -27,5 +27,6 @@ void ConfigureDialog::applyConfiguration() {
|
||||||
ui->graphicsTab->applyConfiguration();
|
ui->graphicsTab->applyConfiguration();
|
||||||
ui->audioTab->applyConfiguration();
|
ui->audioTab->applyConfiguration();
|
||||||
ui->debugTab->applyConfiguration();
|
ui->debugTab->applyConfiguration();
|
||||||
|
ui->webTab->applyConfiguration();
|
||||||
Settings::Apply();
|
Settings::Apply();
|
||||||
}
|
}
|
||||||
|
|
121
src/yuzu/configuration/configure_web.cpp
Normal file
121
src/yuzu/configuration/configure_web.cpp
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
// 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() {}
|
||||||
|
|
||||||
|
void ConfigureWeb::setConfiguration() {
|
||||||
|
ui->web_credentials_disclaimer->setWordWrap(true);
|
||||||
|
ui->telemetry_learn_more->setOpenExternalLinks(true);
|
||||||
|
ui->telemetry_learn_more->setText(tr("<a "
|
||||||
|
"href='https://citra-emu.org/entry/"
|
||||||
|
"telemetry-and-why-thats-a-good-thing/'><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://services.citra-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://citra-emu.org/wiki/citra-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);
|
||||||
|
}
|
38
src/yuzu/configuration/configure_web.h
Normal file
38
src/yuzu/configuration/configure_web.h
Normal file
|
@ -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;
|
||||||
|
};
|
206
src/yuzu/configuration/configure_web.ui
Normal file
206
src/yuzu/configuration/configure_web.ui
Normal file
|
@ -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>
|
25
src/yuzu/discord.h
Normal file
25
src/yuzu/discord.h
Normal file
|
@ -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
|
52
src/yuzu/discord_impl.cpp
Normal file
52
src/yuzu/discord_impl.cpp
Normal file
|
@ -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
|
20
src/yuzu/discord_impl.h
Normal file
20
src/yuzu/discord_impl.h
Normal file
|
@ -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();
|
||||||
|
|
||||||
|
void Pause() override;
|
||||||
|
void Update() override;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace DiscordRPC
|
|
@ -35,6 +35,7 @@ static FileSys::VirtualFile VfsDirectoryCreateFileWrapper(const FileSys::Virtual
|
||||||
#include <QtWidgets>
|
#include <QtWidgets>
|
||||||
#include <fmt/format.h>
|
#include <fmt/format.h>
|
||||||
#include "common/common_paths.h"
|
#include "common/common_paths.h"
|
||||||
|
#include "common/detached_tasks.h"
|
||||||
#include "common/file_util.h"
|
#include "common/file_util.h"
|
||||||
#include "common/logging/backend.h"
|
#include "common/logging/backend.h"
|
||||||
#include "common/logging/filter.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 "video_core/debug_utils/debug_utils.h"
|
||||||
#include "yuzu/about_dialog.h"
|
#include "yuzu/about_dialog.h"
|
||||||
#include "yuzu/bootmanager.h"
|
#include "yuzu/bootmanager.h"
|
||||||
|
#include "yuzu/compatdb.h"
|
||||||
#include "yuzu/compatibility_list.h"
|
#include "yuzu/compatibility_list.h"
|
||||||
#include "yuzu/configuration/config.h"
|
#include "yuzu/configuration/config.h"
|
||||||
#include "yuzu/configuration/configure_dialog.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/graphics/graphics_surface.h"
|
||||||
#include "yuzu/debugger/profiler.h"
|
#include "yuzu/debugger/profiler.h"
|
||||||
#include "yuzu/debugger/wait_tree.h"
|
#include "yuzu/debugger/wait_tree.h"
|
||||||
|
#include "yuzu/discord.h"
|
||||||
#include "yuzu/game_list.h"
|
#include "yuzu/game_list.h"
|
||||||
#include "yuzu/game_list_p.h"
|
#include "yuzu/game_list_p.h"
|
||||||
#include "yuzu/hotkeys.h"
|
#include "yuzu/hotkeys.h"
|
||||||
#include "yuzu/main.h"
|
#include "yuzu/main.h"
|
||||||
#include "yuzu/ui_settings.h"
|
#include "yuzu/ui_settings.h"
|
||||||
|
|
||||||
|
#ifdef USE_DISCORD_PRESENCE
|
||||||
|
#include "yuzu/discord_impl.h"
|
||||||
|
#endif
|
||||||
|
|
||||||
#ifdef QT_STATICPLUGIN
|
#ifdef QT_STATICPLUGIN
|
||||||
Q_IMPORT_PLUGIN(QWindowsIntegrationPlugin);
|
Q_IMPORT_PLUGIN(QWindowsIntegrationPlugin);
|
||||||
#endif
|
#endif
|
||||||
|
@ -102,22 +109,21 @@ enum class CalloutFlag : uint32_t {
|
||||||
DRDDeprecation = 0x2,
|
DRDDeprecation = 0x2,
|
||||||
};
|
};
|
||||||
|
|
||||||
static void ShowCalloutMessage(const QString& message, CalloutFlag flag) {
|
void GMainWindow::ShowTelemetryCallout() {
|
||||||
if (UISettings::values.callout_flags & static_cast<uint32_t>(flag)) {
|
if (UISettings::values.callout_flags & static_cast<uint32_t>(CalloutFlag::Telemetry)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
UISettings::values.callout_flags |= static_cast<uint32_t>(flag);
|
UISettings::values.callout_flags |= static_cast<uint32_t>(CalloutFlag::Telemetry);
|
||||||
|
static const QString telemetry_message =
|
||||||
QMessageBox msg;
|
tr("<a href='https://citra-emu.org/entry/telemetry-and-why-thats-a-good-thing/'>Anonymous "
|
||||||
msg.setText(message);
|
"data is collected</a> to help improve yuzu. "
|
||||||
msg.setStandardButtons(QMessageBox::Ok);
|
"<br/><br/>Would you like to share your usage data with us?");
|
||||||
msg.setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
|
if (QMessageBox::question(this, tr("Telemetry"), telemetry_message) != QMessageBox::Yes) {
|
||||||
msg.setStyleSheet("QLabel{min-width: 900px;}");
|
Settings::values.enable_telemetry = false;
|
||||||
msg.exec();
|
Settings::Apply();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void GMainWindow::ShowCallouts() {}
|
|
||||||
|
|
||||||
const int GMainWindow::max_recent_files_item;
|
const int GMainWindow::max_recent_files_item;
|
||||||
|
|
||||||
|
@ -145,6 +151,9 @@ GMainWindow::GMainWindow()
|
||||||
default_theme_paths = QIcon::themeSearchPaths();
|
default_theme_paths = QIcon::themeSearchPaths();
|
||||||
UpdateUITheme();
|
UpdateUITheme();
|
||||||
|
|
||||||
|
SetDiscordEnabled(UISettings::values.enable_discord_presence);
|
||||||
|
discord_rpc->Update();
|
||||||
|
|
||||||
InitializeWidgets();
|
InitializeWidgets();
|
||||||
InitializeDebugWidgets();
|
InitializeDebugWidgets();
|
||||||
InitializeRecentFileMenuActions();
|
InitializeRecentFileMenuActions();
|
||||||
|
@ -168,7 +177,7 @@ GMainWindow::GMainWindow()
|
||||||
game_list->PopulateAsync(UISettings::values.gamedir, UISettings::values.gamedir_deepscan);
|
game_list->PopulateAsync(UISettings::values.gamedir, UISettings::values.gamedir_deepscan);
|
||||||
|
|
||||||
// Show one-time "callout" messages to the user
|
// Show one-time "callout" messages to the user
|
||||||
ShowCallouts();
|
ShowTelemetryCallout();
|
||||||
|
|
||||||
QStringList args = QApplication::arguments();
|
QStringList args = QApplication::arguments();
|
||||||
if (args.length() >= 2) {
|
if (args.length() >= 2) {
|
||||||
|
@ -183,6 +192,9 @@ GMainWindow::~GMainWindow() {
|
||||||
}
|
}
|
||||||
|
|
||||||
void GMainWindow::InitializeWidgets() {
|
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 = new GRenderWindow(this, emu_thread.get());
|
||||||
render_window->hide();
|
render_window->hide();
|
||||||
|
|
||||||
|
@ -411,6 +423,8 @@ void GMainWindow::ConnectMenuEvents() {
|
||||||
connect(ui.action_Start, &QAction::triggered, this, &GMainWindow::OnStartGame);
|
connect(ui.action_Start, &QAction::triggered, this, &GMainWindow::OnStartGame);
|
||||||
connect(ui.action_Pause, &QAction::triggered, this, &GMainWindow::OnPauseGame);
|
connect(ui.action_Pause, &QAction::triggered, this, &GMainWindow::OnPauseGame);
|
||||||
connect(ui.action_Stop, &QAction::triggered, this, &GMainWindow::OnStopGame);
|
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_Restart, &QAction::triggered, this, [this] { BootGame(QString(game_path)); });
|
||||||
connect(ui.action_Configure, &QAction::triggered, this, &GMainWindow::OnConfigure);
|
connect(ui.action_Configure, &QAction::triggered, this, &GMainWindow::OnConfigure);
|
||||||
|
|
||||||
|
@ -647,6 +661,7 @@ void GMainWindow::BootGame(const QString& filename) {
|
||||||
}
|
}
|
||||||
|
|
||||||
void GMainWindow::ShutdownGame() {
|
void GMainWindow::ShutdownGame() {
|
||||||
|
discord_rpc->Pause();
|
||||||
emu_thread->RequestStop();
|
emu_thread->RequestStop();
|
||||||
|
|
||||||
emit EmulationStopping();
|
emit EmulationStopping();
|
||||||
|
@ -655,6 +670,8 @@ void GMainWindow::ShutdownGame() {
|
||||||
emu_thread->wait();
|
emu_thread->wait();
|
||||||
emu_thread = nullptr;
|
emu_thread = nullptr;
|
||||||
|
|
||||||
|
discord_rpc->Update();
|
||||||
|
|
||||||
// The emulation is stopped, so closing the window or not does not matter anymore
|
// The emulation is stopped, so closing the window or not does not matter anymore
|
||||||
disconnect(render_window, &GRenderWindow::Closed, this, &GMainWindow::OnStopGame);
|
disconnect(render_window, &GRenderWindow::Closed, this, &GMainWindow::OnStopGame);
|
||||||
|
|
||||||
|
@ -664,6 +681,7 @@ void GMainWindow::ShutdownGame() {
|
||||||
ui.action_Pause->setEnabled(false);
|
ui.action_Pause->setEnabled(false);
|
||||||
ui.action_Stop->setEnabled(false);
|
ui.action_Stop->setEnabled(false);
|
||||||
ui.action_Restart->setEnabled(false);
|
ui.action_Restart->setEnabled(false);
|
||||||
|
ui.action_Report_Compatibility->setEnabled(false);
|
||||||
render_window->hide();
|
render_window->hide();
|
||||||
game_list->show();
|
game_list->show();
|
||||||
game_list->setFilterFocus();
|
game_list->setFilterFocus();
|
||||||
|
@ -1147,6 +1165,9 @@ void GMainWindow::OnStartGame() {
|
||||||
ui.action_Pause->setEnabled(true);
|
ui.action_Pause->setEnabled(true);
|
||||||
ui.action_Stop->setEnabled(true);
|
ui.action_Stop->setEnabled(true);
|
||||||
ui.action_Restart->setEnabled(true);
|
ui.action_Restart->setEnabled(true);
|
||||||
|
ui.action_Report_Compatibility->setEnabled(true);
|
||||||
|
|
||||||
|
discord_rpc->Update();
|
||||||
}
|
}
|
||||||
|
|
||||||
void GMainWindow::OnPauseGame() {
|
void GMainWindow::OnPauseGame() {
|
||||||
|
@ -1161,6 +1182,20 @@ void GMainWindow::OnStopGame() {
|
||||||
ShutdownGame();
|
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() {
|
void GMainWindow::ToggleFullscreen() {
|
||||||
if (!emulation_running) {
|
if (!emulation_running) {
|
||||||
return;
|
return;
|
||||||
|
@ -1224,11 +1259,14 @@ void GMainWindow::ToggleWindowMode() {
|
||||||
void GMainWindow::OnConfigure() {
|
void GMainWindow::OnConfigure() {
|
||||||
ConfigureDialog configureDialog(this, hotkey_registry);
|
ConfigureDialog configureDialog(this, hotkey_registry);
|
||||||
auto old_theme = UISettings::values.theme;
|
auto old_theme = UISettings::values.theme;
|
||||||
|
const bool old_discord_presence = UISettings::values.enable_discord_presence;
|
||||||
auto result = configureDialog.exec();
|
auto result = configureDialog.exec();
|
||||||
if (result == QDialog::Accepted) {
|
if (result == QDialog::Accepted) {
|
||||||
configureDialog.applyConfiguration();
|
configureDialog.applyConfiguration();
|
||||||
if (UISettings::values.theme != old_theme)
|
if (UISettings::values.theme != old_theme)
|
||||||
UpdateUITheme();
|
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);
|
game_list->PopulateAsync(UISettings::values.gamedir, UISettings::values.gamedir_deepscan);
|
||||||
config->Save();
|
config->Save();
|
||||||
}
|
}
|
||||||
|
@ -1443,11 +1481,25 @@ void GMainWindow::UpdateUITheme() {
|
||||||
emit UpdateThemedIcons();
|
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
|
#ifdef main
|
||||||
#undef main
|
#undef main
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
int main(int argc, char* argv[]) {
|
int main(int argc, char* argv[]) {
|
||||||
|
Common::DetachedTasks detached_tasks;
|
||||||
MicroProfileOnThreadCreate("Frontend");
|
MicroProfileOnThreadCreate("Frontend");
|
||||||
SCOPE_EXIT({ MicroProfileShutdown(); });
|
SCOPE_EXIT({ MicroProfileShutdown(); });
|
||||||
|
|
||||||
|
@ -1465,5 +1517,7 @@ int main(int argc, char* argv[]) {
|
||||||
GMainWindow main_window;
|
GMainWindow main_window;
|
||||||
// After settings have been loaded by GMainWindow, apply the filter
|
// After settings have been loaded by GMainWindow, apply the filter
|
||||||
main_window.show();
|
main_window.show();
|
||||||
return app.exec();
|
int result = app.exec();
|
||||||
|
detached_tasks.WaitForAllTasks();
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,6 +41,10 @@ enum class EmulatedDirectoryTarget {
|
||||||
SDMC,
|
SDMC,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
namespace DiscordRPC {
|
||||||
|
class DiscordInterface;
|
||||||
|
}
|
||||||
|
|
||||||
class GMainWindow : public QMainWindow {
|
class GMainWindow : public QMainWindow {
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
|
@ -61,6 +65,8 @@ public:
|
||||||
GMainWindow();
|
GMainWindow();
|
||||||
~GMainWindow() override;
|
~GMainWindow() override;
|
||||||
|
|
||||||
|
std::unique_ptr<DiscordRPC::DiscordInterface> discord_rpc;
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -99,7 +105,8 @@ private:
|
||||||
void BootGame(const QString& filename);
|
void BootGame(const QString& filename);
|
||||||
void ShutdownGame();
|
void ShutdownGame();
|
||||||
|
|
||||||
void ShowCallouts();
|
void ShowTelemetryCallout();
|
||||||
|
void SetDiscordEnabled(bool state);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stores the filename in the recently loaded files list.
|
* Stores the filename in the recently loaded files list.
|
||||||
|
@ -135,6 +142,7 @@ private slots:
|
||||||
void OnStartGame();
|
void OnStartGame();
|
||||||
void OnPauseGame();
|
void OnPauseGame();
|
||||||
void OnStopGame();
|
void OnStopGame();
|
||||||
|
void OnMenuReportCompatibility();
|
||||||
/// Called whenever a user selects a game in the game list widget.
|
/// Called whenever a user selects a game in the game list widget.
|
||||||
void OnGameListLoadFile(QString game_path);
|
void OnGameListLoadFile(QString game_path);
|
||||||
void OnGameListOpenFolder(u64 program_id, GameListOpenTarget target);
|
void OnGameListOpenFolder(u64 program_id, GameListOpenTarget target);
|
||||||
|
|
|
@ -45,7 +45,7 @@
|
||||||
<x>0</x>
|
<x>0</x>
|
||||||
<y>0</y>
|
<y>0</y>
|
||||||
<width>1081</width>
|
<width>1081</width>
|
||||||
<height>19</height>
|
<height>21</height>
|
||||||
</rect>
|
</rect>
|
||||||
</property>
|
</property>
|
||||||
<widget class="QMenu" name="menu_File">
|
<widget class="QMenu" name="menu_File">
|
||||||
|
@ -101,6 +101,8 @@
|
||||||
<property name="title">
|
<property name="title">
|
||||||
<string>&Help</string>
|
<string>&Help</string>
|
||||||
</property>
|
</property>
|
||||||
|
<addaction name="action_Report_Compatibility"/>
|
||||||
|
<addaction name="separator"/>
|
||||||
<addaction name="action_About"/>
|
<addaction name="action_About"/>
|
||||||
</widget>
|
</widget>
|
||||||
<addaction name="menu_File"/>
|
<addaction name="menu_File"/>
|
||||||
|
@ -239,6 +241,18 @@
|
||||||
<string>Restart</string>
|
<string>Restart</string>
|
||||||
</property>
|
</property>
|
||||||
</action>
|
</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>
|
</widget>
|
||||||
<resources/>
|
<resources/>
|
||||||
|
<connections/>
|
||||||
</ui>
|
</ui>
|
||||||
|
|
|
@ -39,6 +39,9 @@ struct Values {
|
||||||
bool confirm_before_closing;
|
bool confirm_before_closing;
|
||||||
bool first_start;
|
bool first_start;
|
||||||
|
|
||||||
|
// Discord RPC
|
||||||
|
bool enable_discord_presence;
|
||||||
|
|
||||||
QString roms_path;
|
QString roms_path;
|
||||||
QString symbols_path;
|
QString symbols_path;
|
||||||
QString gamedir;
|
QString gamedir;
|
||||||
|
|
|
@ -138,6 +138,14 @@ void Config::ReadValues() {
|
||||||
Settings::values.use_gdbstub = sdl2_config->GetBoolean("Debugging", "use_gdbstub", false);
|
Settings::values.use_gdbstub = sdl2_config->GetBoolean("Debugging", "use_gdbstub", false);
|
||||||
Settings::values.gdbstub_port =
|
Settings::values.gdbstub_port =
|
||||||
static_cast<u16>(sdl2_config->GetInteger("Debugging", "gdbstub_port", 24689));
|
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() {
|
void Config::Reload() {
|
||||||
|
|
|
@ -202,10 +202,8 @@ gdbstub_port=24689
|
||||||
# Whether or not to enable telemetry
|
# Whether or not to enable telemetry
|
||||||
# 0: No, 1 (default): Yes
|
# 0: No, 1 (default): Yes
|
||||||
enable_telemetry =
|
enable_telemetry =
|
||||||
# Endpoint URL for submitting telemetry data
|
# URL for Web API
|
||||||
telemetry_endpoint_url =
|
web_api_url = https://api.yuzu-emu.org
|
||||||
# Endpoint URL to verify the username and token
|
|
||||||
verify_endpoint_url =
|
|
||||||
# Username and token for yuzu Web Service
|
# Username and token for yuzu Web Service
|
||||||
# See https://services.citra-emu.org/ for more info
|
# See https://services.citra-emu.org/ for more info
|
||||||
yuzu_username =
|
yuzu_username =
|
||||||
|
|
|
@ -10,6 +10,7 @@
|
||||||
#include <fmt/ostream.h>
|
#include <fmt/ostream.h>
|
||||||
|
|
||||||
#include "common/common_paths.h"
|
#include "common/common_paths.h"
|
||||||
|
#include "common/detached_tasks.h"
|
||||||
#include "common/file_util.h"
|
#include "common/file_util.h"
|
||||||
#include "common/logging/backend.h"
|
#include "common/logging/backend.h"
|
||||||
#include "common/logging/filter.h"
|
#include "common/logging/filter.h"
|
||||||
|
@ -78,6 +79,7 @@ static void InitializeLogging() {
|
||||||
|
|
||||||
/// Application entry point
|
/// Application entry point
|
||||||
int main(int argc, char** argv) {
|
int main(int argc, char** argv) {
|
||||||
|
Common::DetachedTasks detached_tasks;
|
||||||
Config config;
|
Config config;
|
||||||
|
|
||||||
int option_index = 0;
|
int option_index = 0;
|
||||||
|
@ -213,5 +215,6 @@ int main(int argc, char** argv) {
|
||||||
system.RunLoop();
|
system.RunLoop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
detached_tasks.WaitForAllTasks();
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue