mirror of
https://github.com/Mudlet/Mudlet
synced 2026-08-13 18:26:27 -04:00
#### Brief overview of PR changes/additions - Closing a profile no longer frees the map out from under a running import, export or download. `TMap` counts the operations that pump `qApp->processEvents()`, and `mudlet::closeHost()` - which one of those pumps is what delivers it - stops the operation and destroys the `Host` once it has unwound, instead of half way through it. - Discord presence fields keep their last character and are only ever cut between characters: each buffer is now the documented limit plus room for its terminator, and a new `utils::copyUtf8String()` walks the cut back to a character boundary. - An interrupting `ttsSpeak()` announces the utterance it starts, and the `Ready` an engine reports for the utterance it cut off no longer drains `ttsQueue()` over the top of the one the script asked for. #### Motivation for adding to Mudlet Each is a filed defect, and each was reproduced before it was fixed. The map one is a use-after-free: ASan reports `heap-use-after-free` inside `TMap::readJsonMapFile()`, freed by `~TMap` <- `~Host` <- `HostManager::deleteHost` <- `mudlet::closeHost` delivered by the import's own `processEvents()`. The Discord one is worse than one field looking wrong: a single over-long non-ASCII field makes the whole `SET_ACTIVITY` payload undecodable, so the entire presence update is discarded - the fake Discord client recorded exactly that. The TTS one silently drops speech: `ttsQueue()` plus an interrupting `ttsSpeak()` speaks the queued line and never speaks the requested one. #### Other info (issues closed, discussion etc) Closes #9520, closes #9634, closes #9659. `MapCloseDuringImportTest` stages the close through `mudlet::slot_closeProfileByName()` and lets the map operation's own pump deliver it; the functional tests build with ASan, so the pre-fix run is a sanitizer report rather than an inference. `TtsInterruptingSpeakTest` hands `ttsStateChanged()` the `Ready` a real engine sends, which Qt's mock engine never does - the mock-visible half is pinned in `Media_spec.lua`, where the two specs that recorded the old behaviour are updated. `Discord_spec.lua` gains four end-to-end specs against `CI/discord-ipc-fixture.py` asserting that the captured frame still decodes as JSON and that a field is cut on a character boundary, and `DiscordTest.cpp` covers the same at unit level. Every new or changed test was confirmed to fail without its fix. Two things deliberately left alone, both older than this PR: `Host::requestClose()` still runs nested inside the map operation's pump (it saves the profile there), and an XML import or a map download has no cancel to poll, so a close waits for it rather than stopping it. **Test case:** Export a large map with `exportJsonMap()` and close the profile's tab while it runs; then `setDiscordDetail(string.rep("ä", 65))` and confirm the presence still updates; then `ttsQueue("queued line") ttsSpeak("first")` followed immediately by `ttsSpeak("second")` and confirm "second" is what gets spoken. Assisted-by: Claude:claude-opus-5
308 lines
13 KiB
C++
308 lines
13 KiB
C++
#ifndef DISCORD_H
|
|
#define DISCORD_H
|
|
/***************************************************************************
|
|
* Copyright (C) 2018 by Vadim Peretokin - vperetokin@gmail.com *
|
|
* Copyright (C) 2018, 2022 by Stephen Lyons - slysven@virginmedia.com *
|
|
* *
|
|
* This program is free software; you can redistribute it and/or modify *
|
|
* it under the terms of the GNU General Public License as published by *
|
|
* the Free Software Foundation; either version 2 of the License, or *
|
|
* (at your option) any later version. *
|
|
* *
|
|
* This program is distributed in the hope that it will be useful, *
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
|
* GNU General Public License for more details. *
|
|
* *
|
|
* You should have received a copy of the GNU General Public License *
|
|
* along with this program; if not, write to the *
|
|
* Free Software Foundation, Inc., *
|
|
* 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. *
|
|
***************************************************************************/
|
|
|
|
#include "Host.h"
|
|
|
|
#include <functional>
|
|
#include <map>
|
|
#include <memory>
|
|
#include <utility>
|
|
#include <QDebug>
|
|
#include <QTimer>
|
|
#include <QTimerEvent>
|
|
#include <QLibrary>
|
|
#include "../3rdparty/discord/rpc/include/discord_register.h"
|
|
#include "../3rdparty/discord/rpc/include/discord_rpc.h"
|
|
|
|
/*
|
|
* From the discord headers and on-line documentation:
|
|
* typedef struct DiscordRichPresence {
|
|
* const char* state; // max 128 bytes
|
|
* const char* details; // max 128 bytes
|
|
* int64_t startTimestamp;
|
|
* int64_t endTimestamp;
|
|
* const char* largeImageKey; // max 32 bytes
|
|
* const char* largeImageText; // max 128 bytes
|
|
* const char* smallImageKey; // max 32 bytes
|
|
* const char* smallImageText; // max 128 bytes
|
|
* const char* partyId; // max 128 bytes
|
|
* int partySize;
|
|
* int partyMax;
|
|
* const char* matchSecret; // max 128 bytes
|
|
* const char* joinSecret; // max 128 bytes
|
|
* const char* spectateSecret; // max 128 bytes
|
|
* int8_t instance;
|
|
* } DiscordRichPresence;
|
|
*
|
|
*
|
|
* typedef struct DiscordUser {
|
|
* const char* userId; // max 32 bytes
|
|
* const char* username; // max 344 bytes
|
|
* const char* discriminator; // max 8 bytes
|
|
* const char* avatar; // max 128 bytes
|
|
* } DiscordUser;
|
|
*/
|
|
|
|
// This is used to hold data to be stuffed into a DiscordRichPresence before
|
|
// it is sent to the RPC library with an Discord_UpdatePresence(...) call.
|
|
// It is done this way because the definition we have for the
|
|
// DiscordRichPresence is filled with const char pointers that can only be
|
|
// set on instantiation.
|
|
class localDiscordPresence {
|
|
|
|
public:
|
|
localDiscordPresence()
|
|
: mState()
|
|
, mDetails()
|
|
, mLargeImageKey()
|
|
, mLargeImageText()
|
|
, mSmallImageKey()
|
|
, mSmallImageText()
|
|
, mPartyId()
|
|
, mMatchSecret()
|
|
, mJoinSecret()
|
|
, mSpectateSecret()
|
|
{
|
|
}
|
|
|
|
void setStateText(const QString&);
|
|
void setDetailText(const QString&);
|
|
void setStartTimeStamp(int64_t startTime) { mStartTimestamp = startTime; }
|
|
void setEndTimeStamp(int64_t endTime) { mEndTimestamp = endTime; }
|
|
void setLargeImageKey(const QString&);
|
|
void setLargeImageText(const QString&);
|
|
void setSmallImageKey(const QString&);
|
|
void setSmallImageText(const QString&);
|
|
void setJoinSecret(const QString&);
|
|
void setMatchSecret(const QString&);
|
|
void setSpectateSecret(const QString&);
|
|
void setPartySize(const int size) { mPartySize = size; }
|
|
void setPartyMax(const int maximum) { mPartyMax = maximum; }
|
|
DiscordRichPresence convert() const;
|
|
QString getStateText() const { return mState; }
|
|
QString getDetailText() const { return mDetails; }
|
|
int64_t getStartTimeStamp() const { return mStartTimestamp; }
|
|
int64_t getEndTimeStamp() const { return mEndTimestamp; }
|
|
QString getLargeImageKey() const { return mLargeImageKey; }
|
|
QString getLargeImageText() const { return mLargeImageText; }
|
|
QString getSmallImageKey() const { return mSmallImageKey; }
|
|
QString getSmallImageText() const { return mSmallImageText; }
|
|
QString getJoinSecret() const { return mJoinSecret; }
|
|
QString getMatchSecret() const { return mMatchSecret; }
|
|
QString getSpectateSecret() const { return mSpectateSecret; }
|
|
QString getPartyId() const { return mPartyId; }
|
|
int getPartySize() const { return mPartySize; }
|
|
int getPartyMax() const { return mPartyMax; }
|
|
int8_t getInstance() const { return mInstance; }
|
|
|
|
private:
|
|
// The limits Discord documents for each field, in bytes (see the struct
|
|
// comment above). The buffers are one byte larger than the limit they hold:
|
|
// sized at exactly the limit, the null terminator would take the last byte
|
|
// and a field of the full documented length would always lose its final
|
|
// character. Named in the 'k' form the rest of the codebase gives an array
|
|
// size (TArea.cpp's kPixmapDataLineSize) rather than the 'scm' one it gives
|
|
// other static class members.
|
|
static constexpr size_t kTextByteLimit = 128;
|
|
static constexpr size_t kImageKeyByteLimit = 32;
|
|
|
|
char mState[kTextByteLimit + 1];
|
|
char mDetails[kTextByteLimit + 1];
|
|
int64_t mStartTimestamp = 0;
|
|
int64_t mEndTimestamp = 0;
|
|
char mLargeImageKey[kImageKeyByteLimit + 1];
|
|
char mLargeImageText[kTextByteLimit + 1];
|
|
char mSmallImageKey[kImageKeyByteLimit + 1];
|
|
char mSmallImageText[kTextByteLimit + 1];
|
|
char mPartyId[kTextByteLimit + 1];
|
|
int mPartySize = 0;
|
|
int mPartyMax = 0;
|
|
char mMatchSecret[kTextByteLimit + 1];
|
|
char mJoinSecret[kTextByteLimit + 1];
|
|
char mSpectateSecret[kTextByteLimit + 1];
|
|
int8_t mInstance = 1;
|
|
};
|
|
|
|
#ifndef QT_NO_DEBUG_STREAM
|
|
// Note "inline" is REQUIRED:
|
|
inline QDebug& operator<<(QDebug& debug, const localDiscordPresence& ldp)
|
|
{
|
|
const QDebugStateSaver saver(debug);
|
|
Q_UNUSED(saver)
|
|
|
|
QString result = qsl("localDiscordPresence(\n"
|
|
" mDetails: \"%1\" mState: \"%2\" mInstance: %3\n"
|
|
" mLargeImageKey: \"%4\" mLargeImageText: \"%5\" \n"
|
|
" mSmallImageKey: \"%6\" mSmallImageText: \"%7\" \n")
|
|
.arg(ldp.getDetailText(), ldp.getStateText(),
|
|
QString::number(ldp.getInstance()),
|
|
ldp.getLargeImageKey(), ldp.getLargeImageText(),
|
|
ldp.getSmallImageKey(), ldp.getSmallImageText());
|
|
|
|
result.append(qsl(" mPartyId: \"%1\" mPartySize: %2 mPartyMax %3\n"
|
|
" mMatchSecret: \"%4\" mJoinSecret: \"%5\" mSpectateSecret \"%6\"\n"
|
|
" mStartTimeStamp: %7 mEndTimeStamp: %8)\n")
|
|
.arg(ldp.getPartyId(), QString::number(ldp.getPartySize()), QString::number(ldp.getPartyMax()),
|
|
ldp.getMatchSecret(), ldp.getJoinSecret(), ldp.getSpectateSecret(),
|
|
QString::number(ldp.getStartTimeStamp()), QString::number(ldp.getEndTimeStamp())));
|
|
|
|
debug.nospace().noquote() << result;
|
|
return debug;
|
|
}
|
|
#endif // QT_NO_DEBUG_STREAM
|
|
|
|
class Discord : public QObject
|
|
{
|
|
Q_OBJECT
|
|
|
|
// Allows the functional test to simulate the logged-in Discord user:
|
|
friend class TDiscordModeTest;
|
|
|
|
public:
|
|
explicit Discord(QObject *parent = nullptr);
|
|
~Discord() override;
|
|
|
|
bool libraryLoaded();
|
|
bool usingMudletsDiscordID(Host*) const;
|
|
static QString getLoggedInUserName() { return smUserName; }
|
|
|
|
void initializeRpc();
|
|
void shutdownRpc();
|
|
void UpdatePresence();
|
|
|
|
void setServerOrigin(Host*, const Host::DiscordOptionFlag);
|
|
void clearServerOrigin(Host*, const Host::DiscordOptionFlag);
|
|
bool isServerOrigin(Host*, const Host::DiscordOptionFlag) const;
|
|
|
|
QString deduceGameName(const QString& address);
|
|
QPair<bool, QString> gameIntegrationSupported(const QString& address);
|
|
|
|
void setLargeImage(Host*, const QString&);
|
|
void setLargeImageText(Host*, const QString&);
|
|
void setSmallImage(Host*, const QString&);
|
|
void setSmallImageText(Host*, const QString&);
|
|
void setStateText(Host*, const QString&);
|
|
void setDetailText(Host*, const QString&);
|
|
void setStartTimeStamp(Host*, int64_t);
|
|
void setEndTimeStamp(Host*, int64_t);
|
|
void setParty(Host*, int);
|
|
void setParty(Host*, int, int);
|
|
bool setApplicationID(Host*, const QString&);
|
|
void resetData(Host*);
|
|
QString getApplicationId(Host* pHost) const;
|
|
|
|
// These retrieve the cached data:
|
|
QString getDetailText(Host* pHost) const { return mDetailTexts.value(pHost); }
|
|
QString getStateText(Host* pHost) const { return mStateTexts.value(pHost); }
|
|
QString getLargeImage(Host* pHost) const { return mLargeImages.value(pHost); }
|
|
QString getLargeImageText(Host* pHost) const { return mLargeImageTexts.value(pHost); }
|
|
QString getSmallImage(Host* pHost) const { return mSmallImages.value(pHost); }
|
|
QString getSmallImageText(Host* pHost) const { return mSmallImageTexts.value(pHost); }
|
|
QPair<int64_t ,int64_t> getTimeStamps(Host* pHost) const { return qMakePair(mStartTimes.value(pHost), mEndTimes.value(pHost)); }
|
|
QPair<int, int> getParty(Host* pHost) const { return qMakePair(mPartySize.value(pHost), mPartyMax.value(pHost)); }
|
|
|
|
// Runs the Host::discordUserIdMatch(...) check for the given Host:
|
|
bool discordUserIdMatch(Host* pHost) const;
|
|
|
|
|
|
const static QString mMudletApplicationId;
|
|
|
|
|
|
private:
|
|
static void handleDiscordReady(const DiscordUser* request);
|
|
static void handleDiscordDisconnected(int errorCode, const char* message);
|
|
static void handleDiscordError(int errorCode, const char* message);
|
|
static void handleDiscordJoinGame(const char* joinSecret);
|
|
static void handleDiscordSpectateGame(const char* spectateSecret);
|
|
static void handleDiscordJoinRequest(const DiscordUser* request);
|
|
|
|
void timerEvent(QTimerEvent *event) override;
|
|
|
|
std::unique_ptr<DiscordEventHandlers> mpHandlers;
|
|
|
|
// These are function pointers to functions located in the Discord RPC library:
|
|
std::function<void(const char*, DiscordEventHandlers*, int, const char*)> Discord_Initialize;
|
|
std::function<void(const DiscordRichPresence*)> Discord_UpdatePresence;
|
|
std::function<void(void)> Discord_RunCallbacks;
|
|
std::function<void(void)> Discord_Shutdown;
|
|
// Could be useful for clearing presence without tearing down the RPC connection:
|
|
// std::function<void(void)> Discord_ClearPresence;
|
|
#if defined(DISCORD_DISABLE_IO_THREAD)
|
|
// std::function<void(void)> Discord_UpdateConnection;
|
|
#endif
|
|
// std::function<void(const char*, int)> Discord_Respond;
|
|
// std::function<void(DiscordEventHandlers*)> Discord_UpdateHandlers;
|
|
|
|
bool mLoaded = false;
|
|
bool mRpcActive = false;
|
|
bool mPendingPresenceUpdate = false;
|
|
|
|
// Key is a Application Id, Value is a pointer to a local copy of the data
|
|
// currently held for that presence:
|
|
std::map<QString, std::unique_ptr<localDiscordPresence>> mPresencePtrs;
|
|
|
|
// Used to tie a profile to a particular Discord presence - multiple
|
|
// profiles can have the same presence but defaults to the nullptr one for
|
|
// Mudlet:
|
|
QMap<Host*, QString>mHostApplicationIDs;
|
|
|
|
QScopedPointer<QLibrary> mpLibrary;
|
|
|
|
// Used to hold the per profile data independently of whichever application id
|
|
// it will be used with:
|
|
QMap<Host*, int64_t>mStartTimes;
|
|
QMap<Host*, int64_t>mEndTimes;
|
|
QMap<Host*, QString>mDetailTexts;
|
|
QMap<Host*, QString>mStateTexts;
|
|
QMap<Host*, QString>mLargeImages;
|
|
QMap<Host*, QString>mLargeImageTexts;
|
|
QMap<Host*, QString>mSmallImages;
|
|
QMap<Host*, QString>mSmallImageTexts;
|
|
QMap<Host*, int>mPartySize;
|
|
QMap<Host*, int>mPartyMax;
|
|
|
|
// Tracks which presence fields were last set by the server (vs Lua).
|
|
// Uses the same bit positions as Host::DiscordOptionFlag.
|
|
QMap<Host*, Host::DiscordOptionFlags> mServerOriginFlags;
|
|
|
|
// Hash with game name as key and various URL forms that might be used for
|
|
// it as values:
|
|
QHash<QString, QVector<QString>> mKnownGames;
|
|
|
|
// The application ID that is currently the one that the Discord RPC library
|
|
// has been set to use - only ONE can be active at a time currently,
|
|
// though an Issue does exist to revise that at Discord:
|
|
// https://github.com/discordapp/discord-rpc/issues/202
|
|
QString mCurrentApplicationId;
|
|
|
|
// These are needed to validate the local user's presence on Discord to
|
|
// the one that they want to be associated with a profile's character name
|
|
// - it may be desired to not reveal the character name on Discord until
|
|
// that has confirmed that a currently active Discord/Discord-PTB/
|
|
// Discord-Canary application is using the expected User identity (reflected
|
|
// in the User Avatar image and name within that application).
|
|
static QString smUserName;
|
|
static QString smUserId;
|
|
static QString smAvatar;
|
|
};
|
|
|
|
#endif // DISCORD_H
|