Discord rich presence (#4500)

* WIP RPC support

* Might have windows support now, don't peek

* Windows support

* RichPresence: __SWITCH__ => DISABLE_RPC (for MOBILE_STK support)

* RichPresence: Handle JSON strings according to spec, support for addons icon

* RichPresence: use translated difficulty name

* RichPresence: disable when client_id=-1

* RichPresence: thread connection, show server name on RPC

* RichPresence: destroy on close

* RichPresence: don't compile methods at all if DISABLE_RPC

* RichPresence: fix windows compile (untested)

* RichPresence: fix for mac

* RichPresence: Linux needs MSG_NOSIGNAL still

* RichPresence: fix memory leaks, don't spam update while not connected

* RichPresence: free thread on terminate

* RichPresence: handle initial registration

* RichPresence: fix compiler warning
This commit is contained in:
Mary 2021-03-09 21:47:33 -05:00 committed by GitHub
parent 8daf149895
commit 0dd3c62a43
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 616 additions and 5 deletions

View File

@ -6297,3 +6297,7 @@ msgstr "If you need more stability, consider using the stable version: %s"
#: supertuxkart.appdata.xml:44
msgid "SuperTuxKart Team"
msgstr "SuperTuxKart Team"
#: rich_presence.cpp:296
msgid "Getting ready to race"
msgstr "Getting ready to race"

View File

@ -1,5 +1,5 @@
# Modify this file to change the last-modified date when you add/remove a file.
# This will then trigger a new cmake run automatically.
# This will then trigger a new cmake run automatically.
file(GLOB_RECURSE STK_HEADERS RELATIVE ${CMAKE_CURRENT_SOURCE_DIR} "src/*.hpp")
file(GLOB_RECURSE STK_SOURCES RELATIVE ${CMAKE_CURRENT_SOURCE_DIR} "src/*.cpp")
file(GLOB_RECURSE STK_SHADERS RELATIVE ${CMAKE_CURRENT_SOURCE_DIR} "data/shaders/*")

View File

@ -42,6 +42,49 @@ namespace HardwareStats
m_data ="{";
} // Constructor
const std::string sanitize(const std::string &value)
{
// Really confusing. Basically converts between utf8 and wide and irrlicht strings and std strings
std::wstring wide = StringUtils::utf8ToWide(value).c_str();
std::string normalized = StringUtils::wideToUtf8(sanitize(wide).c_str()).c_str();
return normalized;
}
const std::wstring sanitize(std::wstring value)
{
// A string is a sequence of Unicode code points wrapped with quotation marks (U+0022). All code points may
// be placed within the quotation marks except for the code points that must be escaped: quotation mark
// (U+0022), reverse solidus (U+005C), and the control characters U+0000 to U+001F. There are two-character
// escape sequence representations of some characters.
wchar_t temp[7] = {0};
for(size_t i = 0; i < value.size(); ++i)
{
if (value[i] <= 0x1f)
{
swprintf(temp, sizeof(temp) / sizeof(wchar_t), L"\\u%04x", value[i]);
std::wstring suffix = value.substr(i + 1);
value = value.substr(0, i);
value.append(temp);
value.append(suffix);
i += 5; // \u0000 = 6 chars, but we're replacing one so 5
}
else if (value[i] == '"' || value[i] == '\\')
{
char escaped = value[i];
std::wstring suffix = value.substr(i + 1);
value = value.substr(0, i);
value.push_back('\\');
value.push_back(escaped);
value.append(suffix);
// Skip the added solidus
++i;
}
}
return value;
}
// --------------------------------------------------------------------
/** Adds a key-value pair to the json string. */
template <typename C>
@ -49,7 +92,7 @@ namespace HardwareStats
{
if(m_data.size()>1) // more than '{'
m_data += ",";
m_data += "\""+key+"\":"+StringUtils::toString(value);
m_data += "\""+sanitize(key)+"\":"+StringUtils::toString(value);
} // add
// --------------------------------------------------------------------
/** Specialisation for adding string values. String values in
@ -58,7 +101,7 @@ namespace HardwareStats
{
if(m_data.size()>1) // more than '{'
m_data += ",";
m_data += "\""+key+"\":\""+StringUtils::toString(value)+"\"";
m_data += "\""+sanitize(key)+"\":\""+StringUtils::toString(sanitize(value))+"\"";
} // add
// --------------------------------------------------------------------
/** Specialisation for adding character pointers. String values in
@ -67,7 +110,7 @@ namespace HardwareStats
{
if(m_data.size()>1) // more than '{'
m_data += ",";
m_data += "\""+key+"\":\""+StringUtils::toString(s)+"\"";
m_data += "\""+sanitize(key)+"\":\""+StringUtils::toString(sanitize(s))+"\"";
} // add
// --------------------------------------------------------------------
void finish()

View File

@ -1152,6 +1152,14 @@ namespace UserConfigParams
PARAM_DEFAULT( StringUserConfigParam("all", "last_track_group",
"Last selected track group") );
PARAM_PREFIX StringUserConfigParam m_discord_client_id
PARAM_DEFAULT( StringUserConfigParam("817760324983324753", "discord_client_id",
"Discord Client ID (Set to -1 to disable)") );
PARAM_PREFIX BoolUserConfigParam m_rich_presence_debug
PARAM_DEFAULT( BoolUserConfigParam(false, "rich_presence_debug",
"If debug logging should be enabled for rich presence") );
PARAM_PREFIX StringUserConfigParam m_skin_file
PARAM_DEFAULT( StringUserConfigParam("peach", "skin_name",
"Name of the skin to use") );

495
src/io/rich_presence.cpp Normal file
View File

@ -0,0 +1,495 @@
#include "utils/time.hpp"
#include "utils/string_utils.hpp"
#include "race/race_manager.hpp"
#include "io/rich_presence.hpp"
#include "config/player_manager.hpp"
#include "config/player_profile.hpp"
#include "modes/world.hpp"
#include "config/hardware_stats.hpp"
#include "config/user_config.hpp"
#include "tracks/track_manager.hpp"
#include "tracks/track.hpp"
#include "karts/abstract_kart.hpp"
#include "karts/kart_properties.hpp"
#include "utils/translation.hpp"
#include "network/protocols/client_lobby.hpp"
#include "network/protocols/lobby_protocol.hpp"
#include "network/server.hpp"
#include <locale>
#include <codecvt>
#if defined(__SWITCH__) || defined(MOBILE_STK) || defined(SERVER_ONLY)
#define DISABLE_RPC
#endif
#if !defined(WIN32) && !defined(DISABLE_RPC)
#include <sys/socket.h>
#include <sys/un.h>
#include <fcntl.h>
#elif defined(WIN32)
#include <process.h>
#include <fileapi.h>
#include <namedpipeapi.h>
#endif
namespace RichPresenceNS {
RichPresence* g_rich_presence = nullptr;
RichPresence* RichPresence::get() {
if (g_rich_presence == nullptr)
{
g_rich_presence = new RichPresence();
}
return g_rich_presence;
}
void RichPresence::destroy() {
if (g_rich_presence != nullptr)
{
delete g_rich_presence;
}
}
RichPresence::RichPresence() : m_connected(false), m_ready(false), m_last(0),
#ifdef WIN32
m_socket(INVALID_HANDLE_VALUE),
#else
m_socket(-1),
#endif
m_thread(nullptr) {
doConnect();
}
RichPresence::~RichPresence() {
terminate();
}
void RichPresence::terminate() {
#ifndef DISABLE_RPC
#ifdef WIN32
#define UNCLEAN m_socket != INVALID_HANDLE_VALUE
#else
#define UNCLEAN m_socket != -1
#endif
if (m_connected || UNCLEAN)
{
if (UNCLEAN && !m_connected)
Log::fatal("RichPresence", "RichPresence terminated uncleanly! Socket is %d", m_socket);
#ifndef WIN32
close(m_socket);
m_socket = -1;
#else
CloseHandle(m_socket);
m_socket = INVALID_HANDLE_VALUE;
#endif
m_connected = false;
m_ready = false;
}
if(m_thread != nullptr && STKProcess::getType() == PT_MAIN)
{
m_thread->join();
delete m_thread;
m_thread = nullptr;
}
#endif // DISABLE_RPC
}
bool RichPresence::doConnect() {
#ifndef DISABLE_RPC
if (std::string(UserConfigParams::m_discord_client_id) == "-1")
return false;
#ifndef DISABLE_RPC
// Just in case we're retrying or something:
terminate();
#if !defined(WIN32) && defined(AF_UNIX)
m_socket = socket(AF_UNIX, SOCK_STREAM, 0);
if (m_socket < 0)
{
if (UserConfigParams::m_rich_presence_debug)
perror("Couldn't open a Unix socket!");
return false;
}
#ifdef SO_NOSIGPIPE
const int set = 1;
setsockopt(m_socket, SOL_SOCKET, SO_NOSIGPIPE, &set, sizeof(set));
#endif
// Discord tries these env vars in order:
char* env;
std::string basePath = "";
#define TRY_ENV(path) env = std::getenv(path); \
if (env != nullptr) \
{\
basePath = env; \
goto completed; \
}
TRY_ENV("XDG_RUNTIME_DIR")
TRY_ENV("TMPDIR")
TRY_ENV("TMP")
TRY_ENV("TEMP")
#undef TRY_ENV
// Falls back to /tmp
basePath = "/tmp";
completed:
basePath = basePath + "/";
#elif defined(WIN32)
// Windows uses named pipes
std::string basePath = "\\\\?\\pipe\\";
#endif
// Discord will only bind up to socket 9
for (int i = 0; i < 10; ++i)
{
if (tryConnect(basePath + "discord-ipc-" + StringUtils::toString(i)))
break;
}
if (m_connected)
{
if (UserConfigParams::m_rich_presence_debug)
Log::info("RichPresence", "Connection opened with Discord!");
m_thread = new std::thread(finishConnection, this);
return true;
}
else
{
// Force cleanup:
m_connected = true;
terminate();
return false;
}
#else
return false;
#endif
#endif // DISABLE_RPC
}
void RichPresence::readData() {
#ifndef DISABLE_RPC
size_t baseLength = sizeof(int32_t) * 2;
struct discordPacket* basePacket = (struct discordPacket*) malloc(baseLength);
#ifdef WIN32
DWORD read;
if (!ReadFile(m_socket, basePacket, baseLength, &read, NULL))
{
Log::error("RichPresence", "Couldn't read from pipe! Error %x", GetLastError());
free(basePacket);
terminate();
return;
}
#else
int read = recv(m_socket, basePacket, baseLength, 0);
if (read == -1)
{
if (UserConfigParams::m_rich_presence_debug)
perror("Couldn't read data from socket!");
terminate();
return;
}
#endif
// Add one char so we can printf easy
struct discordPacket* packet = (struct discordPacket*)
malloc(baseLength + basePacket->length + sizeof(char));
// Copy over length and opcode from base packet
memcpy(packet, basePacket, baseLength);
free(basePacket);
#ifdef WIN32
if (!ReadFile(m_socket, packet->data, packet->length, &read, NULL))
{
Log::error("RichPresence", "Couldn't read from pipe! Error %x", GetLastError());
free(packet);
terminate();
return;
}
#else
read = recv(m_socket, packet->data, packet->length, 0);
if (read == -1)
{
terminate();
}
#endif
packet->data[packet->length] = '\0';
if (UserConfigParams::m_rich_presence_debug)
Log::debug("RichPresence", "<= (OP %d len=%d) %s is data (READ %d bytes)",
packet->op, packet->length, packet->data, read);
free(packet);
#endif
}
void RichPresence::finishConnection(RichPresence* self) {
#ifndef DISABLE_RPC
// We read all the data from the socket. We're clear now to handshake!
self->handshake();
// Make sure we get a response!
self->readData();
// Okay to go nonblocking now!
#if !defined(WIN32) && defined(O_NONBLOCK)
fcntl(self->m_socket, F_SETFL, O_NONBLOCK);
#endif
self->m_ready = true;
#endif
}
bool RichPresence::tryConnect(std::string path) {
#ifndef DISABLE_RPC
#if !defined(WIN32) && defined(AF_UNIX)
struct sockaddr_un addr = {
.sun_family = AF_UNIX
};
memset(addr.sun_path, 0, sizeof(addr.sun_path));
snprintf(addr.sun_path, sizeof(addr.sun_path), "%s", path.c_str());
if(connect(m_socket, (struct sockaddr *) &addr, sizeof(addr)) == -1)
{
// Something is probably wrong:
if (errno != ENOENT && errno != ECONNREFUSED)
{
perror("Couldn't open Discord socket!");
}
return false;
}
// Connected!
m_connected = true;
#elif defined(WIN32)
// Windows
m_socket = CreateFileA(path.c_str(), GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL);
if (m_socket == INVALID_HANDLE_VALUE)
{
DWORD error = GetLastError();
if (error != ERROR_FILE_NOT_FOUND)
{
LPSTR errorText = NULL;
FormatMessage(
FORMAT_MESSAGE_ALLOCATE_BUFFER |
FORMAT_MESSAGE_FROM_SYSTEM |
FORMAT_MESSAGE_IGNORE_INSERTS,
NULL,
error,
MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT),
(LPTSTR)&errorText,
0, NULL);
Log::warn("RichPresence", "Couldn't open file! %s Error: %ls", path.c_str(), errorText);
}
return false;
}
m_connected = true;
#endif
#endif // DISABLE_RPC
return m_connected;
}
void RichPresence::handshake() {
#ifndef DISABLE_RPC
if (UserConfigParams::m_rich_presence_debug)
Log::debug("RichPresence", "Starting handshake...");
HardwareStats::Json json;
json.add<int>("v", 1);
json.add("client_id", std::string(UserConfigParams::m_discord_client_id));
json.finish();
sendData(OP_HANDSHAKE, json.toString());
#endif
}
void RichPresence::sendData(int32_t op, std::string json) {
#ifndef DISABLE_RPC
// Handshake will make us ready:
if (op != OP_HANDSHAKE && !m_ready)
{
Log::warn("RichPresence", "Tried sending data while not ready?");
return;
}
if (UserConfigParams::m_rich_presence_debug)
Log::debug("RichPresence", "=> %s", json.c_str());
int32_t size = json.size();
size_t length = (sizeof(int32_t) * 2) + (size * sizeof(char));
struct discordPacket* packet = (struct discordPacket*) malloc(
length
);
packet->op = op;
packet->length = size;
// Note we aren't copying the NUL at the end
memcpy(&packet->data, json.c_str(), json.size());
#if !defined(WIN32) && defined(AF_UNIX)
int flags = 0;
#ifdef MSG_NOSIGNAL
flags |= MSG_NOSIGNAL;
#endif
if (send(m_socket, packet, length, flags) == -1)
{
if (errno != EPIPE)
perror("Couldn't send data to Discord socket!");
else
{
if (UserConfigParams::m_rich_presence_debug)
Log::debug("RichPresence", "Got an EPIPE, closing");
// EPIPE, cleanup!
terminate();
}
}
#elif defined(WIN32)
DWORD written;
WriteFile(m_socket, packet, length, &written, NULL);
// TODO
if(written != length)
{
if (UserConfigParams::m_rich_presence_debug)
Log::debug("RichPresence", "Amount written != data size! Closing");
terminate();
}
#endif // AF_UNIX
free(packet);
#endif
}
void RichPresence::update(bool force) {
#ifndef DISABLE_RPC
if (STKProcess::getType() != PT_MAIN)
{
// Don't update on server thread
return;
}
time_t now = time(NULL);
if ((now - m_last) < 10 && !force)
{
// Only update every 10s
return;
}
// Check more often if we're not ready
if (m_ready || !m_connected)
{
// Update timer
m_last = now;
}
// Retry connection:
if (!m_connected)
{
doConnect();
}
if (!m_ready)
{
return;
}
if (UserConfigParams::m_rich_presence_debug)
Log::debug("RichPresence", "Updating status!");
std::wstring_convert<std::codecvt_utf8_utf16<wchar_t>> convert;
std::string playerName;
PlayerProfile *player = PlayerManager::getCurrentPlayer();
if (player)
{
if (PlayerManager::getCurrentOnlineState() == PlayerProfile::OS_GUEST ||
PlayerManager::getCurrentOnlineState() == PlayerProfile::OS_SIGNED_IN)
{
playerName = convert.to_bytes(player->getLastOnlineName().c_str()) + "@stk";
}
else
{
playerName = convert.to_bytes(player->getName().c_str());
}
}
else
playerName = "Guest";
World* world = World::getWorld();
RaceManager *raceManager = RaceManager::get();
std::string trackId = raceManager->getTrackName();
std::string difficulty = convert.to_bytes(raceManager->getDifficultyName(
raceManager->getDifficulty()
).c_str());
std::string minorModeName = convert.to_bytes(raceManager->getNameOf(
raceManager->getMinorMode()
).c_str());
// Discord takes the time when we started as unix timestamp
uint64_t since = (now * 1000) - StkTime::getMonoTimeMs();
if (world)
{
since += world->getStart();
}
// {cmd:SET_ACTIVITY,args:{activity:{},pid:0},nonce:0}
HardwareStats::Json base;
base.add("cmd", "SET_ACTIVITY");
HardwareStats::Json args;
HardwareStats::Json activity;
std::string trackName = convert.to_bytes(_("Getting ready to race").c_str());
if (world)
{
Track* track = track_manager->getTrack(trackId);
if (track)
trackName = convert.to_bytes(track->getName().c_str());
}
auto protocol = LobbyProtocol::get<ClientLobby>();
if (protocol != nullptr && protocol.get()->getJoinedServer() != nullptr)
{
trackName.append(" - ");
trackName.append(convert.to_bytes(
protocol.get()->getJoinedServer().get()->getName().c_str()
));
}
activity.add("state", std::string(trackName.c_str()));
if (world)
activity.add("details", minorModeName + " (" + difficulty + ")");
HardwareStats::Json assets;
if (world)
{
Track* track = track_manager->getTrack(trackId);
assets.add("large_text", convert.to_bytes(track->getName().c_str()));
assets.add("large_image", track->isAddon() ?
"addons" : "track_" + trackId);
AbstractKart *abstractKart = world->getLocalPlayerKart(0);
if (abstractKart)
{
const KartProperties* kart = abstractKart->getKartProperties();
assets.add("small_image", kart->isAddon() ?
"addons" : "kart_" + abstractKart->getIdent());
std::string kartName = convert.to_bytes(kart->getName().c_str());
assets.add("small_text", kartName + " (" + playerName + ")");
}
}
else
{
assets.add("large_text", "SuperTuxKart");
assets.add("large_image", "logo");
assets.add("small_text", playerName);
// std::string filename = std::string(basename(player->getIconFilename().c_str()));
// assets->add("small_image", "kart_" + filename);
}
assets.finish();
activity.add<std::string>("assets", assets.toString());
HardwareStats::Json timestamps;
timestamps.add<std::string>("start", std::to_string(since));
timestamps.finish();
activity.add<std::string>("timestamps", timestamps.toString());
activity.finish();
args.add<std::string>("activity", activity.toString());
int pid = 0;
#ifdef WIN32
pid = _getpid();
#elif !defined(DISABLE_RPC)
pid = getppid();
#endif
args.add<int>("pid", pid);
args.finish();
base.add<int>("nonce", now);
base.add<std::string>("args", args.toString());
base.finish();
sendData(OP_DATA, base.toString());
#endif // DISABLE_RPC
}
} // namespace

42
src/io/rich_presence.hpp Normal file
View File

@ -0,0 +1,42 @@
#ifdef WIN32
#include <namedpipeapi.h>
#endif
#include <thread>
namespace RichPresenceNS {
// There are more, but we don't need to use them
enum OPCodes {
OP_HANDSHAKE = 0,
OP_DATA = 1,
};
struct discordPacket {
int32_t op;
int32_t length;
char data[];
};
class RichPresence {
private:
bool m_connected;
bool m_ready;
time_t m_last;
#ifdef WIN32
HANDLE m_socket;
#else
int m_socket;
#endif
std::thread* m_thread;
bool tryConnect(std::string path);
bool doConnect();
void terminate();
void sendData(int32_t op, std::string json);
void handshake();
void readData();
static void finishConnection(RichPresence* self);
public:
RichPresence();
~RichPresence();
void update(bool force);
static RichPresence* get();
static void destroy();
};
}

View File

@ -290,6 +290,7 @@ extern "C" {
#include "utils/stk_process.hpp"
#include "utils/string_utils.hpp"
#include "utils/translation.hpp"
#include "io/rich_presence.hpp"
static void cleanSuperTuxKart();
static void cleanUserConfig();
@ -2573,6 +2574,8 @@ int main(int argc, char *argv[])
cleanSuperTuxKart();
NetworkConfig::destroy();
RichPresenceNS::RichPresence::destroy();
#ifdef DEBUG
MemoryLeaks::checkForLeaks();
#endif

View File

@ -56,6 +56,7 @@
#include "utils/string_utils.hpp"
#include "utils/time.hpp"
#include "utils/translation.hpp"
#include "io/rich_presence.hpp"
#ifndef WIN32
#include <unistd.h>
@ -714,6 +715,8 @@ void MainLoop::run()
}
}
RichPresenceNS::RichPresence::get()->update(false);
if (auto gp = GameProtocol::lock())
{
gp->sendActions();

View File

@ -38,7 +38,7 @@
#include <irrlicht.h>
//-----------------------------------------------------------------------------
WorldStatus::WorldStatus() : m_process_type(STKProcess::getType())
WorldStatus::WorldStatus() : m_process_type(STKProcess::getType()), m_started_at(StkTime::getMonoTimeMs())
{
if (m_process_type == PT_MAIN)
main_loop->setFrameBeforeLoadingWorld();
@ -72,6 +72,7 @@ WorldStatus::WorldStatus() : m_process_type(STKProcess::getType())
*/
void WorldStatus::reset(bool restart)
{
m_started_at = StkTime::getMonoTimeMs();
m_time = 0.0f;
m_time_ticks = 0;
m_auxiliary_ticks = 0;

View File

@ -111,6 +111,9 @@ private:
/** The third sound to be played in ready, set, go. */
SFXBase *m_start_sound;
/** (Unix) time when we started */
uint64_t m_started_at;
/** The clock mode: normal counting forwards, or countdown */
ClockType m_clock_mode;
protected:
@ -200,6 +203,10 @@ public:
/** Returns the current race time. */
float getTime() const { return (float)m_time; }
// ------------------------------------------------------------------------
/** Returns the start time. */
uint64_t getStart() const { return m_started_at; }
// ------------------------------------------------------------------------
/** Returns the current race time in time ticks (i.e. based on the physics
* time step size). */

View File

@ -62,6 +62,7 @@
#include "utils/stk_process.hpp"
#include "utils/string_utils.hpp"
#include "utils/translation.hpp"
#include "io/rich_presence.hpp"
#ifdef __SWITCH__
extern "C" {
@ -688,6 +689,8 @@ void RaceManager::startNextRace()
#ifdef __SWITCH__
appletSetCpuBoostMode(ApmCpuBoostMode_Normal);
#endif
RichPresenceNS::RichPresence::get()->update(true);
} // startNextRace
//---------------------------------------------------------------------------------------------
@ -970,6 +973,8 @@ void RaceManager::exitRace(bool delete_world)
m_saved_gp = NULL;
m_track_number = 0;
RichPresenceNS::RichPresence::get()->update(true);
} // exitRace
//---------------------------------------------------------------------------------------------