diff --git a/CMakeLists.txt b/CMakeLists.txt index 3e9c30fdad..6045ebc734 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -297,8 +297,10 @@ set(GAME_INCLUDE_DIRS extern ${CMAKE_BINARY_DIR}) +find_package(Threads REQUIRED) set(GAME_LIBS aurora::core aurora::gx aurora::gd aurora::si aurora::vi aurora::pad aurora::mtx aurora::os aurora::dvd - aurora::card freeverb cxxopts::cxxopts absl::flat_hash_map nlohmann_json::nlohmann_json TracyClient fmt::fmt) + aurora::card freeverb cxxopts::cxxopts absl::flat_hash_map nlohmann_json::nlohmann_json TracyClient fmt::fmt + Threads::Threads) list(APPEND GAME_LIBS libzstd_static) @@ -320,46 +322,13 @@ if (DUSK_MOVIE_SUPPORT) list(APPEND GAME_COMPILE_DEFS MOVIE_SUPPORT=1) endif () -option(DUSK_ENABLE_DISCORD_RPC "Enable Discord Rich Presence support" ON) -if (DUSK_ENABLE_DISCORD_RPC AND NOT ANDROID AND NOT IOS AND NOT TVOS) - - FetchContent_Populate(discord_rpc - URL https://github.com/discord/discord-rpc/archive/refs/tags/v3.4.0.tar.gz - URL_HASH SHA256=e13427019027acd187352dacba6c65953af66fdf3c35fcf38fc40b454a9d7855 - DOWNLOAD_EXTRACT_TIMESTAMP TRUE - ) - # RapidJSON is a git submodule absent from the discord-rpc tarball; fetch separately. - FetchContent_Populate(rapidjson - URL https://github.com/Tencent/rapidjson/archive/refs/tags/v1.1.0.tar.gz - URL_HASH SHA256=bf7ced29704a1e696fbccf2a2b4ea068e7774fa37f6d7dd4039d0787f8bed98e - DOWNLOAD_EXTRACT_TIMESTAMP TRUE - ) - - if (NOT TARGET discord-rpc) - set(_drpc ${discord_rpc_SOURCE_DIR}/src) - set(_drpc_src - ${_drpc}/discord_rpc.cpp - ${_drpc}/rpc_connection.cpp - ${_drpc}/serialization.cpp - ) - if (WIN32) - list(APPEND _drpc_src ${_drpc}/connection_win.cpp ${_drpc}/discord_register_win.cpp) - elseif (APPLE) - list(APPEND _drpc_src ${_drpc}/connection_unix.cpp ${_drpc}/discord_register_osx.m) - else () - list(APPEND _drpc_src ${_drpc}/connection_unix.cpp ${_drpc}/discord_register_linux.cpp) - endif () - add_library(discord-rpc STATIC ${_drpc_src}) - target_include_directories(discord-rpc PUBLIC - ${discord_rpc_SOURCE_DIR}/include - ${rapidjson_SOURCE_DIR}/include - ) - if (UNIX) - target_link_libraries(discord-rpc PUBLIC pthread) - endif () - endif () - list(APPEND GAME_LIBS discord-rpc) - list(APPEND GAME_COMPILE_DEFS DUSK_DISCORD_RPC=1) +set(DUSK_ENABLE_DISCORD_DEFAULT ON) +if (DEFINED DUSK_ENABLE_DISCORD_RPC AND NOT DEFINED DUSK_ENABLE_DISCORD) + set(DUSK_ENABLE_DISCORD_DEFAULT ${DUSK_ENABLE_DISCORD_RPC}) +endif () +option(DUSK_ENABLE_DISCORD "Enable Discord Rich Presence support" ${DUSK_ENABLE_DISCORD_DEFAULT}) +if (DUSK_ENABLE_DISCORD AND NOT ANDROID AND NOT IOS AND NOT TVOS) + list(APPEND GAME_COMPILE_DEFS DUSK_DISCORD=1) endif () # Edit & Continue diff --git a/files.cmake b/files.cmake index f06884fb35..a463ce5129 100644 --- a/files.cmake +++ b/files.cmake @@ -1512,6 +1512,8 @@ set(DUSK_FILES src/dusk/OSReport.cpp src/dusk/OSThread.cpp src/dusk/OSMutex.cpp + src/dusk/discord.cpp + src/dusk/discord.hpp src/dusk/discord_presence.cpp src/dusk/version.cpp ) diff --git a/include/dusk/discord_presence.hpp b/include/dusk/discord_presence.hpp index 899b2ad5f9..ebecc2d07b 100644 --- a/include/dusk/discord_presence.hpp +++ b/include/dusk/discord_presence.hpp @@ -1,18 +1,14 @@ #pragma once -#ifdef DUSK_DISCORD_RPC +#ifdef DUSK_DISCORD -namespace dusk { -namespace discord { +namespace dusk::discord { -void Initialize(); +void initialize(); +void run_callbacks(); +void update_presence(); +void shutdown(); -void RunCallbacks(); +} // namespace dusk::discord -void UpdatePresence(); - -void Shutdown(); -} -} - -#endif // DUSK_DISCORD_RPC +#endif // DUSK_DISCORD diff --git a/src/dusk/discord.cpp b/src/dusk/discord.cpp new file mode 100644 index 0000000000..f20541bfa4 --- /dev/null +++ b/src/dusk/discord.cpp @@ -0,0 +1,867 @@ +#ifdef DUSK_DISCORD + +#ifndef NOMINMAX +#define NOMINMAX +#endif + +#include "discord.hpp" + +#include "dusk/logging.h" +#include "nlohmann/json.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#define NOMCX +#define NOSERVICE +#define NOIME +#include +#else +#include +#include +#include +#include +#include +#include +#include +#endif + +namespace dusk::discord::rpc { +namespace { + +using json = nlohmann::json; + +constexpr uint32_t kRpcVersion = 1; +constexpr size_t kFrameHeaderSize = sizeof(uint32_t) * 2; +constexpr size_t kMaxFramePayloadSize = 64 * 1024; +constexpr auto kIoWait = std::chrono::milliseconds(500); +constexpr auto kShutdownClearTimeout = std::chrono::milliseconds(200); +constexpr auto kInitialReconnectDelay = std::chrono::milliseconds(500); +constexpr auto kMaxReconnectDelay = std::chrono::milliseconds(60 * 1000); + +enum class Opcode : uint32_t { + Handshake = 0, + Frame = 1, + Close = 2, + Ping = 3, + Pong = 4, +}; + +enum class ConnectionState { + Disconnected, + SentHandshake, + Connected, +}; + +enum class DisconnectCode : int { + PipeClosed = 1, + ReadCorrupt = 2, + BadFrame = 3, +}; + +struct Frame { + Opcode opcode = Opcode::Frame; + std::string payload; +}; + +struct QueuedEvent { + enum class Type { + Ready, + Disconnected, + Error, + }; + + Type type = Type::Ready; + User user; + int code = 0; + std::string message; +}; + +class Backoff { +public: + std::chrono::milliseconds next_delay() { + const auto delay = currentDelay; + currentDelay = std::min(currentDelay * 2, kMaxReconnectDelay); + return delay; + } + + void reset() { currentDelay = kInitialReconnectDelay; } + +private: + std::chrono::milliseconds currentDelay = kInitialReconnectDelay; +}; + +class IpcConnection { +public: + IpcConnection() = default; + ~IpcConnection() { close(); } + + IpcConnection(const IpcConnection&) = delete; + IpcConnection& operator=(const IpcConnection&) = delete; + + bool open() { +#ifdef _WIN32 + wchar_t pipeName[] = L"\\\\?\\pipe\\discord-ipc-0"; + constexpr size_t kPipeDigit = sizeof(pipeName) / sizeof(wchar_t) - 2; + + for (wchar_t pipeNumber = L'0'; pipeNumber <= L'9'; ++pipeNumber) { + pipeName[kPipeDigit] = pipeNumber; + pipe = CreateFileW( + pipeName, GENERIC_READ | GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, 0, nullptr); + if (pipe != INVALID_HANDLE_VALUE) { + return true; + } + + if (GetLastError() == ERROR_PIPE_BUSY && WaitNamedPipeW(pipeName, 10000)) { + --pipeNumber; + } + } + return false; +#else + const auto tempPaths = get_temp_paths(); + for (const std::string& tempPath : tempPaths) { + for (int pipeNumber = 0; pipeNumber < 10; ++pipeNumber) { + socketFd = socket(AF_UNIX, SOCK_STREAM, 0); + if (socketFd == -1) { + return false; + } + + sockaddr_un pipeAddress{}; + pipeAddress.sun_family = AF_UNIX; + const std::string socketPath = + tempPath + "/discord-ipc-" + std::to_string(pipeNumber); + if (socketPath.size() >= sizeof(pipeAddress.sun_path)) { + close(); + continue; + } + + std::strncpy( + pipeAddress.sun_path, socketPath.c_str(), sizeof(pipeAddress.sun_path) - 1); + if (connect(socketFd, reinterpret_cast(&pipeAddress), + sizeof(pipeAddress)) == 0) + { + fcntl(socketFd, F_SETFL, O_NONBLOCK); +#ifdef SO_NOSIGPIPE + int optval = 1; + setsockopt(socketFd, SOL_SOCKET, SO_NOSIGPIPE, &optval, sizeof(optval)); +#endif + return true; + } + close(); + } + } + return false; +#endif + } + + void close() { +#ifdef _WIN32 + if (pipe != INVALID_HANDLE_VALUE) { + CloseHandle(pipe); + pipe = INVALID_HANDLE_VALUE; + } +#else + if (socketFd != -1) { + ::close(socketFd); + socketFd = -1; + } +#endif + pendingRead.clear(); + } + + bool is_open() const { +#ifdef _WIN32 + return pipe != INVALID_HANDLE_VALUE; +#else + return socketFd != -1; +#endif + } + + bool write_frame(const Frame& frame) { + std::array header{}; + write_u32(header.data(), static_cast(frame.opcode)); + write_u32(header.data() + sizeof(uint32_t), static_cast(frame.payload.size())); + + return write_all(header.data(), header.size()) && + write_all( + reinterpret_cast(frame.payload.data()), frame.payload.size()); + } + + enum class ReadStatus { + None, + Frame, + Closed, + Corrupt, + }; + + ReadStatus read_frame(Frame& frame) { + if (!read_available()) { + return is_open() ? ReadStatus::None : ReadStatus::Closed; + } + + if (pendingRead.size() < kFrameHeaderSize) { + return ReadStatus::None; + } + + const uint32_t payloadLength = read_u32(pendingRead.data() + sizeof(uint32_t)); + if (payloadLength > kMaxFramePayloadSize) { + return ReadStatus::Corrupt; + } + + const size_t frameLength = kFrameHeaderSize + payloadLength; + if (pendingRead.size() < frameLength) { + return ReadStatus::None; + } + + frame.opcode = static_cast(read_u32(pendingRead.data())); + frame.payload.assign( + reinterpret_cast(pendingRead.data() + kFrameHeaderSize), payloadLength); + pendingRead.erase( + pendingRead.begin(), pendingRead.begin() + static_cast(frameLength)); + return ReadStatus::Frame; + } + +private: +#ifndef _WIN32 + static std::vector get_temp_paths() { + std::vector paths; + for (const char* name : {"XDG_RUNTIME_DIR", "TMPDIR", "TMP", "TEMP"}) { + if (const char* value = std::getenv(name); value && value[0] != '\0') { + if (std::find(paths.begin(), paths.end(), value) == paths.end()) { + paths.emplace_back(value); + } + } + } + if (std::find(paths.begin(), paths.end(), "/tmp") == paths.end()) { + paths.emplace_back("/tmp"); + } + return paths; + } +#endif + + static void write_u32(uint8_t* out, uint32_t value) { + out[0] = static_cast(value & 0xff); + out[1] = static_cast((value >> 8) & 0xff); + out[2] = static_cast((value >> 16) & 0xff); + out[3] = static_cast((value >> 24) & 0xff); + } + + static uint32_t read_u32(const uint8_t* in) { + return static_cast(in[0]) | (static_cast(in[1]) << 8) | + (static_cast(in[2]) << 16) | (static_cast(in[3]) << 24); + } + + bool write_all(const uint8_t* data, size_t length) { + size_t written = 0; + while (written < length) { +#ifdef _WIN32 + DWORD bytesWritten = 0; + if (WriteFile(pipe, data + written, static_cast(length - written), &bytesWritten, + nullptr) == FALSE || + bytesWritten == 0) + { + close(); + return false; + } + written += bytesWritten; +#else +#ifdef MSG_NOSIGNAL + constexpr int kMsgFlags = MSG_NOSIGNAL; +#else + constexpr int kMsgFlags = 0; +#endif + const ssize_t bytesWritten = + send(socketFd, data + written, length - written, kMsgFlags); + if (bytesWritten < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK) { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + continue; + } + close(); + return false; + } + if (bytesWritten == 0) { + close(); + return false; + } + written += static_cast(bytesWritten); +#endif + } + return true; + } + + bool read_available() { + std::array buffer{}; + bool readAny = false; + + for (;;) { +#ifdef _WIN32 + DWORD bytesAvailable = 0; + if (PeekNamedPipe(pipe, nullptr, 0, nullptr, &bytesAvailable, nullptr) == FALSE) { + close(); + return readAny; + } + if (bytesAvailable == 0) { + return readAny; + } + + const DWORD bytesToRead = + std::min(bytesAvailable, static_cast(buffer.size())); + DWORD bytesRead = 0; + if (ReadFile(pipe, buffer.data(), bytesToRead, &bytesRead, nullptr) == FALSE) { + close(); + return readAny; + } + if (bytesRead == 0) { + close(); + return readAny; + } + pendingRead.insert(pendingRead.end(), buffer.begin(), buffer.begin() + bytesRead); + readAny = true; +#else +#ifdef MSG_NOSIGNAL + constexpr int kMsgFlags = MSG_NOSIGNAL; +#else + constexpr int kMsgFlags = 0; +#endif + const ssize_t bytesRead = recv(socketFd, buffer.data(), buffer.size(), kMsgFlags); + if (bytesRead < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK) { + return readAny; + } + close(); + return readAny; + } + if (bytesRead == 0) { + close(); + return readAny; + } + pendingRead.insert(pendingRead.end(), buffer.begin(), buffer.begin() + bytesRead); + readAny = true; +#endif + } + } + +#ifdef _WIN32 + HANDLE pipe = INVALID_HANDLE_VALUE; +#else + int socketFd = -1; +#endif + std::vector pendingRead; +}; + +int current_process_id() { +#ifdef _WIN32 + return static_cast(GetCurrentProcessId()); +#else + return static_cast(getpid()); +#endif +} + +std::string next_nonce() { + static std::atomic_uint64_t sNonce{1}; + return std::to_string(sNonce.fetch_add(1)); +} + +const json* find_member(const json& object, const char* key) { + if (!object.is_object()) { + return nullptr; + } + + const auto member = object.find(key); + if (member == object.end()) { + return nullptr; + } + return &*member; +} + +std::string json_string_member(const json& object, const char* key) { + const json* member = find_member(object, key); + if (!member || !member->is_string()) { + return {}; + } + return member->get(); +} + +int json_int_member(const json& object, const char* key, int defaultValue) { + const json* member = find_member(object, key); + if (!member || !member->is_number_integer()) { + return defaultValue; + } + + try { + return member->get(); + } catch (const json::exception&) { + return defaultValue; + } +} + +json make_presence_activity(const Presence& presence) { + json activity = json::object(); + + if (!presence.state.empty()) { + activity["state"] = presence.state; + } + if (!presence.details.empty()) { + activity["details"] = presence.details; + } + if (presence.startTimestamp != 0) { + activity["timestamps"] = {{"start", presence.startTimestamp}}; + } + if (!presence.largeImageKey.empty() || !presence.largeImageText.empty()) { + json assets = json::object(); + if (!presence.largeImageKey.empty()) { + assets["large_image"] = presence.largeImageKey; + } + if (!presence.largeImageText.empty()) { + assets["large_text"] = presence.largeImageText; + } + activity["assets"] = std::move(assets); + } + + return activity; +} + +Frame make_handshake_frame(std::string_view applicationId) { + return { + Opcode::Handshake, + json{ + {"v", kRpcVersion}, + {"client_id", std::string(applicationId)}, + } + .dump(), + }; +} + +Frame make_set_activity_frame(std::string nonce, int pid, std::optional presence) { + json args = {{"pid", pid}}; + if (presence) { + args["activity"] = make_presence_activity(*presence); + } else { + args["activity"] = nullptr; + } + + return { + Opcode::Frame, + json{ + {"cmd", "SET_ACTIVITY"}, + {"nonce", std::move(nonce)}, + {"args", std::move(args)}, + } + .dump(), + }; +} + +class Client { +public: + void initialize(std::string applicationId, EventHandlers handlers) { + shutdown(); + + { + std::lock_guard lock(mutex); + this->applicationId = std::move(applicationId); + this->handlers = std::move(handlers); + shouldRun = true; + queuedPresence.reset(); + hasQueuedPresence = false; + clearRequested = false; + sentInitialConnectLog = false; + } + + ioThread = std::thread([this] { io_loop(); }); + } + + void run_callbacks() { + std::deque events; + EventHandlers localHandlers; + { + std::lock_guard lock(mutex); + events.swap(queuedEvents); + localHandlers = handlers; + } + + for (const QueuedEvent& event : events) { + switch (event.type) { + case QueuedEvent::Type::Ready: + if (localHandlers.ready) { + localHandlers.ready(event.user); + } + break; + case QueuedEvent::Type::Disconnected: + if (localHandlers.disconnected) { + localHandlers.disconnected(event.code, event.message); + } + break; + case QueuedEvent::Type::Error: + if (localHandlers.error) { + localHandlers.error(event.code, event.message); + } + break; + } + } + } + + void update_presence(Presence presence) { + { + std::lock_guard lock(mutex); + if (!shouldRun) { + return; + } + queuedPresence = std::move(presence); + hasQueuedPresence = true; + } + cv.notify_all(); + } + + void clear_presence() { + { + std::lock_guard lock(mutex); + if (!shouldRun) { + return; + } + queuedPresence.reset(); + hasQueuedPresence = false; + clearRequested = true; + } + cv.notify_all(); + } + + void shutdown() { + { + std::lock_guard lock(mutex); + if (!shouldRun && !ioThread.joinable()) { + return; + } + shouldRun = false; + clearRequested = true; + } + cv.notify_all(); + + if (ioThread.joinable()) { + ioThread.join(); + } + + std::lock_guard lock(mutex); + queuedPresence.reset(); + hasQueuedPresence = false; + clearRequested = false; + queuedEvents.clear(); + handlers = {}; + applicationId.clear(); + } + +private: + void io_loop() { + IpcConnection connection; + ConnectionState state = ConnectionState::Disconnected; + Backoff reconnectBackoff; + auto nextConnect = std::chrono::steady_clock::now(); + const int pid = current_process_id(); + std::string localApplicationId; + + for (;;) { + { + std::unique_lock lock(mutex); + if (!shouldRun) { + break; + } + localApplicationId = applicationId; + } + + const auto now = std::chrono::steady_clock::now(); + if (state == ConnectionState::Disconnected && now >= nextConnect) { + if (connection.open()) { + if (connection.write_frame(make_handshake_frame(localApplicationId))) { + state = ConnectionState::SentHandshake; + } else { + connection.close(); + } + } + + if (state == ConnectionState::Disconnected) { + log_waiting_for_discord_once(); + nextConnect = now + reconnectBackoff.next_delay(); + } + } + + if (state != ConnectionState::Disconnected) { + process_reads(connection, state, reconnectBackoff, nextConnect); + } + + if (state == ConnectionState::Connected) { + flush_pending_presence(connection, pid); + } + + std::unique_lock lock(mutex); + if (!shouldRun) { + break; + } + cv.wait_for(lock, kIoWait); + } + + flush_shutdown_clear(connection, state, pid); + connection.close(); + } + + void process_reads(IpcConnection& connection, ConnectionState& state, Backoff& reconnectBackoff, + std::chrono::steady_clock::time_point& nextConnect) { + for (;;) { + Frame frame; + const auto status = connection.read_frame(frame); + if (status == IpcConnection::ReadStatus::None) { + return; + } + if (status == IpcConnection::ReadStatus::Closed) { + handle_disconnect(connection, state, reconnectBackoff, nextConnect, + static_cast(DisconnectCode::PipeClosed), "Pipe closed"); + return; + } + if (status == IpcConnection::ReadStatus::Corrupt) { + handle_disconnect(connection, state, reconnectBackoff, nextConnect, + static_cast(DisconnectCode::ReadCorrupt), "Oversized Discord IPC frame"); + return; + } + + switch (frame.opcode) { + case Opcode::Frame: + process_json_frame(frame.payload, state, reconnectBackoff); + break; + case Opcode::Close: + process_close_frame( + frame.payload, connection, state, reconnectBackoff, nextConnect); + return; + case Opcode::Ping: + connection.write_frame({Opcode::Pong, frame.payload}); + break; + case Opcode::Pong: + break; + case Opcode::Handshake: + default: + handle_disconnect(connection, state, reconnectBackoff, nextConnect, + static_cast(DisconnectCode::BadFrame), "Bad Discord IPC frame"); + return; + } + } + } + + void process_json_frame( + const std::string& payload, ConnectionState& state, Backoff& reconnectBackoff) { + json message; + try { + message = json::parse(payload); + } catch (const json::parse_error&) { + enqueue_error( + static_cast(DisconnectCode::ReadCorrupt), "Invalid Discord IPC JSON"); + return; + } + + const std::string cmd = json_string_member(message, "cmd"); + const std::string evt = json_string_member(message, "evt"); + + if (state == ConnectionState::SentHandshake && cmd == "DISPATCH" && evt == "READY") { + state = ConnectionState::Connected; + reconnectBackoff.reset(); + enqueue_ready(message); + return; + } + + if (evt == "ERROR") { + const json* data = find_member(message, "data"); + enqueue_error(data ? json_int_member(*data, "code", 0) : 0, + data ? json_string_member(*data, "message") : std::string{}); + } + } + + void process_close_frame(const std::string& payload, IpcConnection& connection, + ConnectionState& state, Backoff& reconnectBackoff, + std::chrono::steady_clock::time_point& nextConnect) { + int code = static_cast(DisconnectCode::PipeClosed); + std::string message = "Discord closed IPC connection"; + + try { + const json closePayload = json::parse(payload); + code = json_int_member(closePayload, "code", code); + const std::string closeMessage = json_string_member(closePayload, "message"); + if (!closeMessage.empty()) { + message = closeMessage; + } + } catch (const json::exception&) { + } + + handle_disconnect(connection, state, reconnectBackoff, nextConnect, code, message); + } + + void handle_disconnect(IpcConnection& connection, ConnectionState& state, + Backoff& reconnectBackoff, std::chrono::steady_clock::time_point& nextConnect, int code, + std::string_view message) { + const bool wasConnected = + state == ConnectionState::Connected || state == ConnectionState::SentHandshake; + connection.close(); + state = ConnectionState::Disconnected; + nextConnect = std::chrono::steady_clock::now() + reconnectBackoff.next_delay(); + if (wasConnected) { + enqueue_disconnected(code, message); + } + } + + void flush_pending_presence(IpcConnection& connection, int pid) { + std::optional presence; + bool shouldClear = false; + { + std::lock_guard lock(mutex); + if (hasQueuedPresence) { + presence = queuedPresence; + hasQueuedPresence = false; + } else if (clearRequested) { + shouldClear = true; + clearRequested = false; + } + } + + if (presence) { + if (!connection.write_frame( + make_set_activity_frame(next_nonce(), pid, std::move(presence)))) + { + requeue_presence(); + } + } else if (shouldClear) { + connection.write_frame(make_set_activity_frame(next_nonce(), pid, std::nullopt)); + } + } + + void flush_shutdown_clear(IpcConnection& connection, ConnectionState state, int pid) { + if (state != ConnectionState::Connected || !connection.is_open()) { + return; + } + + connection.write_frame(make_set_activity_frame(next_nonce(), pid, std::nullopt)); + const auto deadline = std::chrono::steady_clock::now() + kShutdownClearTimeout; + while (std::chrono::steady_clock::now() < deadline) { + Frame frame; + const auto status = connection.read_frame(frame); + if (status == IpcConnection::ReadStatus::None) { + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + continue; + } + if (status != IpcConnection::ReadStatus::Frame) { + break; + } + if (frame.opcode == Opcode::Ping) { + connection.write_frame({Opcode::Pong, frame.payload}); + } + } + } + + void requeue_presence() { + std::lock_guard lock(mutex); + if (queuedPresence) { + hasQueuedPresence = true; + } + } + + void enqueue_ready(const json& readyMessage) { + User user; + const auto data = readyMessage.find("data"); + if (data != readyMessage.end() && data->is_object()) { + const auto userIt = data->find("user"); + if (userIt != data->end() && userIt->is_object()) { + user.id = json_string_member(*userIt, "id"); + user.username = json_string_member(*userIt, "username"); + user.discriminator = json_string_member(*userIt, "discriminator"); + user.avatar = json_string_member(*userIt, "avatar"); + } + } + + std::lock_guard lock(mutex); + queuedEvents.push_back({QueuedEvent::Type::Ready, std::move(user)}); + } + + void enqueue_disconnected(int code, std::string_view message) { + std::lock_guard lock(mutex); + QueuedEvent event; + event.type = QueuedEvent::Type::Disconnected; + event.code = code; + event.message = message; + queuedEvents.push_back(std::move(event)); + } + + void enqueue_error(int code, std::string_view message) { + std::lock_guard lock(mutex); + QueuedEvent event; + event.type = QueuedEvent::Type::Error; + event.code = code; + event.message = message; + queuedEvents.push_back(std::move(event)); + } + + void log_waiting_for_discord_once() { + bool shouldLog = false; + { + std::lock_guard lock(mutex); + if (!sentInitialConnectLog) { + sentInitialConnectLog = true; + shouldLog = true; + } + } + if (shouldLog) { + DuskLog.info("Discord: Waiting for local Discord IPC"); + } + } + + std::mutex mutex; + std::condition_variable cv; + std::thread ioThread; + std::string applicationId; + EventHandlers handlers; + std::deque queuedEvents; + std::optional queuedPresence; + bool hasQueuedPresence = false; + bool clearRequested = false; + bool shouldRun = false; + bool sentInitialConnectLog = false; +}; + +Client& client() { + static Client sClient; + return sClient; +} + +} // namespace + +void initialize(std::string applicationId, EventHandlers handlers) { + client().initialize(std::move(applicationId), std::move(handlers)); +} + +void run_callbacks() { + client().run_callbacks(); +} + +void update_presence(Presence presence) { + client().update_presence(std::move(presence)); +} + +void clear_presence() { + client().clear_presence(); +} + +void shutdown() { + client().shutdown(); +} + +} // namespace dusk::discord::rpc + +#endif // DUSK_DISCORD diff --git a/src/dusk/discord.hpp b/src/dusk/discord.hpp new file mode 100644 index 0000000000..67f54ff2f3 --- /dev/null +++ b/src/dusk/discord.hpp @@ -0,0 +1,41 @@ +#pragma once + +#ifdef DUSK_DISCORD + +#include +#include +#include +#include + +namespace dusk::discord::rpc { + +struct User { + std::string id; + std::string username; + std::string discriminator; + std::string avatar; +}; + +struct Presence { + std::string state; + std::string details; + int64_t startTimestamp = 0; + std::string largeImageKey; + std::string largeImageText; +}; + +struct EventHandlers { + std::function ready; + std::function disconnected; + std::function error; +}; + +void initialize(std::string applicationId, EventHandlers handlers); +void run_callbacks(); +void update_presence(Presence presence); +void clear_presence(); +void shutdown(); + +} // namespace dusk::discord::rpc + +#endif // DUSK_DISCORD diff --git a/src/dusk/discord_presence.cpp b/src/dusk/discord_presence.cpp index 3b12075c8b..6b2c7c912b 100644 --- a/src/dusk/discord_presence.cpp +++ b/src/dusk/discord_presence.cpp @@ -1,38 +1,39 @@ -#ifdef DUSK_DISCORD_RPC +#ifdef DUSK_DISCORD #include "dusk/discord_presence.hpp" +#include "d/d_com_inf_game.h" +#include "discord.hpp" #include "dusk/logging.h" #include "dusk/main.h" #include "dusk/map_loader_definitions.h" -#include "d/d_com_inf_game.h" -#include "discord_rpc.h" #include "fmt/format.h" #include -#include #include +#include +#include -namespace dusk { -namespace discord { +namespace dusk::discord { static int64_t g_startTime = 0; static bool g_initialized = false; -static const char* APPLICATION_ID = "1495632471994405035"; +static constexpr const char* kApplicationId = "1495632471994405035"; -static void OnReady(const DiscordUser* user) { - DuskLog.info("Discord: Connected as {}", user->username); +static void on_ready(const rpc::User& user) { + DuskLog.info("Discord: Connected as {}", user.username); } -static void OnDisconnected(int errorCode, const char* message) { +static void on_disconnected(int errorCode, std::string_view message) { DuskLog.warn("Discord: Disconnected ({}: {})", errorCode, message); } -static void OnError(int errorCode, const char* message) { +static void on_error(int errorCode, std::string_view message) { DuskLog.warn("Discord: Error ({}: {})", errorCode, message); } -static const char* LookupMapName(const char* mapFile) { - if (!mapFile || mapFile[0] == '\0') return nullptr; +static const char* lookup_map_name(const char* mapFile) { + if (!mapFile || mapFile[0] == '\0') + return nullptr; for (const auto& region : gameRegions) { for (const auto& map : region.maps) { if (map.mapFile && strcmp(mapFile, map.mapFile) == 0) { @@ -43,80 +44,80 @@ static const char* LookupMapName(const char* mapFile) { return nullptr; } -void Initialize() { - g_startTime = static_cast( - std::chrono::duration_cast( - std::chrono::system_clock::now().time_since_epoch() - ).count() - ); +void initialize() { + g_startTime = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); - DiscordEventHandlers handlers{}; - handlers.ready = OnReady; - handlers.disconnected = OnDisconnected; - handlers.errored = OnError; - Discord_Initialize(APPLICATION_ID, &handlers, 0, nullptr); + rpc::EventHandlers handlers{}; + handlers.ready = on_ready; + handlers.disconnected = on_disconnected; + handlers.error = on_error; + rpc::initialize(kApplicationId, std::move(handlers)); g_initialized = true; DuskLog.info("Discord Rich Presence initialized"); } -void RunCallbacks() { - if (!g_initialized) return; - Discord_RunCallbacks(); +void run_callbacks() { + if (!g_initialized) + return; + rpc::run_callbacks(); } -void UpdatePresence() { - if (!g_initialized) return; +void update_presence() { + if (!g_initialized) + return; - static auto lastUpdate = std::chrono::steady_clock::time_point{}; + static auto sLastUpdate = std::chrono::steady_clock::time_point{}; const auto now = std::chrono::steady_clock::now(); - if (now - lastUpdate < std::chrono::seconds(15)) return; - lastUpdate = now; + if (now - sLastUpdate < std::chrono::seconds(15)) + return; + sLastUpdate = now; - static std::string detailsBuf; - static std::string stateBuf; + static std::string sDetailsBuf; + static std::string sStateBuf; - DiscordRichPresence presence{}; + rpc::Presence presence{}; presence.startTimestamp = g_startTime; presence.largeImageKey = "icon"; presence.largeImageText = "Dusk"; - if (dusk::IsGameLaunched) { + if (IsGameLaunched) { const char* stageName = dComIfGp_getLastPlayStageName(); // stageName is empty until a room is actually entered if (stageName[0] != '\0') { - const char* locationName = LookupMapName(stageName); + const char* locationName = lookup_map_name(stageName); if (locationName) { - detailsBuf = locationName; - } - else { - detailsBuf = "Twilight Princess"; + sDetailsBuf = locationName; + } else { + sDetailsBuf = "Twilight Princess"; } - presence.details = detailsBuf.c_str(); + presence.details = sDetailsBuf; - stateBuf = fmt::format(FMT_STRING("{}/{} \u2665 | {} Rupees"), + sStateBuf = fmt::format(FMT_STRING("{}/{} \u2665 | {} Rupees"), dComIfGs_getLife() / 4, dComIfGs_getMaxLife() / 5, dComIfGs_getRupee()); - presence.state = stateBuf.c_str(); + presence.state = sStateBuf; } } - Discord_UpdatePresence(&presence); + rpc::update_presence(std::move(presence)); DuskLog.debug("Discord Rich Presence sent"); } -void Shutdown() { - if (!g_initialized) return; - Discord_ClearPresence(); - Discord_Shutdown(); +void shutdown() { + if (!g_initialized) + return; + rpc::clear_presence(); + rpc::shutdown(); g_initialized = false; DuskLog.info("Discord Rich Presence shut down"); } -} // namespace discord -} // namespace dusk +} // namespace dusk::discord -#endif // DUSK_DISCORD_RPC +#endif // DUSK_DISCORD diff --git a/src/m_Do/m_Do_main.cpp b/src/m_Do/m_Do_main.cpp index c2984e3863..8d3bd0ce63 100644 --- a/src/m_Do/m_Do_main.cpp +++ b/src/m_Do/m_Do_main.cpp @@ -307,9 +307,9 @@ void main01(void) { FrameMark; -#ifdef DUSK_DISCORD_RPC - dusk::discord::RunCallbacks(); - dusk::discord::UpdatePresence(); +#ifdef DUSK_DISCORD + dusk::discord::run_callbacks(); + dusk::discord::update_presence(); #endif } while (dusk::IsRunning); @@ -571,8 +571,8 @@ int game_main(int argc, char* argv[]) { auroraInfo = aurora_initialize(argc, argv, &config); } -#ifdef DUSK_DISCORD_RPC - dusk::discord::Initialize(); +#ifdef DUSK_DISCORD + dusk::discord::initialize(); #endif VISetWindowTitle( @@ -613,8 +613,8 @@ int game_main(int argc, char* argv[]) { // pre game launch ui main loop if (!launchUILoop()) { dusk::ShutdownCrashReporting(); -#ifdef DUSK_DISCORD_RPC - dusk::discord::Shutdown(); +#ifdef DUSK_DISCORD + dusk::discord::shutdown(); #endif dusk::ui::shutdown(); aurora_shutdown(); @@ -674,8 +674,8 @@ int game_main(int argc, char* argv[]) { // Notifies all CVs and causes threads to exit OSResetSystem(OS_RESET_SHUTDOWN, 0, 0); -#ifdef DUSK_DISCORD_RPC - dusk::discord::Shutdown(); +#ifdef DUSK_DISCORD + dusk::discord::shutdown(); #endif dusk::ui::shutdown(); aurora_shutdown();