diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ca06e5d1..3ae88177e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ - Minor: Tabs unhighlight when their content is read in other tabs. (#5649) - Minor: Made usernames in bits and sub messages clickable. (#5686) - Minor: Mentions of FrankerFaceZ and BetterTTV in settings are standardized as such. (#5698) +- Minor: Added support for the "Device code grant flow" for authentication. (#5680) - Bugfix: Fixed tab move animation occasionally failing to start after closing a tab. (#5426, #5612) - Bugfix: If a network request errors with 200 OK, Qt's error code is now reported instead of the HTTP status. (#5378) - Bugfix: Fixed restricted users usernames not being clickable. (#5405) diff --git a/src/Application.cpp b/src/Application.cpp index 8cb221101..9b1ede765 100644 --- a/src/Application.cpp +++ b/src/Application.cpp @@ -236,7 +236,9 @@ void Application::initialize(Settings &settings, const Paths &paths) // XXX: Loading Twitch badges after Helix has been initialized, which only happens after // the AccountController initialize has been called - this->twitchBadges->loadTwitchBadges(); + this->accounts->twitch.requestCurrent([this](const auto &) { + this->twitchBadges->loadTwitchBadges(); + }); #ifdef CHATTERINO_HAVE_PLUGINS this->plugins->initialize(settings); @@ -271,7 +273,9 @@ void Application::initialize(Settings &settings, const Paths &paths) { this->initNm(paths); } - this->twitchPubSub->initialize(); + this->accounts->twitch.requestCurrent([this](const auto &) { + this->twitchPubSub->initialize(); + }); this->initBttvLiveUpdates(); this->initSeventvEventAPI(); diff --git a/src/common/ChatterinoSetting.hpp b/src/common/ChatterinoSetting.hpp index 9db04def4..fcae2a036 100644 --- a/src/common/ChatterinoSetting.hpp +++ b/src/common/ChatterinoSetting.hpp @@ -111,7 +111,6 @@ public: _registerSetting(this->getData()); } - template EnumStringSetting &operator=(Enum newValue) { this->setValue(qmagicenum::enumNameString(newValue).toLower()); @@ -138,6 +137,21 @@ public: .value_or(this->defaultValue); } + static Enum get(const std::string &path, Enum defaultValue) + { + EnumStringSetting setting(path, defaultValue); + + return setting.getEnum(); + } + + static void set(const std::string &path, Enum newValue, + Enum defaultValue = static_cast(0)) + { + EnumStringSetting setting(path, defaultValue); + + setting = newValue; + } + Enum defaultValue; using pajlada::Settings::Setting::operator==; diff --git a/src/messages/MessageBuilder.cpp b/src/messages/MessageBuilder.cpp index 00b5eedbf..6f18b13cb 100644 --- a/src/messages/MessageBuilder.cpp +++ b/src/messages/MessageBuilder.cpp @@ -53,6 +53,7 @@ #include #include #include +#include #include #include @@ -1987,6 +1988,29 @@ MessagePtr MessageBuilder::makeLowTrustUpdateMessage( return builder.release(); } +MessagePtrMut MessageBuilder::makeAccountExpiredMessage( + const QString &expirationText) +{ + auto loginPromptText = u"Try adding your account again."_s; + + MessageBuilder builder; + auto text = expirationText % ' ' % loginPromptText; + builder->messageText = text; + builder->searchText = text; + builder->flags.set(MessageFlag::System, + MessageFlag::DoNotTriggerNotification); + + builder.emplace(); + builder.emplace(expirationText, MessageElementFlag::Text, + MessageColor::System); + builder + .emplace(loginPromptText, MessageElementFlag::Text, + MessageColor::Link) + ->setLink({Link::OpenAccountsPage, {}}); + + return builder.release(); +} + std::pair MessageBuilder::makeIrcMessage( /* mutable */ Channel *channel, const Communi::IrcMessage *ircMessage, const MessageParseArgs &args, /* mutable */ QString content, diff --git a/src/messages/MessageBuilder.hpp b/src/messages/MessageBuilder.hpp index 122348b06..298cd9f8d 100644 --- a/src/messages/MessageBuilder.hpp +++ b/src/messages/MessageBuilder.hpp @@ -258,6 +258,9 @@ public: const QVariantMap &tags, const QTime &time); + static MessagePtrMut makeAccountExpiredMessage( + const QString &expirationText); + private: struct TextState { TwitchChannel *twitchChannel = nullptr; diff --git a/src/providers/irc/IrcConnection2.cpp b/src/providers/irc/IrcConnection2.cpp index 3f9d57c97..ffc623298 100644 --- a/src/providers/irc/IrcConnection2.cpp +++ b/src/providers/irc/IrcConnection2.cpp @@ -53,7 +53,7 @@ IrcConnection::IrcConnection(QObject *parent) else { qCDebug(chatterinoIrc) << "Reconnecting"; - this->open(); + this->connectAndInitializeRequested(); } }); diff --git a/src/providers/irc/IrcConnection2.hpp b/src/providers/irc/IrcConnection2.hpp index 0e269397c..4440a9bf4 100644 --- a/src/providers/irc/IrcConnection2.hpp +++ b/src/providers/irc/IrcConnection2.hpp @@ -12,6 +12,8 @@ namespace chatterino { class IrcConnection : public Communi::IrcConnection { + Q_OBJECT + public: IrcConnection(QObject *parent = nullptr); ~IrcConnection() override; @@ -31,6 +33,11 @@ public: virtual void open(); virtual void close(); +signals: + /// Emitted when this connection intends to be connected. + /// The server should initialize this connection an open it. + void connectAndInitializeRequested(); + private: QTimer pingTimer_; QTimer reconnectTimer_; diff --git a/src/providers/twitch/IrcMessageHandler.cpp b/src/providers/twitch/IrcMessageHandler.cpp index 9864b3644..91762df54 100644 --- a/src/providers/twitch/IrcMessageHandler.cpp +++ b/src/providers/twitch/IrcMessageHandler.cpp @@ -230,29 +230,10 @@ MessagePtr parseNoticeMessage(Communi::IrcNoticeMessage *message) if (message->content().startsWith("Login auth", Qt::CaseInsensitive)) { - const auto linkColor = MessageColor(MessageColor::Link); - const auto accountsLink = Link(Link::OpenAccountsPage, QString()); const auto curUser = getApp()->getAccounts()->twitch.getCurrent(); - const auto expirationText = QString("Login expired for user \"%1\"!") - .arg(curUser->getUserName()); - const auto loginPromptText = QString("Try adding your account again."); - MessageBuilder builder; - auto text = QString("%1 %2").arg(expirationText, loginPromptText); - builder.message().messageText = text; - builder.message().searchText = text; - builder.message().flags.set(MessageFlag::System); - builder.message().flags.set(MessageFlag::DoNotTriggerNotification); - - builder.emplace(); - builder.emplace(expirationText, MessageElementFlag::Text, - MessageColor::System); - builder - .emplace(loginPromptText, MessageElementFlag::Text, - linkColor) - ->setLink(accountsLink); - - return builder.release(); + return MessageBuilder::makeAccountExpiredMessage( + u"Login expired for user \"" % curUser->getUserName() % u"\"!"); } if (message->content().startsWith("You are permanently banned ")) diff --git a/src/providers/twitch/TwitchAccount.cpp b/src/providers/twitch/TwitchAccount.cpp index 3dbbe7a10..35e265cd9 100644 --- a/src/providers/twitch/TwitchAccount.cpp +++ b/src/providers/twitch/TwitchAccount.cpp @@ -30,14 +30,74 @@ namespace chatterino { using namespace literals; -TwitchAccount::TwitchAccount(const QString &username, const QString &oauthToken, - const QString &oauthClient, const QString &userID) +std::optional TwitchAccountData::loadRaw( + const std::string &key) +{ + using QStringSetting = pajlada::Settings::Setting; + + auto username = QStringSetting::get("/accounts/" + key + "/username"); + auto userID = QStringSetting::get("/accounts/" + key + "/userID"); + auto clientID = QStringSetting::get("/accounts/" + key + "/clientID"); + auto oauthToken = QStringSetting::get("/accounts/" + key + "/oauthToken"); + + if (username.isEmpty() || userID.isEmpty() || clientID.isEmpty() || + oauthToken.isEmpty()) + { + return std::nullopt; + } + + auto accountType = EnumStringSetting::get( + "/accounts/" + key + "/accountType", + TwitchAccount::Type::ImplicitGrant); + auto refreshToken = + QStringSetting::get("/accounts/" + key + "/refreshToken"); + auto expiresAtStr = QStringSetting::get("/accounts/" + key + "/expiresAt"); + QDateTime expiresAt; + if (accountType == TwitchAccount::Type::DeviceAuth) + { + expiresAt = QDateTime::fromString(expiresAtStr, Qt::ISODate); + } + + return TwitchAccountData{ + .username = username.trimmed(), + .userID = userID.trimmed(), + .clientID = clientID.trimmed(), + .oauthToken = oauthToken.trimmed(), + .ty = accountType, + .refreshToken = refreshToken, + .expiresAt = expiresAt, + }; +} + +void TwitchAccountData::save() const +{ + using QStringSetting = pajlada::Settings::Setting; + + auto basePath = "/accounts/uid" + this->userID.toStdString(); + QStringSetting::set(basePath + "/username", this->username); + QStringSetting::set(basePath + "/userID", this->userID); + QStringSetting::set(basePath + "/clientID", this->clientID); + QStringSetting::set(basePath + "/oauthToken", this->oauthToken); + EnumStringSetting::set(basePath + "/accountType", + this->ty); + QStringSetting::set(basePath + "/refreshToken", this->refreshToken); + if (this->ty == TwitchAccount::Type::DeviceAuth) + { + QStringSetting::set(basePath + "/expiresAt", + this->expiresAt.toString(Qt::ISODate)); + } +} + +TwitchAccount::TwitchAccount(const TwitchAccountData &data) : Account(ProviderId::Twitch) - , oauthClient_(oauthClient) - , oauthToken_(oauthToken) - , userName_(username) - , userId_(userID) - , isAnon_(username == ANONYMOUS_USERNAME) + , oauthClient_(data.clientID) + , oauthToken_(data.oauthToken) + , userName_(data.username) + , userId_(data.userID) + , type_(data.ty) + , refreshToken_(data.refreshToken) + , expiresAt_(data.expiresAt) + , isAnon_(data.username == ANONYMOUS_USERNAME) , emoteSets_(std::make_shared()) , emotes_(std::make_shared()) { @@ -70,6 +130,21 @@ const QString &TwitchAccount::getUserId() const return this->userId_; } +const QString &TwitchAccount::refreshToken() const +{ + return this->refreshToken_; +} + +const QDateTime &TwitchAccount::expiresAt() const +{ + return this->expiresAt_; +} + +TwitchAccount::Type TwitchAccount::type() const +{ + return this->type_; +} + QColor TwitchAccount::color() { return this->color_.get(); @@ -80,28 +155,39 @@ void TwitchAccount::setColor(QColor color) this->color_.set(std::move(color)); } -bool TwitchAccount::setOAuthClient(const QString &newClientID) +bool TwitchAccount::setData(const TwitchAccountData &data) { - if (this->oauthClient_.compare(newClientID) == 0) + assert(this->userName_ == data.username && this->userId_ == data.userID); + + bool anyUpdate = false; + + if (this->oauthToken_ != data.oauthToken) { - return false; + this->oauthToken_ = data.oauthToken; + anyUpdate = true; + } + if (this->oauthClient_ != data.clientID) + { + this->oauthClient_ = data.clientID; + anyUpdate = true; + } + if (this->refreshToken_ != data.refreshToken) + { + this->refreshToken_ = data.refreshToken; + anyUpdate = true; + } + if (this->expiresAt_ != data.expiresAt) + { + this->expiresAt_ = data.expiresAt; + anyUpdate = true; + } + if (this->type_ != data.ty) + { + this->type_ = data.ty; + anyUpdate = true; } - this->oauthClient_ = newClientID; - - return true; -} - -bool TwitchAccount::setOAuthToken(const QString &newOAuthToken) -{ - if (this->oauthToken_.compare(newOAuthToken) == 0) - { - return false; - } - - this->oauthToken_ = newOAuthToken; - - return true; + return anyUpdate; } bool TwitchAccount::isAnon() const diff --git a/src/providers/twitch/TwitchAccount.hpp b/src/providers/twitch/TwitchAccount.hpp index fe191a850..e14668b50 100644 --- a/src/providers/twitch/TwitchAccount.hpp +++ b/src/providers/twitch/TwitchAccount.hpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -26,11 +27,41 @@ namespace chatterino { class Channel; using ChannelPtr = std::shared_ptr; +struct TwitchAccountData; + class TwitchAccount : public Account { public: - TwitchAccount(const QString &username, const QString &oauthToken_, - const QString &oauthClient_, const QString &_userID); + enum class Type : uint32_t { + /// Tokens as obtained from https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#implicit-grant-flow + ImplicitGrant, + /// Tokens as obtained from https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#device-code-grant-flow + DeviceAuth, + }; + struct TwitchEmote { + EmoteId id; + EmoteName name; + }; + + struct EmoteSet { + QString key; + QString channelName; + QString channelID; + QString text; + bool subscriber{false}; + bool local{false}; + std::vector emotes; + }; + + struct TwitchAccountEmoteData { + std::vector> emoteSets; + + // this EmoteMap should contain all emotes available globally + // excluding locally available emotes, such as follower ones + EmoteMap emotes; + }; + + TwitchAccount(const TwitchAccountData &data); ~TwitchAccount() override; TwitchAccount(const TwitchAccount &) = delete; TwitchAccount(TwitchAccount &&) = delete; @@ -43,6 +74,9 @@ public: const QString &getOAuthToken() const; const QString &getOAuthClient() const; const QString &getUserId() const; + [[nodiscard]] const QString &refreshToken() const; + [[nodiscard]] const QDateTime &expiresAt() const; + [[nodiscard]] Type type() const; /** * The Seventv user-id of the current user. @@ -53,13 +87,10 @@ public: QColor color(); void setColor(QColor color); - // Attempts to update the users OAuth Client ID - // Returns true if the value has changed, otherwise false - bool setOAuthClient(const QString &newClientID); - - // Attempts to update the users OAuth Token - // Returns true if the value has changed, otherwise false - bool setOAuthToken(const QString &newOAuthToken); + /// Attempts to update the account data + /// @pre The name and userID must match this account. + /// @returns true if the value has changed, otherwise false + bool setData(const TwitchAccountData &data); bool isAnon() const; @@ -112,6 +143,9 @@ private: QString oauthToken_; QString userName_; QString userId_; + Type type_ = Type::ImplicitGrant; + QString refreshToken_; + QDateTime expiresAt_; const bool isAnon_; Atomic color_; @@ -128,4 +162,17 @@ private: QString seventvUserID_; }; +struct TwitchAccountData { + QString username; + QString userID; + QString clientID; + QString oauthToken; + TwitchAccount::Type ty = TwitchAccount::Type::ImplicitGrant; + QString refreshToken; + QDateTime expiresAt; + + static std::optional loadRaw(const std::string &key); + void save() const; +}; + } // namespace chatterino diff --git a/src/providers/twitch/TwitchAccountManager.cpp b/src/providers/twitch/TwitchAccountManager.cpp index 7f4ed78a0..41a651eee 100644 --- a/src/providers/twitch/TwitchAccountManager.cpp +++ b/src/providers/twitch/TwitchAccountManager.cpp @@ -3,18 +3,132 @@ #include "Application.hpp" #include "common/Args.hpp" #include "common/Common.hpp" +#include "common/Literals.hpp" +#include "common/network/NetworkResult.hpp" +#include "common/Outcome.hpp" #include "common/QLogging.hpp" +#include "messages/MessageBuilder.hpp" #include "providers/twitch/api/Helix.hpp" #include "providers/twitch/TwitchAccount.hpp" #include "providers/twitch/TwitchCommon.hpp" +#include "providers/twitch/TwitchIrcServer.hpp" #include "providers/twitch/TwitchUser.hpp" #include "util/SharedPtrElementLess.hpp" +#include + namespace chatterino { +using namespace literals; + +const QString DEVICE_AUTH_CLIENT_ID = u"ows8k58flcricj1oe1pm53eb78xwql"_s; +const QString DEVICE_AUTH_SCOPES = + u""_s + "channel:moderate" // for seeing automod & which moderator banned/unbanned a user (felanbird unbanned weeb123) + " channel:read:redemptions" // for getting the list of channel point redemptions (not currently used) + " chat:edit" // for sending messages in chat + " chat:read" // for viewing messages in chat + " whispers:read" // for viewing recieved whispers + + // https://dev.twitch.tv/docs/api/reference#start-commercial + " channel:edit:commercial" // for /commercial api + + // https://dev.twitch.tv/docs/api/reference#create-clip + " clips:edit" // for /clip creation + + // https://dev.twitch.tv/docs/api/reference#create-stream-marker + // https://dev.twitch.tv/docs/api/reference#modify-channel-information + " channel:manage:broadcast" // for creating stream markers with /marker command, and for the /settitle and /setgame commands + + // https://dev.twitch.tv/docs/api/reference#get-user-block-list + " user:read:blocked_users" // for getting list of blocked users + + // https://dev.twitch.tv/docs/api/reference#block-user + // https://dev.twitch.tv/docs/api/reference#unblock-user + " user:manage:blocked_users" // for blocking/unblocking other users + + // https://dev.twitch.tv/docs/api/reference#manage-held-automod-messages + " moderator:manage:automod" // for approving/denying automod messages + + // https://dev.twitch.tv/docs/api/reference#start-a-raid + // https://dev.twitch.tv/docs/api/reference#cancel-a-raid + " channel:manage:raids" // for starting/canceling raids + + // https://dev.twitch.tv/docs/api/reference#create-poll + // https://dev.twitch.tv/docs/api/reference#end-poll + " channel:manage:polls" // for creating & ending polls (not currently used) + + // https://dev.twitch.tv/docs/api/reference#get-polls + " channel:read:polls" // for reading broadcaster poll status (not currently used) + + // https://dev.twitch.tv/docs/api/reference#create-prediction + // https://dev.twitch.tv/docs/api/reference#end-prediction + " channel:manage:predictions" // for creating & ending predictions (not currently used) + + // https://dev.twitch.tv/docs/api/reference#get-predictions + " channel:read:predictions" // for reading broadcaster prediction status (not currently used) + + // https://dev.twitch.tv/docs/api/reference#send-chat-announcement + " moderator:manage:announcements" // for /announce api + + // https://dev.twitch.tv/docs/api/reference#send-whisper + " user:manage:whispers" // for whispers api + + // https://dev.twitch.tv/docs/api/reference#ban-user + // https://dev.twitch.tv/docs/api/reference#unban-user + " moderator:manage:banned_users" // for ban/unban/timeout/untimeout api + + // https://dev.twitch.tv/docs/api/reference#delete-chat-messages + " moderator:manage:chat_messages" // for delete message api (/delete, /clear) + + // https://dev.twitch.tv/docs/api/reference#update-user-chat-color + " user:manage:chat_color" // for update user color api (/color coral) + + // https://dev.twitch.tv/docs/api/reference#get-chat-settings + " moderator:manage:chat_settings" // for roomstate api (/followersonly, /uniquechat, /slow) + + // https://dev.twitch.tv/docs/api/reference#get-moderators + // https://dev.twitch.tv/docs/api/reference#add-channel-moderator + // https://dev.twitch.tv/docs/api/reference#remove-channel-vip + " channel:manage:moderators" // for add/remove/view mod api + + // https://dev.twitch.tv/docs/api/reference#add-channel-vip + // https://dev.twitch.tv/docs/api/reference#remove-channel-vip + // https://dev.twitch.tv/docs/api/reference#get-vips + " channel:manage:vips" // for add/remove/view vip api + + // https://dev.twitch.tv/docs/api/reference#get-chatters + " moderator:read:chatters" // for get chatters api + + // https://dev.twitch.tv/docs/api/reference#get-shield-mode-status + // https://dev.twitch.tv/docs/api/reference#update-shield-mode-status + " moderator:manage:shield_mode" // for reading/managing the channel's shield-mode status + + // https://dev.twitch.tv/docs/api/reference/#send-a-shoutout + " moderator:manage:shoutouts" // for reading/managing the channel's shoutouts (not currently used) + + // https://dev.twitch.tv/docs/api/reference/#get-moderated-channels + " user:read:moderated_channels" // for reading where the user is modded (not currently used) + + // https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types/#channelchatmessage + " user:read:chat" // for reading chat via eventsub (in progress) + + // https://dev.twitch.tv/docs/api/reference/#send-chat-message + " user:write:chat" // for sending chat messages via helix (in testing) + + // https://dev.twitch.tv/docs/api/reference/#get-user-emotes + " user:read:emotes" // for fetching emotes that a user can use via helix + + // https://dev.twitch.tv/docs/api/reference/#warn-chat-user + " moderator:manage:warnings" // for /warn api (and channel.moderate v2 eventsub in the future) + + // https://dev.twitch.tv/docs/api/reference/#get-followed-channels + " user:read:follows" // for determining if the current user follows a streamer + ; + TwitchAccountManager::TwitchAccountManager() : accounts(SharedPtrElementLess{}) - , anonymousUser_(new TwitchAccount(ANONYMOUS_USERNAME, "", "", "")) + , anonymousUser_(new TwitchAccount({.username = ANONYMOUS_USERNAME})) { this->currentUserChanged.connect([this] { auto currentUser = this->getCurrent(); @@ -27,6 +141,11 @@ TwitchAccountManager::TwitchAccountManager() std::ignore = this->accounts.itemRemoved.connect([this](const auto &acc) { this->removeUser(acc.item.get()); }); + + this->refreshTask_.start(60000); + QObject::connect(&this->refreshTask_, &QTimer::timeout, [this] { + this->refreshAccounts(false); + }); } std::shared_ptr TwitchAccountManager::getCurrent() @@ -78,8 +197,6 @@ void TwitchAccountManager::reloadUsers() { auto keys = pajlada::Settings::SettingManager::getObjectKeys("/accounts"); - UserData userData; - bool listUpdated = false; for (const auto &uid : keys) @@ -89,39 +206,25 @@ void TwitchAccountManager::reloadUsers() continue; } - auto username = pajlada::Settings::Setting::get( - "/accounts/" + uid + "/username"); - auto userID = pajlada::Settings::Setting::get("/accounts/" + - uid + "/userID"); - auto clientID = pajlada::Settings::Setting::get( - "/accounts/" + uid + "/clientID"); - auto oauthToken = pajlada::Settings::Setting::get( - "/accounts/" + uid + "/oauthToken"); - - if (username.isEmpty() || userID.isEmpty() || clientID.isEmpty() || - oauthToken.isEmpty()) + auto userData = TwitchAccountData::loadRaw(uid); + if (!userData) { continue; } - userData.username = username.trimmed(); - userData.userID = userID.trimmed(); - userData.clientID = clientID.trimmed(); - userData.oauthToken = oauthToken.trimmed(); - - switch (this->addUser(userData)) + switch (this->addUser(*userData)) { case AddUserResponse::UserAlreadyExists: { qCDebug(chatterinoTwitch) - << "User" << userData.username << "already exists"; + << "User" << userData->username << "already exists"; // Do nothing } break; case AddUserResponse::UserValuesUpdated: { qCDebug(chatterinoTwitch) - << "User" << userData.username + << "User" << userData->username << "already exists, and values updated!"; - if (userData.username == this->getCurrent()->getUserName()) + if (userData->username == this->getCurrent()->getUserName()) { qCDebug(chatterinoTwitch) << "It was the current user, so we need to " @@ -131,7 +234,7 @@ void TwitchAccountManager::reloadUsers() } break; case AddUserResponse::UserAdded: { - qCDebug(chatterinoTwitch) << "Added user" << userData.username; + qCDebug(chatterinoTwitch) << "Added user" << userData->username; listUpdated = true; } break; @@ -168,8 +271,10 @@ void TwitchAccountManager::load() this->currentUser_ = this->anonymousUser_; } - this->currentUserChanged(); - this->currentUser_->reloadEmotes(); + this->refreshAccounts(true); + this->requestCurrentChecked([this](const auto & /*current*/) { + this->currentUser_->reloadEmotes(); + }); }); } @@ -208,37 +313,189 @@ bool TwitchAccountManager::removeUser(TwitchAccount *account) return true; } +void TwitchAccountManager::refreshAccounts(bool emitChanged) +{ + assertInGuiThread(); + if (this->isRefreshing_) + { + return; + } + this->isRefreshing_ = true; + + auto launchedRequests = std::make_shared(1); + auto tryFlush = [this, launchedRequests, emitChanged] { + if (*launchedRequests == 0) + { + assert(false && "Called tryFlush after a flush"); + return; + } + + if (--(*launchedRequests) == 0) + { + this->isRefreshing_ = false; + auto consumers = std::exchange(this->pendingUserConsumers_, {}); + for (const auto &consumer : consumers) + { + consumer(this->currentUser_); + } + if (emitChanged) + { + this->currentUserChanged(); + } + } + }; + + qCDebug(chatterinoTwitch) << "Checking for accounts to refresh"; + + auto current = this->currentUser_; + auto now = QDateTime::currentDateTimeUtc(); + for (const auto &account : *this->accounts.readOnly()) + { + if (account->isAnon() || + account->type() != TwitchAccount::Type::DeviceAuth) + { + continue; + } + if (now.secsTo(account->expiresAt()) >= 100) + { + continue; + } + (*launchedRequests)++; + qCDebug(chatterinoTwitch) + << "Refreshing user" << account->getUserName(); + + QUrlQuery query{ + {u"client_id"_s, DEVICE_AUTH_CLIENT_ID}, + {u"scope"_s, DEVICE_AUTH_SCOPES}, + {u"refresh_token"_s, account->refreshToken()}, + {u"grant_type"_s, u"refresh_token"_s}, + }; + NetworkRequest("https://id.twitch.tv/oauth2/token", + NetworkRequestType::Post) + .payload(query.toString(QUrl::FullyEncoded).toUtf8()) + .timeout(20000) + .onSuccess([account, current](const auto &res) { + const auto json = res.parseJson(); + auto accessToken = json["access_token"_L1].toString(); + auto refreshToken = json["refresh_token"_L1].toString(); + auto expiresIn = json["expires_in"_L1].toInt(-1); + if (accessToken.isEmpty() || refreshToken.isEmpty() || + expiresIn <= 0) + { + qCWarning(chatterinoTwitch) + << "Received invalid OAuth response when refreshing" + << account->getUserName(); + return; + } + auto expiresAt = + QDateTime::currentDateTimeUtc().addSecs(expiresIn - 120); + TwitchAccountData data{ + .username = account->getUserName(), + .userID = account->getUserId(), + .clientID = DEVICE_AUTH_CLIENT_ID, + .oauthToken = accessToken, + .ty = TwitchAccount::Type::DeviceAuth, + .refreshToken = refreshToken, + .expiresAt = expiresAt, + }; + data.save(); + account->setData(data); + pajlada::Settings::SettingManager::getInstance()->save(); + qCDebug(chatterinoTwitch) + << "Refreshed user" << account->getUserName(); + + if (account == current) + { + getHelix()->update(DEVICE_AUTH_CLIENT_ID, + account->getOAuthToken()); + } + }) + .onError([this, account](const auto &res) { + auto json = res.parseJson(); + QString message = u"Failed to refresh OAuth token for " % + account->getUserName() % u" (" % + res.formatError() % u" - " % + json["message"_L1].toString(u"no message"_s) % + u")."; + qCWarning(chatterinoTwitch) << message; + + if (account == this->getCurrent()) + { + if (res.status().value_or(0) == 400) // invalid token + { + auto msg = + MessageBuilder::makeAccountExpiredMessage(message); + getApp()->getTwitch()->forEachChannel( + [msg](const auto &chan) { + chan->addMessage(msg, MessageContext::Original); + }); + } + else + { + getApp()->getTwitch()->addGlobalSystemMessage(message); + } + } + }) + .finally(tryFlush) + .execute(); + } + tryFlush(); // if no account was refreshed +} + +void TwitchAccountManager::requestCurrent(UserCallback cb) +{ + assertInGuiThread(); + if (this->isRefreshing_) + { + this->pendingUserConsumers_.emplace_back(std::move(cb)); + return; + } + + cb(this->currentUser_); +} + +void TwitchAccountManager::requestCurrentChecked(UserCallback cb) +{ + assertInGuiThread(); + this->recheckRefresher(); + this->requestCurrent(std::move(cb)); +} + +void TwitchAccountManager::recheckRefresher() +{ + auto now = QDateTime::currentDateTimeUtc(); + for (const auto &account : *this->accounts.readOnly()) + { + if (account->isAnon() || + account->type() != TwitchAccount::Type::DeviceAuth) + { + continue; + } + if (now.secsTo(account->expiresAt()) < 1000) + { + this->refreshAccounts(false); + return; + } + } +} + TwitchAccountManager::AddUserResponse TwitchAccountManager::addUser( - const TwitchAccountManager::UserData &userData) + const TwitchAccountData &userData) { auto previousUser = this->findUserByUsername(userData.username); if (previousUser) { - bool userUpdated = false; - - if (previousUser->setOAuthClient(userData.clientID)) - { - userUpdated = true; - } - - if (previousUser->setOAuthToken(userData.oauthToken)) - { - userUpdated = true; - } + bool userUpdated = previousUser->setData(userData); if (userUpdated) { return AddUserResponse::UserValuesUpdated; } - else - { - return AddUserResponse::UserAlreadyExists; - } + + return AddUserResponse::UserAlreadyExists; } - auto newUser = - std::make_shared(userData.username, userData.oauthToken, - userData.clientID, userData.userID); + auto newUser = std::make_shared(userData); // std::lock_guard lock(this->mutex); diff --git a/src/providers/twitch/TwitchAccountManager.hpp b/src/providers/twitch/TwitchAccountManager.hpp index d0e21111d..ce6698318 100644 --- a/src/providers/twitch/TwitchAccountManager.hpp +++ b/src/providers/twitch/TwitchAccountManager.hpp @@ -8,6 +8,7 @@ #include #include +#include #include #include @@ -22,24 +23,30 @@ namespace chatterino { class TwitchAccount; +struct TwitchAccountData; class AccountController; +extern const QString DEVICE_AUTH_SCOPES; +extern const QString DEVICE_AUTH_CLIENT_ID; + class TwitchAccountManager { TwitchAccountManager(); public: - struct UserData { - QString username; - QString userID; - QString clientID; - QString oauthToken; - }; + struct UserData; + using UserCallback = + std::function &)>; // Returns the current twitchUsers, or the anonymous user if we're not // currently logged in std::shared_ptr getCurrent(); + void requestCurrent(UserCallback cb); + void requestCurrentChecked(UserCallback cb); + + void recheckRefresher(); + std::vector getUsernames() const; std::shared_ptr findUserByUsername( @@ -69,8 +76,13 @@ private: UserValuesUpdated, UserAdded, }; - AddUserResponse addUser(const UserData &data); + AddUserResponse addUser(const TwitchAccountData &data); bool removeUser(TwitchAccount *account); + void refreshAccounts(bool emitChanged); + + bool isRefreshing_ = false; + std::vector pendingUserConsumers_; + QTimer refreshTask_; std::shared_ptr currentUser_; diff --git a/src/providers/twitch/TwitchChannel.cpp b/src/providers/twitch/TwitchChannel.cpp index 0371fd295..e49f9b2e1 100644 --- a/src/providers/twitch/TwitchChannel.cpp +++ b/src/providers/twitch/TwitchChannel.cpp @@ -100,6 +100,10 @@ TwitchChannel::TwitchChannel(const QString &name) this->bSignals_.emplace_back( getApp()->getAccounts()->twitch.currentUserChanged.connect([this] { + if (this->roomId().isEmpty()) + { + return; + } this->setMod(false); this->refreshPubSub(); this->refreshTwitchChannelEmotes(false); @@ -234,6 +238,21 @@ void TwitchChannel::setLocalizedName(const QString &name) } void TwitchChannel::refreshTwitchChannelEmotes(bool manualRefresh) +{ + // ensure the user is authenticated + getApp()->getAccounts()->twitch.requestCurrentChecked( + [weak{this->weak_from_this()}, manualRefresh](const auto &account) { + auto self = std::dynamic_pointer_cast(weak.lock()); + if (!self) + { + return; + } + self->refreshTwitchChannelEmotesFor(account, manualRefresh); + }); +} + +void TwitchChannel::refreshTwitchChannelEmotesFor( + const std::shared_ptr &account, bool manualRefresh) { if (getApp()->isTest()) { @@ -242,15 +261,14 @@ void TwitchChannel::refreshTwitchChannelEmotes(bool manualRefresh) if (manualRefresh) { - getApp()->getAccounts()->twitch.getCurrent()->reloadEmotes(this); + account->reloadEmotes(this); } // Twitch's 'Get User Emotes' doesn't assigns a different set-ID to follower // emotes compared to subscriber emotes. QString setID = TWITCH_SUB_EMOTE_SET_PREFIX % this->roomId(); this->localTwitchEmoteSetID_.set(setID); - if (getApp()->getAccounts()->twitch.getCurrent()->hasEmoteSet( - EmoteSetId{setID})) + if (account->hasEmoteSet(EmoteSetId{setID})) { this->localTwitchEmotes_.set(std::make_shared()); return; @@ -273,8 +291,7 @@ void TwitchChannel::refreshTwitchChannelEmotes(bool manualRefresh) }; getHelix()->getFollowedChannel( - getApp()->getAccounts()->twitch.getCurrent()->getUserId(), - this->roomId(), nullptr, + account->getUserId(), this->roomId(), nullptr, [weak{this->weak_from_this()}, makeEmotes](const auto &chan) { auto self = std::dynamic_pointer_cast(weak.lock()); if (!self || !chan) diff --git a/src/providers/twitch/TwitchChannel.hpp b/src/providers/twitch/TwitchChannel.hpp index 484b52bbb..5882e4c27 100644 --- a/src/providers/twitch/TwitchChannel.hpp +++ b/src/providers/twitch/TwitchChannel.hpp @@ -35,6 +35,7 @@ struct Emote; using EmotePtr = std::shared_ptr; class EmoteMap; +class TwitchAccount; class TwitchBadges; class FfzEmotes; class BttvEmotes; @@ -342,6 +343,9 @@ private: void cleanUpReplyThreads(); void showLoginMessage(); + void refreshTwitchChannelEmotesFor( + const std::shared_ptr &account, bool manualRefresh); + /// roomIdChanged is called whenever this channel's ID has been changed /// This should only happen once per channel, whenever the ID goes from unset to set void roomIdChanged(); diff --git a/src/providers/twitch/TwitchIrcServer.cpp b/src/providers/twitch/TwitchIrcServer.cpp index 901ac0525..d0d366ed1 100644 --- a/src/providers/twitch/TwitchIrcServer.cpp +++ b/src/providers/twitch/TwitchIrcServer.cpp @@ -225,6 +225,19 @@ TwitchIrcServer::TwitchIrcServer() this->connections_.managedConnect(this->readConnection_->heartbeat, [this] { this->markChannelsConnected(); }); + + QObject::connect( + this->writeConnection_.get(), + &IrcConnection::connectAndInitializeRequested, this, [this]() { + this->initializeConnection(this->writeConnection_.get(), + ConnectionType::Write); + }); + QObject::connect(this->readConnection_.get(), + &IrcConnection::connectAndInitializeRequested, this, + [this]() { + this->initializeConnection(this->readConnection_.get(), + ConnectionType::Read); + }); } void TwitchIrcServer::initialize() @@ -712,49 +725,50 @@ void TwitchIrcServer::initialize() void TwitchIrcServer::initializeConnection(IrcConnection *connection, ConnectionType type) { - std::shared_ptr account = - getApp()->getAccounts()->twitch.getCurrent(); + getApp()->getAccounts()->twitch.requestCurrentChecked([this, connection, + type](const auto + &account) { + qCDebug(chatterinoTwitch) << "logging in as" << account->getUserName(); - qCDebug(chatterinoTwitch) << "logging in as" << account->getUserName(); + // twitch.tv/tags enables IRCv3 tags on messages. See https://dev.twitch.tv/docs/irc/tags + // twitch.tv/commands enables a bunch of miscellaneous command capabilities. See https://dev.twitch.tv/docs/irc/commands + // twitch.tv/membership enables the JOIN/PART/NAMES commands. See https://dev.twitch.tv/docs/irc/membership + // This is enabled so we receive USERSTATE messages when joining channels / typing messages, along with the other command capabilities + QStringList caps{"twitch.tv/tags", "twitch.tv/commands"}; + if (type != ConnectionType::Write) + { + caps.push_back("twitch.tv/membership"); + } - // twitch.tv/tags enables IRCv3 tags on messages. See https://dev.twitch.tv/docs/irc/tags - // twitch.tv/commands enables a bunch of miscellaneous command capabilities. See https://dev.twitch.tv/docs/irc/commands - // twitch.tv/membership enables the JOIN/PART/NAMES commands. See https://dev.twitch.tv/docs/irc/membership - // This is enabled so we receive USERSTATE messages when joining channels / typing messages, along with the other command capabilities - QStringList caps{"twitch.tv/tags", "twitch.tv/commands"}; - if (type != ConnectionType::Write) - { - caps.push_back("twitch.tv/membership"); - } + connection->network()->setSkipCapabilityValidation(true); + connection->network()->setRequestedCapabilities(caps); - connection->network()->setSkipCapabilityValidation(true); - connection->network()->setRequestedCapabilities(caps); + QString username = account->getUserName(); + QString oauthToken = account->getOAuthToken(); - QString username = account->getUserName(); - QString oauthToken = account->getOAuthToken(); + if (!oauthToken.startsWith("oauth:")) + { + oauthToken.prepend("oauth:"); + } - if (!oauthToken.startsWith("oauth:")) - { - oauthToken.prepend("oauth:"); - } + connection->setUserName(username); + connection->setNickName(username); + connection->setRealName(username); - connection->setUserName(username); - connection->setNickName(username); - connection->setRealName(username); + if (!account->isAnon()) + { + connection->setPassword(oauthToken); + } - if (!account->isAnon()) - { - connection->setPassword(oauthToken); - } + // https://dev.twitch.tv/docs/irc#connecting-to-the-twitch-irc-server + // SSL disabled: irc://irc.chat.twitch.tv:6667 (or port 80) + // SSL enabled: irc://irc.chat.twitch.tv:6697 (or port 443) + connection->setHost(Env::get().twitchServerHost); + connection->setPort(Env::get().twitchServerPort); + connection->setSecure(Env::get().twitchServerSecure); - // https://dev.twitch.tv/docs/irc#connecting-to-the-twitch-irc-server - // SSL disabled: irc://irc.chat.twitch.tv:6667 (or port 80) - // SSL enabled: irc://irc.chat.twitch.tv:6697 (or port 443) - connection->setHost(Env::get().twitchServerHost); - connection->setPort(Env::get().twitchServerPort); - connection->setSecure(Env::get().twitchServerSecure); - - this->open(type); + this->open(type); + }); } std::shared_ptr TwitchIrcServer::createChannel( diff --git a/src/widgets/dialogs/LoginDialog.cpp b/src/widgets/dialogs/LoginDialog.cpp index 5664acc35..fde38b943 100644 --- a/src/widgets/dialogs/LoginDialog.cpp +++ b/src/widgets/dialogs/LoginDialog.cpp @@ -2,10 +2,15 @@ #include "Application.hpp" #include "common/Common.hpp" +#include "common/Literals.hpp" #include "common/network/NetworkRequest.hpp" +#include "common/network/NetworkResult.hpp" +#include "common/Outcome.hpp" #include "common/QLogging.hpp" #include "controllers/accounts/AccountController.hpp" +#include "providers/twitch/api/Helix.hpp" #include "providers/twitch/TwitchAccount.hpp" +#include "singletons/StreamerMode.hpp" #include "util/Clipboard.hpp" #include "util/Helpers.hpp" @@ -17,62 +22,369 @@ #include #include #include +#include #include +#include #include - -namespace chatterino { +#include namespace { - bool logInWithCredentials(QWidget *parent, const QString &userID, - const QString &username, const QString &clientID, - const QString &oauthToken) +using namespace chatterino; +using namespace literals; + +bool logInWithImplicitGrantCredentials(QWidget *parent, const QString &userID, + const QString &username, + const QString &clientID, + const QString &oauthToken) +{ + QStringList errors; + + if (userID.isEmpty()) { - QStringList errors; - - if (userID.isEmpty()) - { - errors.append("Missing user ID"); - } - if (username.isEmpty()) - { - errors.append("Missing username"); - } - if (clientID.isEmpty()) - { - errors.append("Missing Client ID"); - } - if (oauthToken.isEmpty()) - { - errors.append("Missing OAuth Token"); - } - - if (errors.length() > 0) - { - QMessageBox messageBox(parent); - messageBox.setWindowTitle("Invalid account credentials"); - messageBox.setIcon(QMessageBox::Critical); - messageBox.setText(errors.join("
")); - messageBox.exec(); - return false; - } - - std::string basePath = "/accounts/uid" + userID.toStdString(); - pajlada::Settings::Setting::set(basePath + "/username", - username); - pajlada::Settings::Setting::set(basePath + "/userID", userID); - pajlada::Settings::Setting::set(basePath + "/clientID", - clientID); - pajlada::Settings::Setting::set(basePath + "/oauthToken", - oauthToken); - - getApp()->getAccounts()->twitch.reloadUsers(); - getApp()->getAccounts()->twitch.currentUsername = username; - return true; + errors.append("Missing user ID"); + } + if (username.isEmpty()) + { + errors.append("Missing username"); + } + if (clientID.isEmpty()) + { + errors.append("Missing Client ID"); + } + if (oauthToken.isEmpty()) + { + errors.append("Missing OAuth Token"); } + if (!errors.empty()) + { + QMessageBox messageBox(parent); + messageBox.setWindowTitle("Invalid account credentials"); + messageBox.setIcon(QMessageBox::Critical); + messageBox.setText(errors.join("
")); + messageBox.exec(); + return false; + } + + TwitchAccountData{ + .username = username, + .userID = userID, + .clientID = clientID, + .oauthToken = oauthToken, + .ty = TwitchAccount::Type::ImplicitGrant, + .refreshToken = {}, + .expiresAt = {}, + } + .save(); + + getApp()->getAccounts()->twitch.reloadUsers(); + getApp()->getAccounts()->twitch.currentUsername = username; + return true; +} + +class DeviceLoginWidget : public QWidget +{ +public: + DeviceLoginWidget(); + +private: + void reset(const QString &prevError = {}); + void tryInitSession(const QJsonObject &response); + void displayError(const QString &error); + + void ping(); + + void updateCurrentWidget(QWidget *next); + + QHBoxLayout layout; + QLabel *detailLabel = nullptr; + + QTimer expiryTimer_; + QTimer pingTimer_; + QString verificationUri_; + QString userCode_; + QString deviceCode_; +}; + +DeviceLoginWidget::DeviceLoginWidget() +{ + QObject::connect(&this->pingTimer_, &QTimer::timeout, [this] { + this->ping(); + }); + QObject::connect(&this->expiryTimer_, &QTimer::timeout, [this] { + this->reset(u"The code expired."_s); + }); + this->setLayout(&this->layout); + this->reset(); +} + +void DeviceLoginWidget::updateCurrentWidget(QWidget *next) +{ + // clear the layout + QLayoutItem *prev = nullptr; + while ((prev = this->layout.takeAt(0))) + { + delete prev->widget(); + delete prev; + } + + // insert the item + this->layout.addWidget(next, 1, Qt::AlignCenter); +} + +void DeviceLoginWidget::reset(const QString &prevError) +{ + this->expiryTimer_.stop(); + this->pingTimer_.stop(); + + auto *wrap = new QWidget; + auto *layout = new QVBoxLayout(wrap); + + auto *titleLabel = new QLabel(u"Click on 'Start' to connect an account!"_s); + this->detailLabel = new QLabel(prevError); + this->detailLabel->setWordWrap(true); + layout->addWidget(titleLabel, 1, Qt::AlignCenter); + layout->addWidget(this->detailLabel, 0, Qt::AlignCenter); + + auto *startButton = new QPushButton(u"Start"_s); + connect(startButton, &QPushButton::clicked, this, [this] { + QUrlQuery query{ + {u"client_id"_s, DEVICE_AUTH_CLIENT_ID}, + {u"scopes"_s, DEVICE_AUTH_SCOPES}, + }; + NetworkRequest(u"https://id.twitch.tv/oauth2/device"_s, + NetworkRequestType::Post) + .payload(query.toString(QUrl::FullyEncoded).toUtf8()) + .timeout(10000) + .caller(this) + .onSuccess([this](const auto &res) { + this->tryInitSession(res.parseJson()); + return Success; + }) + .onError([this](const auto &res) { + const auto json = res.parseJson(); + this->displayError( + json["message"_L1].toString(u"error: (no message)"_s)); + }) + .execute(); + }); + layout->addWidget(startButton); + + this->updateCurrentWidget(wrap); +} + +void DeviceLoginWidget::tryInitSession(const QJsonObject &response) +{ + auto getString = [&](auto key, QString &dest) { + const auto val = response[key]; + if (!val.isString()) + { + return false; + } + dest = val.toString(); + return true; + }; + if (!getString("device_code"_L1, this->deviceCode_)) + { + this->displayError(u"Failed to initialize: missing 'device_code'"_s); + return; + } + if (!getString("user_code"_L1, this->userCode_)) + { + this->displayError(u"Failed to initialize: missing 'user_code'"_s); + return; + } + if (!getString("verification_uri"_L1, this->verificationUri_)) + { + this->displayError( + u"Failed to initialize: missing 'verification_uri'"_s); + return; + } + const auto expiry = response["expires_in"_L1]; + if (!expiry.isDouble()) + { + this->displayError(u"Failed to initialize: missing 'expires_in'"_s); + return; + } + this->expiryTimer_.start(expiry.toInt(1800) * 1000); + this->pingTimer_.start(response["interval"_L1].toInt(5) * 1000); + + auto *wrap = new QWidget; + auto *layout = new QVBoxLayout(wrap); + + // A simplified link split by the code, such that + // prefixUrl is the part before the code and postfixUrl + // is the part after the code. + auto [prefixUrl, postfixUrl] = [&] { + QStringView view(this->verificationUri_); + // TODO(Qt 6): use .sliced() + if (view.startsWith(u"https://")) + { + view = view.mid(8); + } + if (view.startsWith(u"www.")) + { + view = view.mid(4); + } + + auto idx = view.indexOf(this->userCode_); + if (idx < 0) + { + return std::make_tuple(view, QStringView()); + } + + return std::make_tuple(view.mid(0, idx), + view.mid(idx + this->userCode_.length())); + }(); + + // + // {prefixUrl} + // {userCode} + // {postfixUrl} + // + auto *userCode = new QLabel( + u"%2%3%4"_s + .arg(this->verificationUri_, prefixUrl, this->userCode_, + postfixUrl)); + userCode->setOpenExternalLinks(true); + userCode->setTextInteractionFlags(Qt::TextBrowserInteraction); + if (getApp()->getStreamerMode()->isEnabled()) + { + userCode->setText( + u"You're in streamer mode.\nUse the buttons below.\nDon't show the code on stream!"_s); + } + layout->addWidget(userCode, 1, Qt::AlignCenter); + + this->detailLabel = new QLabel; + layout->addWidget(this->detailLabel, 0, Qt::AlignCenter); + + { + auto *hbox = new QHBoxLayout; + + auto addButton = [&](auto text, auto handler) { + auto *button = new QPushButton(text); + connect(button, &QPushButton::clicked, handler); + hbox->addWidget(button, 1); + }; + addButton(u"Copy code"_s, [this] { + crossPlatformCopy(this->userCode_); + }); + addButton(u"Copy URL"_s, [this] { + crossPlatformCopy(this->verificationUri_); + }); + addButton(u"Open URL"_s, [this] { + if (!QDesktopServices::openUrl(QUrl(this->verificationUri_))) + { + qCWarning(chatterinoWidget) << "open login in browser failed"; + this->displayError(u"Failed to open browser"_s); + } + }); + layout->addLayout(hbox, 1); + } + + this->updateCurrentWidget(wrap); +} + +void DeviceLoginWidget::displayError(const QString &error) +{ + if (this->detailLabel) + { + this->detailLabel->setText(error); + } + else + { + qCWarning(chatterinoWidget) + << "Tried to display error but no detail label was found - error:" + << error; + } +} + +void DeviceLoginWidget::ping() +{ + QUrlQuery query{ + {u"client_id"_s, DEVICE_AUTH_CLIENT_ID}, + {u"scope"_s, DEVICE_AUTH_SCOPES}, + {u"device_code"_s, this->deviceCode_}, + {u"grant_type"_s, u"urn:ietf:params:oauth:grant-type:device_code"_s}, + }; + + NetworkRequest(u"https://id.twitch.tv/oauth2/token"_s, + NetworkRequestType::Post) + .caller(this) + .timeout((this->pingTimer_.interval() * 9) / 10) + .payload(query.toString(QUrl::FullyEncoded).toUtf8()) + .onSuccess([this](const auto &res) { + const auto json = res.parseJson(); + auto accessToken = json["access_token"_L1].toString(); + auto refreshToken = json["refresh_token"_L1].toString(); + auto expiresIn = json["expires_in"_L1].toInt(-1); + if (accessToken.isEmpty() || refreshToken.isEmpty() || + expiresIn <= 0) + { + this->displayError("Received malformed response"); + return; + } + auto expiresAt = + QDateTime::currentDateTimeUtc().addSecs(expiresIn - 120); + QPointer self(this); + auto helix = std::make_shared(); + helix->update(DEVICE_AUTH_CLIENT_ID, accessToken); + helix->fetchUsers( + {}, {}, + [self, helix, refreshToken, accessToken, + expiresAt](const auto &res) { + if (res.empty()) + { + if (self) + { + self->displayError("No user associated with token"); + } + return; + } + const auto &user = res.front(); + TwitchAccountData{ + .username = user.login, + .userID = user.id, + .clientID = DEVICE_AUTH_CLIENT_ID, + .oauthToken = accessToken, + .ty = TwitchAccount::Type::DeviceAuth, + .refreshToken = refreshToken, + .expiresAt = expiresAt, + } + .save(); + getApp()->getAccounts()->twitch.reloadUsers(); + getApp()->getAccounts()->twitch.currentUsername = + user.login; + + if (self) + { + self->window()->close(); + } + }, + [self]() { + if (self) + { + self->displayError( + u"Failed to fetch authenticated user"_s); + } + }); + }) + .onError([this](const auto &res) { + auto json = res.parseJson(); + auto message = json["message"_L1].toString(u"(no message)"_s); + if (message != u"authorization_pending"_s) + { + this->displayError(res.formatError() + u" - "_s + message); + } + }) + .execute(); +} + } // namespace +namespace chatterino { + BasicLoginWidget::BasicLoginWidget() { const QString logInLink = "https://chatterino.com/client_login"; @@ -145,7 +457,8 @@ BasicLoginWidget::BasicLoginWidget() } } - if (logInWithCredentials(this, userID, username, clientID, oauthToken)) + if (logInWithImplicitGrantCredentials(this, userID, username, clientID, + oauthToken)) { this->window()->close(); } @@ -215,8 +528,8 @@ AdvancedLoginWidget::AdvancedLoginWidget() QString clientID = this->ui_.clientIDInput.text(); QString oauthToken = this->ui_.oauthTokenInput.text(); - logInWithCredentials(this, userID, username, clientID, - oauthToken); + logInWithImplicitGrantCredentials(this, userID, username, + clientID, oauthToken); }); } @@ -238,7 +551,7 @@ void AdvancedLoginWidget::refreshButtons() LoginDialog::LoginDialog(QWidget *parent) : QDialog(parent) { - this->setMinimumWidth(300); + this->setMinimumWidth(400); this->setWindowFlags( (this->windowFlags() & ~(Qt::WindowContextHelpButtonHint)) | Qt::Dialog | Qt::MSWindowsFixedSizeDialogHint); @@ -248,6 +561,7 @@ LoginDialog::LoginDialog(QWidget *parent) this->setLayout(&this->ui_.mainLayout); this->ui_.mainLayout.addWidget(&this->ui_.tabWidget); + this->ui_.tabWidget.addTab(new DeviceLoginWidget, "Device"); this->ui_.tabWidget.addTab(&this->ui_.basic, "Basic"); this->ui_.tabWidget.addTab(&this->ui_.advanced, "Advanced"); diff --git a/tests/src/Commands.cpp b/tests/src/Commands.cpp index e44366f20..b319cd15b 100644 --- a/tests/src/Commands.cpp +++ b/tests/src/Commands.cpp @@ -873,9 +873,15 @@ TEST(Commands, E2E) EXPECT_CALL(mockHelix, update).Times(1); EXPECT_CALL(mockHelix, loadBlocks).Times(1); - auto account = std::make_shared( - testaccount420["login"].toString(), "token", "oauthclient", - testaccount420["id"].toString()); + auto account = std::make_shared(TwitchAccountData{ + .username = testaccount420["login"].toString(), + .userID = testaccount420["id"].toString(), + .clientID = "oauthclient", + .oauthToken = "token", + .ty = TwitchAccount::Type::ImplicitGrant, + .refreshToken = {}, + .expiresAt = {}, + }); getApp()->getAccounts()->twitch.accounts.append(account); getApp()->getAccounts()->twitch.currentUsername = testaccount420["login"].toString(); diff --git a/tests/src/TwitchPubSubClient.cpp b/tests/src/TwitchPubSubClient.cpp index e195d937b..497d0ee05 100644 --- a/tests/src/TwitchPubSubClient.cpp +++ b/tests/src/TwitchPubSubClient.cpp @@ -10,6 +10,7 @@ #include #include +#include using namespace chatterino; using namespace std::chrono_literals; @@ -82,8 +83,15 @@ public: QString token = "token") : PubSub(QString("wss://127.0.0.1:9050%1").arg(path), pingInterval) { - auto account = std::make_shared("testaccount_420", token, - "clientid", "123456"); + auto account = std::make_shared(TwitchAccountData{ + .username = "testaccount_420", + .userID = "123456", + .clientID = "clientid", + .oauthToken = std::move(token), + .ty = TwitchAccount::Type::ImplicitGrant, + .refreshToken = {}, + .expiresAt = {}, + }); this->setAccount(account); } };