mirror of
				https://github.com/PabloMK7/citra.git
				synced 2025-10-30 21:30:04 +00:00 
			
		
		
		
	Merge pull request #4211 from wwylele/web-cleanup
web_service: stop using std::future + callback style async
This commit is contained in:
		
						commit
						4a30a502a0
					
				
					 23 changed files with 333 additions and 458 deletions
				
			
		|  | @ -26,6 +26,7 @@ | ||||||
| #include "citra/config.h" | #include "citra/config.h" | ||||||
| #include "citra/emu_window/emu_window_sdl2.h" | #include "citra/emu_window/emu_window_sdl2.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" | ||||||
|  | @ -129,6 +130,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; | ||||||
|     bool use_gdbstub = Settings::values.use_gdbstub; |     bool use_gdbstub = Settings::values.use_gdbstub; | ||||||
|  | @ -344,5 +346,6 @@ int main(int argc, char** argv) { | ||||||
| 
 | 
 | ||||||
|     Core::Movie::GetInstance().Shutdown(); |     Core::Movie::GetInstance().Shutdown(); | ||||||
| 
 | 
 | ||||||
|  |     detached_tasks.WaitForAllTasks(); | ||||||
|     return 0; |     return 0; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -4,6 +4,7 @@ | ||||||
| 
 | 
 | ||||||
| #include <QIcon> | #include <QIcon> | ||||||
| #include <QMessageBox> | #include <QMessageBox> | ||||||
|  | #include <QtConcurrent/QtConcurrentRun> | ||||||
| #include "citra_qt/configuration/configure_web.h" | #include "citra_qt/configuration/configure_web.h" | ||||||
| #include "citra_qt/ui_settings.h" | #include "citra_qt/ui_settings.h" | ||||||
| #include "core/settings.h" | #include "core/settings.h" | ||||||
|  | @ -16,7 +17,7 @@ ConfigureWeb::ConfigureWeb(QWidget* parent) | ||||||
|     connect(ui->button_regenerate_telemetry_id, &QPushButton::clicked, this, |     connect(ui->button_regenerate_telemetry_id, &QPushButton::clicked, this, | ||||||
|             &ConfigureWeb::RefreshTelemetryID); |             &ConfigureWeb::RefreshTelemetryID); | ||||||
|     connect(ui->button_verify_login, &QPushButton::clicked, this, &ConfigureWeb::VerifyLogin); |     connect(ui->button_verify_login, &QPushButton::clicked, this, &ConfigureWeb::VerifyLogin); | ||||||
|     connect(this, &ConfigureWeb::LoginVerified, this, &ConfigureWeb::OnLoginVerified); |     connect(&verify_watcher, &QFutureWatcher<bool>::finished, this, &ConfigureWeb::OnLoginVerified); | ||||||
| 
 | 
 | ||||||
| #ifndef USE_DISCORD_PRESENCE | #ifndef USE_DISCORD_PRESENCE | ||||||
|     ui->discord_group->setVisible(false); |     ui->discord_group->setVisible(false); | ||||||
|  | @ -89,17 +90,19 @@ void ConfigureWeb::OnLoginChanged() { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| void ConfigureWeb::VerifyLogin() { | void ConfigureWeb::VerifyLogin() { | ||||||
|     verified = |  | ||||||
|         Core::VerifyLogin(ui->edit_username->text().toStdString(), |  | ||||||
|                           ui->edit_token->text().toStdString(), [&]() { emit LoginVerified(); }); |  | ||||||
|     ui->button_verify_login->setDisabled(true); |     ui->button_verify_login->setDisabled(true); | ||||||
|     ui->button_verify_login->setText(tr("Verifying")); |     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() { | void ConfigureWeb::OnLoginVerified() { | ||||||
|     ui->button_verify_login->setEnabled(true); |     ui->button_verify_login->setEnabled(true); | ||||||
|     ui->button_verify_login->setText(tr("Verify")); |     ui->button_verify_login->setText(tr("Verify")); | ||||||
|     if (verified.get()) { |     if (verify_watcher.result()) { | ||||||
|         user_verified = true; |         user_verified = true; | ||||||
|         ui->label_username_verified->setPixmap(QIcon::fromTheme("checked").pixmap(16)); |         ui->label_username_verified->setPixmap(QIcon::fromTheme("checked").pixmap(16)); | ||||||
|         ui->label_token_verified->setPixmap(QIcon::fromTheme("checked").pixmap(16)); |         ui->label_token_verified->setPixmap(QIcon::fromTheme("checked").pixmap(16)); | ||||||
|  |  | ||||||
|  | @ -4,8 +4,8 @@ | ||||||
| 
 | 
 | ||||||
| #pragma once | #pragma once | ||||||
| 
 | 
 | ||||||
| #include <future> |  | ||||||
| #include <memory> | #include <memory> | ||||||
|  | #include <QFutureWatcher> | ||||||
| #include <QWidget> | #include <QWidget> | ||||||
| 
 | 
 | ||||||
| namespace Ui { | namespace Ui { | ||||||
|  | @ -28,14 +28,11 @@ public slots: | ||||||
|     void VerifyLogin(); |     void VerifyLogin(); | ||||||
|     void OnLoginVerified(); |     void OnLoginVerified(); | ||||||
| 
 | 
 | ||||||
| signals: |  | ||||||
|     void LoginVerified(); |  | ||||||
| 
 |  | ||||||
| private: | private: | ||||||
|     void setConfiguration(); |     void setConfiguration(); | ||||||
| 
 | 
 | ||||||
|     bool user_verified = true; |     bool user_verified = true; | ||||||
|     std::future<bool> verified; |     QFutureWatcher<bool> verify_watcher; | ||||||
| 
 | 
 | ||||||
|     std::unique_ptr<Ui::ConfigureWeb> ui; |     std::unique_ptr<Ui::ConfigureWeb> ui; | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | @ -43,6 +43,7 @@ | ||||||
| #include "citra_qt/updater/updater.h" | #include "citra_qt/updater/updater.h" | ||||||
| #include "citra_qt/util/clickable_label.h" | #include "citra_qt/util/clickable_label.h" | ||||||
| #include "common/common_paths.h" | #include "common/common_paths.h" | ||||||
|  | #include "common/detached_tasks.h" | ||||||
| #include "common/logging/backend.h" | #include "common/logging/backend.h" | ||||||
| #include "common/logging/filter.h" | #include "common/logging/filter.h" | ||||||
| #include "common/logging/log.h" | #include "common/logging/log.h" | ||||||
|  | @ -1666,6 +1667,7 @@ void GMainWindow::SetDiscordEnabled(bool state) { | ||||||
| #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(); }); | ||||||
| 
 | 
 | ||||||
|  | @ -1691,5 +1693,7 @@ int main(int argc, char* argv[]) { | ||||||
|     Frontend::RegisterSoftwareKeyboard(std::make_shared<QtKeyboard>(main_window)); |     Frontend::RegisterSoftwareKeyboard(std::make_shared<QtKeyboard>(main_window)); | ||||||
| 
 | 
 | ||||||
|     main_window.show(); |     main_window.show(); | ||||||
|     return app.exec(); |     int result = app.exec(); | ||||||
|  |     detached_tasks.WaitForAllTasks(); | ||||||
|  |     return result; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -74,7 +74,8 @@ Lobby::Lobby(QWidget* parent, QStandardItemModel* list, | ||||||
|     connect(ui->room_list, &QTreeView::clicked, this, &Lobby::OnExpandRoom); |     connect(ui->room_list, &QTreeView::clicked, this, &Lobby::OnExpandRoom); | ||||||
| 
 | 
 | ||||||
|     // Actions
 |     // Actions
 | ||||||
|     connect(this, &Lobby::LobbyRefreshed, this, &Lobby::OnRefreshLobby); |     connect(&room_list_watcher, &QFutureWatcher<AnnounceMultiplayerRoom::RoomList>::finished, this, | ||||||
|  |             &Lobby::OnRefreshLobby); | ||||||
| 
 | 
 | ||||||
|     // manually start a refresh when the window is opening
 |     // manually start a refresh when the window is opening
 | ||||||
|     // TODO(jroweboy): if this refresh is slow for people with bad internet, then don't do it as
 |     // TODO(jroweboy): if this refresh is slow for people with bad internet, then don't do it as
 | ||||||
|  | @ -161,16 +162,17 @@ void Lobby::ResetModel() { | ||||||
| void Lobby::RefreshLobby() { | void Lobby::RefreshLobby() { | ||||||
|     if (auto session = announce_multiplayer_session.lock()) { |     if (auto session = announce_multiplayer_session.lock()) { | ||||||
|         ResetModel(); |         ResetModel(); | ||||||
|         room_list_future = session->GetRoomList([&]() { emit LobbyRefreshed(); }); |  | ||||||
|         ui->refresh_list->setEnabled(false); |         ui->refresh_list->setEnabled(false); | ||||||
|         ui->refresh_list->setText(tr("Refreshing")); |         ui->refresh_list->setText(tr("Refreshing")); | ||||||
|  |         room_list_watcher.setFuture( | ||||||
|  |             QtConcurrent::run([session]() { return session->GetRoomList(); })); | ||||||
|     } else { |     } else { | ||||||
|         // TODO(jroweboy): Display an error box about announce couldn't be started
 |         // TODO(jroweboy): Display an error box about announce couldn't be started
 | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| void Lobby::OnRefreshLobby() { | void Lobby::OnRefreshLobby() { | ||||||
|     AnnounceMultiplayerRoom::RoomList new_room_list = room_list_future.get(); |     AnnounceMultiplayerRoom::RoomList new_room_list = room_list_watcher.result(); | ||||||
|     for (auto room : new_room_list) { |     for (auto room : new_room_list) { | ||||||
|         // find the icon for the game if this person owns that game.
 |         // find the icon for the game if this person owns that game.
 | ||||||
|         QPixmap smdh_icon; |         QPixmap smdh_icon; | ||||||
|  |  | ||||||
|  | @ -4,7 +4,6 @@ | ||||||
| 
 | 
 | ||||||
| #pragma once | #pragma once | ||||||
| 
 | 
 | ||||||
| #include <future> |  | ||||||
| #include <memory> | #include <memory> | ||||||
| #include <QDialog> | #include <QDialog> | ||||||
| #include <QFutureWatcher> | #include <QFutureWatcher> | ||||||
|  | @ -61,11 +60,6 @@ private slots: | ||||||
|     void OnJoinRoom(const QModelIndex&); |     void OnJoinRoom(const QModelIndex&); | ||||||
| 
 | 
 | ||||||
| signals: | signals: | ||||||
|     /**
 |  | ||||||
|      * Signalled when the latest lobby data is retrieved. |  | ||||||
|      */ |  | ||||||
|     void LobbyRefreshed(); |  | ||||||
| 
 |  | ||||||
|     void StateChanged(const Network::RoomMember::State&); |     void StateChanged(const Network::RoomMember::State&); | ||||||
| 
 | 
 | ||||||
| private: | private: | ||||||
|  | @ -84,7 +78,7 @@ private: | ||||||
|     QStandardItemModel* game_list; |     QStandardItemModel* game_list; | ||||||
|     LobbyFilterProxyModel* proxy; |     LobbyFilterProxyModel* proxy; | ||||||
| 
 | 
 | ||||||
|     std::future<AnnounceMultiplayerRoom::RoomList> room_list_future; |     QFutureWatcher<AnnounceMultiplayerRoom::RoomList> room_list_watcher; | ||||||
|     std::weak_ptr<Core::AnnounceMultiplayerSession> announce_multiplayer_session; |     std::weak_ptr<Core::AnnounceMultiplayerSession> announce_multiplayer_session; | ||||||
|     std::unique_ptr<Ui::Lobby> ui; |     std::unique_ptr<Ui::Lobby> ui; | ||||||
|     QFutureWatcher<void>* watcher; |     QFutureWatcher<void>* watcher; | ||||||
|  |  | ||||||
|  | @ -42,6 +42,8 @@ add_library(common STATIC | ||||||
|     alignment.h |     alignment.h | ||||||
|     announce_multiplayer_room.h |     announce_multiplayer_room.h | ||||||
|     assert.h |     assert.h | ||||||
|  |     detached_tasks.cpp | ||||||
|  |     detached_tasks.h | ||||||
|     bit_field.h |     bit_field.h | ||||||
|     bit_set.h |     bit_set.h | ||||||
|     chunk_file.h |     chunk_file.h | ||||||
|  |  | ||||||
|  | @ -6,7 +6,6 @@ | ||||||
| 
 | 
 | ||||||
| #include <array> | #include <array> | ||||||
| #include <functional> | #include <functional> | ||||||
| #include <future> |  | ||||||
| #include <string> | #include <string> | ||||||
| #include <vector> | #include <vector> | ||||||
| #include "common/common_types.h" | #include "common/common_types.h" | ||||||
|  | @ -90,7 +89,7 @@ public: | ||||||
|      * Send the data to the announce service |      * Send the data to the announce service | ||||||
|      * @result The result of the announce attempt |      * @result The result of the announce attempt | ||||||
|      */ |      */ | ||||||
|     virtual std::future<Common::WebResult> Announce() = 0; |     virtual Common::WebResult Announce() = 0; | ||||||
| 
 | 
 | ||||||
|     /**
 |     /**
 | ||||||
|      * Empties the stored players |      * Empties the stored players | ||||||
|  | @ -99,11 +98,9 @@ public: | ||||||
| 
 | 
 | ||||||
|     /**
 |     /**
 | ||||||
|      * Get the room information from the announce service |      * Get the room information from the announce service | ||||||
|      * @param func a function that gets exectued when the get finished. |  | ||||||
|      * Can be used as a callback |  | ||||||
|      * @result A list of all rooms the announce service has |      * @result A list of all rooms the announce service has | ||||||
|      */ |      */ | ||||||
|     virtual std::future<RoomList> GetRoomList(std::function<void()> func) = 0; |     virtual RoomList GetRoomList() = 0; | ||||||
| 
 | 
 | ||||||
|     /**
 |     /**
 | ||||||
|      * Sends a delete message to the announce service |      * Sends a delete message to the announce service | ||||||
|  | @ -124,18 +121,12 @@ public: | ||||||
|                             const u64 /*preferred_game_id*/) override {} |                             const u64 /*preferred_game_id*/) override {} | ||||||
|     void AddPlayer(const std::string& /*nickname*/, const MacAddress& /*mac_address*/, |     void AddPlayer(const std::string& /*nickname*/, const MacAddress& /*mac_address*/, | ||||||
|                    const u64 /*game_id*/, const std::string& /*game_name*/) override {} |                    const u64 /*game_id*/, const std::string& /*game_name*/) override {} | ||||||
|     std::future<Common::WebResult> Announce() override { |     Common::WebResult Announce() override { | ||||||
|         return std::async(std::launch::deferred, []() { |         return Common::WebResult{Common::WebResult::Code::NoWebservice, "WebService is missing"}; | ||||||
|             return Common::WebResult{Common::WebResult::Code::NoWebservice, |  | ||||||
|                                      "WebService is missing"}; |  | ||||||
|         }); |  | ||||||
|     } |     } | ||||||
|     void ClearPlayers() override {} |     void ClearPlayers() override {} | ||||||
|     std::future<RoomList> GetRoomList(std::function<void()> func) override { |     RoomList GetRoomList() override { | ||||||
|         return std::async(std::launch::deferred, [func]() { |  | ||||||
|             func(); |  | ||||||
|         return RoomList{}; |         return RoomList{}; | ||||||
|         }); |  | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     void Delete() override {} |     void Delete() override {} | ||||||
|  |  | ||||||
							
								
								
									
										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
 | ||||||
|  | @ -21,7 +21,7 @@ static constexpr std::chrono::seconds announce_time_interval(15); | ||||||
| 
 | 
 | ||||||
| AnnounceMultiplayerSession::AnnounceMultiplayerSession() { | AnnounceMultiplayerSession::AnnounceMultiplayerSession() { | ||||||
| #ifdef ENABLE_WEB_SERVICE | #ifdef ENABLE_WEB_SERVICE | ||||||
|     backend = std::make_unique<WebService::RoomJson>(Settings::values.web_api_url + "/lobby", |     backend = std::make_unique<WebService::RoomJson>(Settings::values.web_api_url, | ||||||
|                                                      Settings::values.citra_username, |                                                      Settings::values.citra_username, | ||||||
|                                                      Settings::values.citra_token); |                                                      Settings::values.citra_token); | ||||||
| #else | #else | ||||||
|  | @ -87,9 +87,7 @@ void AnnounceMultiplayerSession::AnnounceMultiplayerLoop() { | ||||||
|             backend->AddPlayer(member.nickname, member.mac_address, member.game_info.id, |             backend->AddPlayer(member.nickname, member.mac_address, member.game_info.id, | ||||||
|                                member.game_info.name); |                                member.game_info.name); | ||||||
|         } |         } | ||||||
|         future = backend->Announce(); |         Common::WebResult result = backend->Announce(); | ||||||
|         if (future.valid()) { |  | ||||||
|             Common::WebResult result = future.get(); |  | ||||||
|         if (result.result_code != Common::WebResult::Code::Success) { |         if (result.result_code != Common::WebResult::Code::Success) { | ||||||
|             std::lock_guard<std::mutex> lock(callback_mutex); |             std::lock_guard<std::mutex> lock(callback_mutex); | ||||||
|             for (auto callback : error_callbacks) { |             for (auto callback : error_callbacks) { | ||||||
|  | @ -97,12 +95,10 @@ void AnnounceMultiplayerSession::AnnounceMultiplayerLoop() { | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|     } |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| std::future<AnnounceMultiplayerRoom::RoomList> AnnounceMultiplayerSession::GetRoomList( | AnnounceMultiplayerRoom::RoomList AnnounceMultiplayerSession::GetRoomList() { | ||||||
|     std::function<void()> func) { |     return backend->GetRoomList(); | ||||||
|     return backend->GetRoomList(func); |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| } // namespace Core
 | } // namespace Core
 | ||||||
|  |  | ||||||
|  | @ -54,7 +54,7 @@ public: | ||||||
|      * @param func A function that gets executed when the async get finished, e.g. a signal |      * @param func A function that gets executed when the async get finished, e.g. a signal | ||||||
|      * @return a list of rooms received from the web service |      * @return a list of rooms received from the web service | ||||||
|      */ |      */ | ||||||
|     std::future<AnnounceMultiplayerRoom::RoomList> GetRoomList(std::function<void()> func); |     AnnounceMultiplayerRoom::RoomList GetRoomList(); | ||||||
| 
 | 
 | ||||||
| private: | private: | ||||||
|     Common::Event shutdown_event; |     Common::Event shutdown_event; | ||||||
|  |  | ||||||
|  | @ -82,23 +82,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.web_api_url + "/profile", |     return WebService::VerifyLogin(Settings::values.web_api_url, username, token); | ||||||
|                                    func); |  | ||||||
| #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.web_api_url + "/telemetry", Settings::values.citra_username, |                                                               Settings::values.citra_username, | ||||||
|                                                               Settings::values.citra_token); |                                                               Settings::values.citra_token); | ||||||
|     } else { |     } else { | ||||||
|         backend = std::make_unique<Telemetry::NullVisitor>(); |         backend = std::make_unique<Telemetry::NullVisitor>(); | ||||||
|  |  | ||||||
|  | @ -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
 | ||||||
|  |  | ||||||
|  | @ -25,6 +25,7 @@ | ||||||
| #endif | #endif | ||||||
| 
 | 
 | ||||||
| #include "common/common_types.h" | #include "common/common_types.h" | ||||||
|  | #include "common/detached_tasks.h" | ||||||
| #include "common/scm_rev.h" | #include "common/scm_rev.h" | ||||||
| #include "core/announce_multiplayer_session.h" | #include "core/announce_multiplayer_session.h" | ||||||
| #include "core/core.h" | #include "core/core.h" | ||||||
|  | @ -54,6 +55,7 @@ static void PrintVersion() { | ||||||
| 
 | 
 | ||||||
| /// Application entry point
 | /// Application entry point
 | ||||||
| int main(int argc, char** argv) { | int main(int argc, char** argv) { | ||||||
|  |     Common::DetachedTasks detached_tasks; | ||||||
|     int option_index = 0; |     int option_index = 0; | ||||||
|     char* endarg; |     char* endarg; | ||||||
| 
 | 
 | ||||||
|  | @ -204,5 +206,6 @@ int main(int argc, char** argv) { | ||||||
|         room->Destroy(); |         room->Destroy(); | ||||||
|     } |     } | ||||||
|     Network::Shutdown(); |     Network::Shutdown(); | ||||||
|  |     detached_tasks.WaitForAllTasks(); | ||||||
|     return 0; |     return 0; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -3,6 +3,7 @@ | ||||||
| // Refer to the license.txt file included.
 | // Refer to the license.txt file included.
 | ||||||
| 
 | 
 | ||||||
| #include <future> | #include <future> | ||||||
|  | #include "common/detached_tasks.h" | ||||||
| #include "common/logging/log.h" | #include "common/logging/log.h" | ||||||
| #include "web_service/announce_room_json.h" | #include "web_service/announce_room_json.h" | ||||||
| #include "web_service/json.h" | #include "web_service/json.h" | ||||||
|  | @ -82,30 +83,31 @@ void RoomJson::AddPlayer(const std::string& nickname, | ||||||
|     room.members.push_back(member); |     room.members.push_back(member); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| std::future<Common::WebResult> RoomJson::Announce() { | Common::WebResult RoomJson::Announce() { | ||||||
|     nlohmann::json json = room; |     nlohmann::json json = room; | ||||||
|     return PostJson(endpoint_url, json.dump(), false); |     return client.PostJson("/lobby", json.dump(), false); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| void RoomJson::ClearPlayers() { | void RoomJson::ClearPlayers() { | ||||||
|     room.members.clear(); |     room.members.clear(); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| std::future<AnnounceMultiplayerRoom::RoomList> RoomJson::GetRoomList(std::function<void()> func) { | AnnounceMultiplayerRoom::RoomList RoomJson::GetRoomList() { | ||||||
|     auto DeSerialize = [func](const std::string& reply) -> AnnounceMultiplayerRoom::RoomList { |     auto reply = client.GetJson("/lobby", true).returned_data; | ||||||
|         nlohmann::json json = nlohmann::json::parse(reply); |     if (reply.empty()) { | ||||||
|         AnnounceMultiplayerRoom::RoomList room_list = |         return {}; | ||||||
|             json.at("rooms").get<AnnounceMultiplayerRoom::RoomList>(); |     } | ||||||
|         func(); |     return nlohmann::json::parse(reply).at("rooms").get<AnnounceMultiplayerRoom::RoomList>(); | ||||||
|         return room_list; |  | ||||||
|     }; |  | ||||||
|     return GetJson<AnnounceMultiplayerRoom::RoomList>(DeSerialize, endpoint_url, true); |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| void RoomJson::Delete() { | void RoomJson::Delete() { | ||||||
|     nlohmann::json json; |     nlohmann::json json; | ||||||
|     json["id"] = room.UID; |     json["id"] = room.UID; | ||||||
|     DeleteJson(endpoint_url, json.dump()); |     Common::DetachedTasks::AddTask( | ||||||
|  |         [host{this->host}, username{this->username}, token{this->token}, content{json.dump()}]() { | ||||||
|  |             // create a new client here because the this->client might be destroyed.
 | ||||||
|  |             Client{host, username, token}.DeleteJson("/lobby", content, false); | ||||||
|  |         }); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| } // namespace WebService
 | } // namespace WebService
 | ||||||
|  |  | ||||||
|  | @ -5,9 +5,9 @@ | ||||||
| #pragma once | #pragma once | ||||||
| 
 | 
 | ||||||
| #include <functional> | #include <functional> | ||||||
| #include <future> |  | ||||||
| #include <string> | #include <string> | ||||||
| #include "common/announce_multiplayer_room.h" | #include "common/announce_multiplayer_room.h" | ||||||
|  | #include "web_service/web_backend.h" | ||||||
| 
 | 
 | ||||||
| namespace WebService { | namespace WebService { | ||||||
| 
 | 
 | ||||||
|  | @ -17,8 +17,8 @@ namespace WebService { | ||||||
|  */ |  */ | ||||||
| class RoomJson : public AnnounceMultiplayerRoom::Backend { | class RoomJson : public AnnounceMultiplayerRoom::Backend { | ||||||
| public: | public: | ||||||
|     RoomJson(const std::string& endpoint_url, const std::string& username, const std::string& token) |     RoomJson(const std::string& host, const std::string& username, const std::string& token) | ||||||
|         : endpoint_url(endpoint_url), username(username), token(token) {} |         : client(host, username, token), host(host), username(username), token(token) {} | ||||||
|     ~RoomJson() = default; |     ~RoomJson() = default; | ||||||
|     void SetRoomInformation(const std::string& uid, const std::string& name, const u16 port, |     void SetRoomInformation(const std::string& uid, const std::string& name, const u16 port, | ||||||
|                             const u32 max_player, const u32 net_version, const bool has_password, |                             const u32 max_player, const u32 net_version, const bool has_password, | ||||||
|  | @ -27,14 +27,15 @@ public: | ||||||
|     void AddPlayer(const std::string& nickname, |     void AddPlayer(const std::string& nickname, | ||||||
|                    const AnnounceMultiplayerRoom::MacAddress& mac_address, const u64 game_id, |                    const AnnounceMultiplayerRoom::MacAddress& mac_address, const u64 game_id, | ||||||
|                    const std::string& game_name) override; |                    const std::string& game_name) override; | ||||||
|     std::future<Common::WebResult> Announce() override; |     Common::WebResult Announce() override; | ||||||
|     void ClearPlayers() override; |     void ClearPlayers() override; | ||||||
|     std::future<AnnounceMultiplayerRoom::RoomList> GetRoomList(std::function<void()> func) override; |     AnnounceMultiplayerRoom::RoomList GetRoomList() override; | ||||||
|     void Delete() override; |     void Delete() override; | ||||||
| 
 | 
 | ||||||
| private: | private: | ||||||
|     AnnounceMultiplayerRoom::Room room; |     AnnounceMultiplayerRoom::Room room; | ||||||
|     std::string endpoint_url; |     Client client; | ||||||
|  |     std::string host; | ||||||
|     std::string username; |     std::string username; | ||||||
|     std::string token; |     std::string token; | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | @ -2,7 +2,9 @@ | ||||||
| // Licensed under GPLv2 or any later version
 | // Licensed under GPLv2 or any later version
 | ||||||
| // Refer to the license.txt file included.
 | // Refer to the license.txt file included.
 | ||||||
| 
 | 
 | ||||||
|  | #include <thread> | ||||||
| #include "common/assert.h" | #include "common/assert.h" | ||||||
|  | #include "common/detached_tasks.h" | ||||||
| #include "web_service/telemetry_json.h" | #include "web_service/telemetry_json.h" | ||||||
| #include "web_service/web_backend.h" | #include "web_service/web_backend.h" | ||||||
| 
 | 
 | ||||||
|  | @ -81,8 +83,12 @@ void TelemetryJson::Complete() { | ||||||
|     SerializeSection(Telemetry::FieldType::UserConfig, "UserConfig"); |     SerializeSection(Telemetry::FieldType::UserConfig, "UserConfig"); | ||||||
|     SerializeSection(Telemetry::FieldType::UserSystem, "UserSystem"); |     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
 |     // Send the telemetry async but don't handle the errors since they were written to the log
 | ||||||
|     future = PostJson(endpoint_url, TopSection().dump(), true); |     Common::DetachedTasks::AddTask( | ||||||
|  |         [host{this->host}, username{this->username}, token{this->token}, content]() { | ||||||
|  |             Client{host, username, token}.PostJson("/telemetry", content, true); | ||||||
|  |         }); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| } // namespace WebService
 | } // namespace WebService
 | ||||||
|  |  | ||||||
|  | @ -5,7 +5,6 @@ | ||||||
| #pragma once | #pragma once | ||||||
| 
 | 
 | ||||||
| #include <array> | #include <array> | ||||||
| #include <future> |  | ||||||
| #include <string> | #include <string> | ||||||
| #include "common/announce_multiplayer_room.h" | #include "common/announce_multiplayer_room.h" | ||||||
| #include "common/telemetry.h" | #include "common/telemetry.h" | ||||||
|  | @ -19,9 +18,8 @@ namespace WebService { | ||||||
|  */ |  */ | ||||||
| class TelemetryJson : public Telemetry::VisitorInterface { | class TelemetryJson : public Telemetry::VisitorInterface { | ||||||
| public: | public: | ||||||
|     TelemetryJson(const std::string& endpoint_url, const std::string& username, |     TelemetryJson(const std::string& host, const std::string& username, const std::string& token) | ||||||
|                   const std::string& token) |         : host(host), username(username), token(token) {} | ||||||
|         : endpoint_url(endpoint_url), username(username), token(token) {} |  | ||||||
|     ~TelemetryJson() = default; |     ~TelemetryJson() = default; | ||||||
| 
 | 
 | ||||||
|     void Visit(const Telemetry::Field<bool>& field) override; |     void Visit(const Telemetry::Field<bool>& field) override; | ||||||
|  | @ -53,10 +51,9 @@ private: | ||||||
| 
 | 
 | ||||||
|     nlohmann::json output; |     nlohmann::json output; | ||||||
|     std::array<nlohmann::json, 7> sections; |     std::array<nlohmann::json, 7> sections; | ||||||
|     std::string endpoint_url; |     std::string host; | ||||||
|     std::string username; |     std::string username; | ||||||
|     std::string token; |     std::string token; | ||||||
|     std::future<Common::WebResult> future; |  | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| } // namespace WebService
 | } // namespace WebService
 | ||||||
|  |  | ||||||
|  | @ -8,15 +8,12 @@ | ||||||
| 
 | 
 | ||||||
| namespace WebService { | namespace WebService { | ||||||
| 
 | 
 | ||||||
| std::future<bool> VerifyLogin(std::string& username, std::string& token, | bool VerifyLogin(const std::string& host, const std::string& username, const std::string& token) { | ||||||
|                               const std::string& endpoint_url, std::function<void()> func) { |     Client client(host, username, token); | ||||||
|     auto get_func = [func, username](const std::string& reply) -> bool { |     auto reply = client.GetJson("/profile", false).returned_data; | ||||||
|         func(); |  | ||||||
| 
 |  | ||||||
|     if (reply.empty()) { |     if (reply.empty()) { | ||||||
|         return false; |         return false; | ||||||
|     } |     } | ||||||
| 
 |  | ||||||
|     nlohmann::json json = nlohmann::json::parse(reply); |     nlohmann::json json = nlohmann::json::parse(reply); | ||||||
|     const auto iter = json.find("username"); |     const auto iter = json.find("username"); | ||||||
| 
 | 
 | ||||||
|  | @ -25,9 +22,6 @@ std::future<bool> VerifyLogin(std::string& username, std::string& token, | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     return username == *iter; |     return username == *iter; | ||||||
|     }; |  | ||||||
|     UpdateCoreJWT(true, username, token); |  | ||||||
|     return GetJson<bool>(get_func, endpoint_url, false); |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| } // namespace WebService
 | } // namespace WebService
 | ||||||
|  |  | ||||||
|  | @ -12,13 +12,11 @@ namespace WebService { | ||||||
| 
 | 
 | ||||||
| /**
 | /**
 | ||||||
|  * Checks if username and token is valid |  * Checks if username and token is valid | ||||||
|  |  * @param host the web API URL | ||||||
|  * @param username Citra username to use for authentication. |  * @param username Citra username to use for authentication. | ||||||
|  * @param token Citra token to use for authentication. |  * @param token Citra token to use for authentication. | ||||||
|  * @param endpoint_url URL of the services.citra-emu.org endpoint. |  * @returns a bool indicating whether the verification succeeded | ||||||
|  * @param func A function that gets exectued when the verification is finished |  | ||||||
|  * @returns Future with bool indicating whether the verification succeeded |  | ||||||
|  */ |  */ | ||||||
| std::future<bool> VerifyLogin(std::string& username, std::string& token, | bool VerifyLogin(const std::string& host, const std::string& username, const std::string& token); | ||||||
|                               const std::string& endpoint_url, std::function<void()> func); |  | ||||||
| 
 | 
 | ||||||
| } // namespace WebService
 | } // namespace WebService
 | ||||||
|  |  | ||||||
|  | @ -20,334 +20,130 @@ constexpr int HTTPS_PORT = 443; | ||||||
| 
 | 
 | ||||||
| constexpr int TIMEOUT_SECONDS = 30; | constexpr int TIMEOUT_SECONDS = 30; | ||||||
| 
 | 
 | ||||||
| std::string UpdateCoreJWT(bool force_new_token, const std::string& username, | Client::JWTCache Client::jwt_cache{}; | ||||||
|                           const std::string& token) { | 
 | ||||||
|     static std::string jwt; | Client::Client(const std::string& host, const std::string& username, const std::string& token) | ||||||
|     if (jwt.empty() || force_new_token) { |     : host(host), username(username), token(token) { | ||||||
|         if (!username.empty() && !token.empty()) { |     std::lock_guard<std::mutex> lock(jwt_cache.mutex); | ||||||
|             std::future<Common::WebResult> future = |     if (username == jwt_cache.username && token == jwt_cache.token) { | ||||||
|                 PostJson(Settings::values.web_api_url + "/jwt/internal", username, token); |         jwt = jwt_cache.jwt; | ||||||
|             jwt = future.get().returned_data; |  | ||||||
|     } |     } | ||||||
|     } |  | ||||||
|     return jwt; |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| std::unique_ptr<httplib::Client> GetClientFor(const LUrlParser::clParseURL& parsedUrl) { | Common::WebResult Client::GenericJson(const std::string& method, const std::string& path, | ||||||
|     namespace hl = httplib; |                                       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; |         int port; | ||||||
| 
 |  | ||||||
|     std::unique_ptr<hl::Client> cli; |  | ||||||
| 
 |  | ||||||
|         if (parsedUrl.m_Scheme == "http") { |         if (parsedUrl.m_Scheme == "http") { | ||||||
|             if (!parsedUrl.GetPort(&port)) { |             if (!parsedUrl.GetPort(&port)) { | ||||||
|                 port = HTTP_PORT; |                 port = HTTP_PORT; | ||||||
|             } |             } | ||||||
|         return std::make_unique<hl::Client>(parsedUrl.m_Host.c_str(), port, TIMEOUT_SECONDS); |             cli = | ||||||
|  |                 std::make_unique<httplib::Client>(parsedUrl.m_Host.c_str(), port, TIMEOUT_SECONDS); | ||||||
|         } else if (parsedUrl.m_Scheme == "https") { |         } else if (parsedUrl.m_Scheme == "https") { | ||||||
|             if (!parsedUrl.GetPort(&port)) { |             if (!parsedUrl.GetPort(&port)) { | ||||||
|                 port = HTTPS_PORT; |                 port = HTTPS_PORT; | ||||||
|             } |             } | ||||||
|         return std::make_unique<hl::SSLClient>(parsedUrl.m_Host.c_str(), port, TIMEOUT_SECONDS); |             cli = std::make_unique<httplib::SSLClient>(parsedUrl.m_Host.c_str(), port, | ||||||
|  |                                                        TIMEOUT_SECONDS); | ||||||
|         } else { |         } else { | ||||||
|             LOG_ERROR(WebService, "Bad URL scheme {}", parsedUrl.m_Scheme); |             LOG_ERROR(WebService, "Bad URL scheme {}", parsedUrl.m_Scheme); | ||||||
|         return nullptr; |             return Common::WebResult{Common::WebResult::Code::InvalidURL, "Bad URL scheme"}; | ||||||
|  |         } | ||||||
|     } |     } | ||||||
| } |  | ||||||
| 
 |  | ||||||
| static Common::WebResult PostJsonAsyncFn(const std::string& url, |  | ||||||
|                                          const LUrlParser::clParseURL& parsed_url, |  | ||||||
|                                          const httplib::Headers& params, const std::string& data, |  | ||||||
|                                          bool is_jwt_requested) { |  | ||||||
|     static bool is_first_attempt = true; |  | ||||||
| 
 |  | ||||||
|     namespace hl = httplib; |  | ||||||
|     std::unique_ptr<hl::Client> cli = GetClientFor(parsed_url); |  | ||||||
| 
 |  | ||||||
|     if (cli == nullptr) { |     if (cli == nullptr) { | ||||||
|         return Common::WebResult{Common::WebResult::Code::InvalidURL, "URL is invalid"}; |         LOG_ERROR(WebService, "Invalid URL {}", host + path); | ||||||
|  |         return Common::WebResult{Common::WebResult::Code::InvalidURL, "Invalid URL"}; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     hl::Request request; |     httplib::Headers params; | ||||||
|     request.method = "POST"; |     if (!jwt.empty()) { | ||||||
|     request.path = "/" + parsed_url.m_Path; |         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.headers = params; | ||||||
|     request.body = data; |     request.body = data; | ||||||
| 
 | 
 | ||||||
|     hl::Response response; |     httplib::Response response; | ||||||
| 
 | 
 | ||||||
|     if (!cli->send(request, response)) { |     if (!cli->send(request, response)) { | ||||||
|         LOG_ERROR(WebService, "POST to {} returned null", url); |         LOG_ERROR(WebService, "{} to {} returned null", method, host + path); | ||||||
|         return Common::WebResult{Common::WebResult::Code::LibError, "Null response"}; |         return Common::WebResult{Common::WebResult::Code::LibError, "Null response"}; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     if (response.status >= 400) { |     if (response.status >= 400) { | ||||||
|         LOG_ERROR(WebService, "POST to {} returned error status code: {}", url, response.status); |         LOG_ERROR(WebService, "{} to {} returned error status code: {}", method, host + path, | ||||||
|         if (response.status == 401 && !is_jwt_requested && is_first_attempt) { |                   response.status); | ||||||
|             LOG_WARNING(WebService, "Requesting new JWT"); |  | ||||||
|             UpdateCoreJWT(true, Settings::values.citra_username, Settings::values.citra_token); |  | ||||||
|             is_first_attempt = false; |  | ||||||
|             PostJsonAsyncFn(url, parsed_url, params, data, is_jwt_requested); |  | ||||||
|             is_first_attempt = true; |  | ||||||
|         } |  | ||||||
|         return Common::WebResult{Common::WebResult::Code::HttpError, |         return Common::WebResult{Common::WebResult::Code::HttpError, | ||||||
|                                  std::to_string(response.status)}; |                                  std::to_string(response.status)}; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     auto content_type = response.headers.find("content-type"); |     auto content_type = response.headers.find("content-type"); | ||||||
| 
 | 
 | ||||||
|     if (content_type == response.headers.end() || |     if (content_type == response.headers.end()) { | ||||||
|         (content_type->second.find("application/json") == std::string::npos && |         LOG_ERROR(WebService, "{} to {} returned no content", method, host + path); | ||||||
|          content_type->second.find("text/html; charset=utf-8") == std::string::npos)) { |  | ||||||
|         LOG_ERROR(WebService, "POST to {} returned wrong content: {}", url, content_type->second); |  | ||||||
|         return Common::WebResult{Common::WebResult::Code::WrongContent, ""}; |         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}; |     return Common::WebResult{Common::WebResult::Code::Success, "", response.body}; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| std::future<Common::WebResult> PostJson(const std::string& url, const std::string& data, | void Client::UpdateJWT() { | ||||||
|                                         bool allow_anonymous) { |     if (!username.empty() && !token.empty()) { | ||||||
| 
 |         auto result = GenericJson("POST", "/jwt/internal", "", "", username, token); | ||||||
|     using lup = LUrlParser::clParseURL; |         if (result.result_code != Common::WebResult::Code::Success) { | ||||||
|     namespace hl = httplib; |             LOG_ERROR(WebService, "UpdateJWT failed"); | ||||||
| 
 |  | ||||||
|     lup parsedUrl = lup::ParseURL(url); |  | ||||||
| 
 |  | ||||||
|     if (url.empty() || !parsedUrl.IsValid()) { |  | ||||||
|         LOG_ERROR(WebService, "URL is invalid"); |  | ||||||
|         return std::async(std::launch::deferred, [] { |  | ||||||
|             return Common::WebResult{Common::WebResult::Code::InvalidURL, "URL is invalid"}; |  | ||||||
|         }); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     const std::string jwt = |  | ||||||
|         UpdateCoreJWT(false, Settings::values.citra_username, Settings::values.citra_token); |  | ||||||
| 
 |  | ||||||
|     const bool are_credentials_provided{!jwt.empty()}; |  | ||||||
|     if (!allow_anonymous && !are_credentials_provided) { |  | ||||||
|         LOG_ERROR(WebService, "Credentials must be provided for authenticated requests"); |  | ||||||
|         return std::async(std::launch::deferred, [] { |  | ||||||
|             return Common::WebResult{Common::WebResult::Code::CredentialsMissing, |  | ||||||
|                                      "Credentials needed"}; |  | ||||||
|         }); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     // Built request header
 |  | ||||||
|     hl::Headers params; |  | ||||||
|     if (are_credentials_provided) { |  | ||||||
|         // Authenticated request if credentials are provided
 |  | ||||||
|         params = {{std::string("Authorization"), fmt::format("Bearer {}", jwt)}, |  | ||||||
|                   {std::string("api-version"), std::string(API_VERSION)}, |  | ||||||
|                   {std::string("Content-Type"), std::string("application/json")}}; |  | ||||||
|         } else { |         } else { | ||||||
|         // Otherwise, anonymous request
 |             std::lock_guard<std::mutex> lock(jwt_cache.mutex); | ||||||
|         params = {{std::string("api-version"), std::string(API_VERSION)}, |             jwt_cache.username = username; | ||||||
|                   {std::string("Content-Type"), std::string("application/json")}}; |             jwt_cache.token = token; | ||||||
|  |             jwt_cache.jwt = jwt = result.returned_data; | ||||||
|  |         } | ||||||
|     } |     } | ||||||
| 
 |  | ||||||
|     // Post JSON asynchronously
 |  | ||||||
|     return std::async(std::launch::async, PostJsonAsyncFn, url, parsedUrl, params, data, false); |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| std::future<Common::WebResult> PostJson(const std::string& url, const std::string& username, | Common::WebResult Client::GenericJson(const std::string& method, const std::string& path, | ||||||
|                                         const std::string& token) { |                                       const std::string& data, bool allow_anonymous) { | ||||||
|     using lup = LUrlParser::clParseURL; |     if (jwt.empty()) { | ||||||
|     namespace hl = httplib; |         UpdateJWT(); | ||||||
| 
 |  | ||||||
|     lup parsedUrl = lup::ParseURL(url); |  | ||||||
| 
 |  | ||||||
|     if (url.empty() || !parsedUrl.IsValid()) { |  | ||||||
|         LOG_ERROR(WebService, "URL is invalid"); |  | ||||||
|         return std::async(std::launch::deferred, [] { |  | ||||||
|             return Common::WebResult{Common::WebResult::Code::InvalidURL, ""}; |  | ||||||
|         }); |  | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     const bool are_credentials_provided{!token.empty() && !username.empty()}; |     if (jwt.empty() && !allow_anonymous) { | ||||||
|     if (!are_credentials_provided) { |  | ||||||
|         LOG_ERROR(WebService, "Credentials must be provided for authenticated requests"); |         LOG_ERROR(WebService, "Credentials must be provided for authenticated requests"); | ||||||
|         return std::async(std::launch::deferred, [] { |         return Common::WebResult{Common::WebResult::Code::CredentialsMissing, "Credentials needed"}; | ||||||
|             return Common::WebResult{Common::WebResult::Code::CredentialsMissing, ""}; |  | ||||||
|         }); |  | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     // Built request header
 |     auto result = GenericJson(method, path, data, jwt); | ||||||
|     hl::Headers params; |     if (result.result_string == "401") { | ||||||
|     if (are_credentials_provided) { |         // Try again with new JWT
 | ||||||
|         // Authenticated request if credentials are provided
 |         UpdateJWT(); | ||||||
|         params = {{std::string("x-username"), username}, |         result = GenericJson(method, path, data, jwt); | ||||||
|                   {std::string("x-token"), token}, |  | ||||||
|                   {std::string("api-version"), std::string(API_VERSION)}, |  | ||||||
|                   {std::string("Content-Type"), std::string("application/json")}}; |  | ||||||
|     } else { |  | ||||||
|         // Otherwise, anonymous request
 |  | ||||||
|         params = {{std::string("api-version"), std::string(API_VERSION)}, |  | ||||||
|                   {std::string("Content-Type"), std::string("application/json")}}; |  | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     // Post JSON asynchronously
 |     return result; | ||||||
|     return std::async(std::launch::async, PostJsonAsyncFn, url, parsedUrl, params, "", true); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| template <typename T> |  | ||||||
| std::future<T> GetJson(std::function<T(const std::string&)> func, const std::string& url, |  | ||||||
|                        bool allow_anonymous) { |  | ||||||
|     static bool is_first_attempt = true; |  | ||||||
| 
 |  | ||||||
|     using lup = LUrlParser::clParseURL; |  | ||||||
|     namespace hl = httplib; |  | ||||||
| 
 |  | ||||||
|     lup parsedUrl = lup::ParseURL(url); |  | ||||||
| 
 |  | ||||||
|     if (url.empty() || !parsedUrl.IsValid()) { |  | ||||||
|         LOG_ERROR(WebService, "URL is invalid"); |  | ||||||
|         return std::async(std::launch::deferred, [func{std::move(func)}]() { return func(""); }); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     const std::string jwt = |  | ||||||
|         UpdateCoreJWT(false, Settings::values.citra_username, Settings::values.citra_token); |  | ||||||
| 
 |  | ||||||
|     const bool are_credentials_provided{!jwt.empty()}; |  | ||||||
|     if (!allow_anonymous && !are_credentials_provided) { |  | ||||||
|         LOG_ERROR(WebService, "Credentials must be provided for authenticated requests"); |  | ||||||
|         return std::async(std::launch::deferred, [func{std::move(func)}]() { return func(""); }); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     // Built request header
 |  | ||||||
|     hl::Headers params; |  | ||||||
|     if (are_credentials_provided) { |  | ||||||
|         params = {{std::string("Authorization"), fmt::format("Bearer {}", jwt)}, |  | ||||||
|                   {std::string("api-version"), std::string(API_VERSION)}}; |  | ||||||
|     } else { |  | ||||||
|         // Otherwise, anonymous request
 |  | ||||||
|         params = {{std::string("api-version"), std::string(API_VERSION)}}; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     // Get JSON asynchronously
 |  | ||||||
|     return std::async(std::launch::async, [func, url, parsedUrl, params, allow_anonymous] { |  | ||||||
|         std::unique_ptr<hl::Client> cli = GetClientFor(parsedUrl); |  | ||||||
| 
 |  | ||||||
|         if (cli == nullptr) { |  | ||||||
|             return func(""); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         hl::Request request; |  | ||||||
|         request.method = "GET"; |  | ||||||
|         request.path = "/" + parsedUrl.m_Path; |  | ||||||
|         request.headers = params; |  | ||||||
| 
 |  | ||||||
|         hl::Response response; |  | ||||||
| 
 |  | ||||||
|         if (!cli->send(request, response)) { |  | ||||||
|             LOG_ERROR(WebService, "GET to {} returned null", url); |  | ||||||
|             return func(""); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         if (response.status >= 400) { |  | ||||||
|             LOG_ERROR(WebService, "GET to {} returned error status code: {}", url, response.status); |  | ||||||
|             if (response.status == 401 && is_first_attempt) { |  | ||||||
|                 LOG_WARNING(WebService, "Requesting new JWT"); |  | ||||||
|                 UpdateCoreJWT(true, Settings::values.citra_username, Settings::values.citra_token); |  | ||||||
|                 is_first_attempt = false; |  | ||||||
|                 GetJson(func, url, allow_anonymous); |  | ||||||
|                 is_first_attempt = true; |  | ||||||
|             } |  | ||||||
|             return func(""); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         auto content_type = response.headers.find("content-type"); |  | ||||||
| 
 |  | ||||||
|         if (content_type == response.headers.end() || |  | ||||||
|             content_type->second.find("application/json") == std::string::npos) { |  | ||||||
|             LOG_ERROR(WebService, "GET to {} returned wrong content: {}", url, |  | ||||||
|                       content_type->second); |  | ||||||
|             return func(""); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         return func(response.body); |  | ||||||
|     }); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| template std::future<bool> GetJson(std::function<bool(const std::string&)> func, |  | ||||||
|                                    const std::string& url, bool allow_anonymous); |  | ||||||
| template std::future<AnnounceMultiplayerRoom::RoomList> GetJson( |  | ||||||
|     std::function<AnnounceMultiplayerRoom::RoomList(const std::string&)> func, |  | ||||||
|     const std::string& url, bool allow_anonymous); |  | ||||||
| 
 |  | ||||||
| void DeleteJson(const std::string& url, const std::string& data) { |  | ||||||
|     static bool is_first_attempt = true; |  | ||||||
| 
 |  | ||||||
|     using lup = LUrlParser::clParseURL; |  | ||||||
|     namespace hl = httplib; |  | ||||||
| 
 |  | ||||||
|     lup parsedUrl = lup::ParseURL(url); |  | ||||||
| 
 |  | ||||||
|     if (url.empty() || !parsedUrl.IsValid()) { |  | ||||||
|         LOG_ERROR(WebService, "URL is invalid"); |  | ||||||
|         return; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     const std::string jwt = |  | ||||||
|         UpdateCoreJWT(false, Settings::values.citra_username, Settings::values.citra_token); |  | ||||||
| 
 |  | ||||||
|     const bool are_credentials_provided{!jwt.empty()}; |  | ||||||
|     if (!are_credentials_provided) { |  | ||||||
|         LOG_ERROR(WebService, "Credentials must be provided for authenticated requests"); |  | ||||||
|         return; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     // Built request header
 |  | ||||||
|     hl::Headers params = {{std::string("Authorization"), fmt::format("Bearer {}", jwt)}, |  | ||||||
|                           {std::string("api-version"), std::string(API_VERSION)}, |  | ||||||
|                           {std::string("Content-Type"), std::string("application/json")}}; |  | ||||||
| 
 |  | ||||||
|     // Delete JSON asynchronously
 |  | ||||||
|     std::async(std::launch::async, [url, parsedUrl, params, data] { |  | ||||||
|         std::unique_ptr<hl::Client> cli = GetClientFor(parsedUrl); |  | ||||||
| 
 |  | ||||||
|         if (cli == nullptr) { |  | ||||||
|             return; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         hl::Request request; |  | ||||||
|         request.method = "DELETE"; |  | ||||||
|         request.path = "/" + parsedUrl.m_Path; |  | ||||||
|         request.headers = params; |  | ||||||
|         request.body = data; |  | ||||||
| 
 |  | ||||||
|         hl::Response response; |  | ||||||
| 
 |  | ||||||
|         if (!cli->send(request, response)) { |  | ||||||
|             LOG_ERROR(WebService, "DELETE to {} returned null", url); |  | ||||||
|             return; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         if (response.status >= 400) { |  | ||||||
|             LOG_ERROR(WebService, "DELETE to {} returned error status code: {}", url, |  | ||||||
|                       response.status); |  | ||||||
|             if (response.status == 401 && is_first_attempt) { |  | ||||||
|                 LOG_WARNING(WebService, "Requesting new JWT"); |  | ||||||
|                 UpdateCoreJWT(true, Settings::values.citra_username, Settings::values.citra_token); |  | ||||||
|                 is_first_attempt = false; |  | ||||||
|                 DeleteJson(url, data); |  | ||||||
|                 is_first_attempt = true; |  | ||||||
|             } |  | ||||||
|             return; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         auto content_type = response.headers.find("content-type"); |  | ||||||
| 
 |  | ||||||
|         if (content_type == response.headers.end() || |  | ||||||
|             content_type->second.find("application/json") == std::string::npos) { |  | ||||||
|             LOG_ERROR(WebService, "DELETE to {} returned wrong content: {}", url, |  | ||||||
|                       content_type->second); |  | ||||||
|             return; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         return; |  | ||||||
|     }); |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| } // namespace WebService
 | } // namespace WebService
 | ||||||
|  |  | ||||||
|  | @ -5,79 +5,88 @@ | ||||||
| #pragma once | #pragma once | ||||||
| 
 | 
 | ||||||
| #include <functional> | #include <functional> | ||||||
| #include <future> | #include <mutex> | ||||||
| #include <string> | #include <string> | ||||||
| #include <tuple> | #include <tuple> | ||||||
| #include <httplib.h> | #include <httplib.h> | ||||||
| #include "common/announce_multiplayer_room.h" | #include "common/announce_multiplayer_room.h" | ||||||
| #include "common/common_types.h" | #include "common/common_types.h" | ||||||
| 
 | 
 | ||||||
| namespace LUrlParser { | namespace httplib { | ||||||
| class clParseURL; | class Client; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| namespace WebService { | namespace WebService { | ||||||
| 
 | 
 | ||||||
| /**
 | class Client { | ||||||
|  * Requests a new JWT if necessary | public: | ||||||
|  * @param force_new_token If true, force to request a new token from the server. |     Client(const std::string& host, const std::string& username, const std::string& token); | ||||||
|  * @param username Citra username to use for authentication. |  | ||||||
|  * @param token Citra token to use for authentication. |  | ||||||
|  * @return string with the current JWT toke |  | ||||||
|  */ |  | ||||||
| std::string UpdateCoreJWT(bool force_new_token, const std::string& username, |  | ||||||
|                           const std::string& token); |  | ||||||
| 
 | 
 | ||||||
| /**
 |     /**
 | ||||||
|  * Posts JSON to a api.citra-emu.org. |      * Posts JSON to the specified path. | ||||||
|  * @param url URL of the api.citra-emu.org endpoint to post data to. |      * @param path the URL segment after the host address. | ||||||
|  * @param parsed_url Parsed URL used for the POST request. |  | ||||||
|  * @param params Headers sent for the POST request. |  | ||||||
|  * @param data String of JSON data to use for the body of the POST request. |  | ||||||
|  * @param data If true, a JWT is requested in the function |  | ||||||
|  * @return future with the returned value of the POST |  | ||||||
|  */ |  | ||||||
| static Common::WebResult PostJsonAsyncFn(const std::string& url, |  | ||||||
|                                          const LUrlParser::clParseURL& parsed_url, |  | ||||||
|                                          const httplib::Headers& params, const std::string& data, |  | ||||||
|                                          bool is_jwt_requested); |  | ||||||
| 
 |  | ||||||
| /**
 |  | ||||||
|  * Posts JSON to api.citra-emu.org. |  | ||||||
|  * @param url URL of the api.citra-emu.org endpoint to post data to. |  | ||||||
|      * @param data String of JSON data to use for the body of the POST request. |      * @param data String of JSON data to use for the body of the POST request. | ||||||
|      * @param allow_anonymous If true, allow anonymous unauthenticated requests. |      * @param allow_anonymous If true, allow anonymous unauthenticated requests. | ||||||
|  * @return future with the returned value of the POST |      * @return the result of the request. | ||||||
|      */ |      */ | ||||||
| std::future<Common::WebResult> PostJson(const std::string& url, const std::string& data, |     Common::WebResult PostJson(const std::string& path, const std::string& data, | ||||||
|                                         bool allow_anonymous); |                                bool allow_anonymous) { | ||||||
|  |         return GenericJson("POST", path, data, allow_anonymous); | ||||||
|  |     } | ||||||
| 
 | 
 | ||||||
| /**
 |     /**
 | ||||||
|  * Posts JSON to api.citra-emu.org. |      * Gets JSON from the specified path. | ||||||
|  * @param url URL of the api.citra-emu.org endpoint to post data to. |      * @param path the URL segment after the host address. | ||||||
|  * @param username Citra username to use for authentication. |  | ||||||
|  * @param token Citra token to use for authentication. |  | ||||||
|  * @return future with the error or result of the POST |  | ||||||
|  */ |  | ||||||
| std::future<Common::WebResult> PostJson(const std::string& url, const std::string& username, |  | ||||||
|                                         const std::string& token); |  | ||||||
| 
 |  | ||||||
| /**
 |  | ||||||
|  * Gets JSON from api.citra-emu.org. |  | ||||||
|  * @param func A function that gets exectued when the json as a string is received |  | ||||||
|  * @param url URL of the api.citra-emu.org endpoint to post data to. |  | ||||||
|      * @param allow_anonymous If true, allow anonymous unauthenticated requests. |      * @param allow_anonymous If true, allow anonymous unauthenticated requests. | ||||||
|  * @return future that holds the return value T of the func |      * @return the result of the request. | ||||||
|      */ |      */ | ||||||
| template <typename T> |     Common::WebResult GetJson(const std::string& path, bool allow_anonymous) { | ||||||
| std::future<T> GetJson(std::function<T(const std::string&)> func, const std::string& url, |         return GenericJson("GET", path, "", allow_anonymous); | ||||||
|                        bool allow_anonymous); |     } | ||||||
| 
 | 
 | ||||||
| /**
 |     /**
 | ||||||
|  * Delete JSON to api.citra-emu.org. |      * Deletes JSON to the specified path. | ||||||
|  * @param url URL of the api.citra-emu.org endpoint to post data to. |      * @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 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. | ||||||
|      */ |      */ | ||||||
| void DeleteJson(const std::string& url, const std::string& data); |     Common::WebResult DeleteJson(const std::string& path, const std::string& data, | ||||||
|  |                                  bool allow_anonymous) { | ||||||
|  |         return GenericJson("DELETE", path, data, allow_anonymous); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  | private: | ||||||
|  |     /// A generic function handles POST, GET and DELETE request together
 | ||||||
|  |     Common::WebResult GenericJson(const std::string& method, const std::string& path, | ||||||
|  |                                   const std::string& data, bool allow_anonymous); | ||||||
|  | 
 | ||||||
|  |     /**
 | ||||||
|  |      * A generic function with explicit authentication method specified | ||||||
|  |      * JWT is used if the jwt parameter is not empty | ||||||
|  |      * username + token is used if jwt is empty but username and token are not empty | ||||||
|  |      * anonymous if all of jwt, username and token are empty | ||||||
|  |      */ | ||||||
|  |     Common::WebResult GenericJson(const std::string& method, const std::string& path, | ||||||
|  |                                   const std::string& data, const std::string& jwt = "", | ||||||
|  |                                   const std::string& username = "", const std::string& token = ""); | ||||||
|  | 
 | ||||||
|  |     // Retrieve a new JWT from given username and token
 | ||||||
|  |     void UpdateJWT(); | ||||||
|  | 
 | ||||||
|  |     std::string host; | ||||||
|  |     std::string username; | ||||||
|  |     std::string token; | ||||||
|  |     std::string jwt; | ||||||
|  |     std::unique_ptr<httplib::Client> cli; | ||||||
|  | 
 | ||||||
|  |     struct JWTCache { | ||||||
|  |         std::mutex mutex; | ||||||
|  |         std::string username; | ||||||
|  |         std::string token; | ||||||
|  |         std::string jwt; | ||||||
|  |     }; | ||||||
|  |     static JWTCache jwt_cache; | ||||||
|  | }; | ||||||
| 
 | 
 | ||||||
| } // namespace WebService
 | } // namespace WebService
 | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue