From a88b440290372cc6699734c1df89027333436158 Mon Sep 17 00:00:00 2001 From: jp9000 Date: Wed, 6 Feb 2019 22:45:06 -0800 Subject: [PATCH] UI: Add Twitch integration --- UI/CMakeLists.txt | 19 ++ UI/auth-twitch.cpp | 409 +++++++++++++++++++++++++++++++++++++++ UI/auth-twitch.hpp | 46 +++++ UI/data/locale/en-US.ini | 2 + UI/ui-config.h.in | 4 + UI/window-basic-main.cpp | 4 + 6 files changed, 484 insertions(+) create mode 100644 UI/auth-twitch.cpp create mode 100644 UI/auth-twitch.hpp diff --git a/UI/CMakeLists.txt b/UI/CMakeLists.txt index f86beb23e..3c2542e70 100644 --- a/UI/CMakeLists.txt +++ b/UI/CMakeLists.txt @@ -19,6 +19,16 @@ project(obs) set(DISABLE_UPDATE_MODULE TRUE CACHE BOOL "Disables building the update module") +if(NOT DEFINED TWITCH_CLIENTID OR "${TWITCH_CLIENTID}" STREQUAL "" OR + NOT DEFINED TWITCH_HASH OR "${TWITCH_HASH}" STREQUAL "" OR + NOT BROWSER_AVAILABLE_INTERNAL) + set(TWITCH_ENABLED FALSE) + set(TWITCH_CLIENTID "") + set(TWITCH_HASH "0") +else() + set(TWITCH_ENABLED TRUE) +endif() + if(NOT DEFINED MIXER_CLIENTID OR "${MIXER_CLIENTID}" STREQUAL "" OR NOT DEFINED MIXER_HASH OR "${MIXER_HASH}" STREQUAL "" OR NOT BROWSER_AVAILABLE_INTERNAL) @@ -130,6 +140,15 @@ if(BROWSER_AVAILABLE_INTERNAL) auth-oauth.hpp ) + if(TWITCH_ENABLED) + list(APPEND obs_PLATFORM_SOURCES + auth-twitch.cpp + ) + list(APPEND obs_PLATFORM_HEADERS + auth-twitch.hpp + ) + endif() + if(MIXER_ENABLED) list(APPEND obs_PLATFORM_SOURCES auth-mixer.cpp diff --git a/UI/auth-twitch.cpp b/UI/auth-twitch.cpp new file mode 100644 index 000000000..10f68871d --- /dev/null +++ b/UI/auth-twitch.cpp @@ -0,0 +1,409 @@ +#include "auth-twitch.hpp" + +#include +#include +#include + +#include +#include + +#include "window-basic-main.hpp" +#include "remote-text.hpp" + +#include + +#include "ui-config.h" +#include "obf.h" + +using namespace json11; + +#include +extern QCef *cef; +extern QCefCookieManager *panel_cookies; + +/* ------------------------------------------------------------------------- */ + +#define TWITCH_AUTH_URL \ + "https://obsproject.com/app-auth/twitch?action=redirect" +#define TWITCH_TOKEN_URL \ + "https://obsproject.com/app-auth/twitch-token" +#define ACCEPT_HEADER \ + "Accept: application/vnd.twitchtv.v5+json" + +#define TWITCH_SCOPE_VERSION 1 + +static Auth::Def twitchDef = { + "Twitch", + Auth::Type::OAuth_StreamKey +}; + +/* ------------------------------------------------------------------------- */ + +TwitchAuth::TwitchAuth(const Def &d) + : OAuthStreamKey(d) +{ + cef->add_popup_whitelist_url( + "https://twitch.tv/popout/frankerfacez/chat?ffz-settings", + this); + uiLoadTimer.setSingleShot(true); + uiLoadTimer.setInterval(500); + connect(&uiLoadTimer, &QTimer::timeout, + this, &TwitchAuth::TryLoadSecondaryUIPanes); +} + +bool TwitchAuth::GetChannelInfo() +try { + std::string client_id = TWITCH_CLIENTID; + deobfuscate_str(&client_id[0], TWITCH_HASH); + + if (!GetToken(TWITCH_TOKEN_URL, client_id, TWITCH_SCOPE_VERSION)) + return false; + if (token.empty()) + return false; + if (!key_.empty()) + return true; + + std::string auth; + auth += "Authorization: OAuth "; + auth += token; + + std::vector headers; + headers.push_back(std::string("Client-ID: ") + client_id); + headers.push_back(ACCEPT_HEADER); + headers.push_back(std::move(auth)); + + std::string output; + std::string error; + + bool success = false; + + auto func = [&] () { + success = GetRemoteFile( + "https://api.twitch.tv/kraken/channel", + output, + error, + nullptr, + "application/json", + nullptr, + headers, + nullptr, + 5); + }; + + ExecuteFuncSafeBlockMsgBox( + func, + QTStr("Auth.LoadingChannel.Title"), + QTStr("Auth.LoadingChannel.Text").arg(service())); + if (!success || output.empty()) + throw ErrorInfo("Failed to get text from remote", error); + + Json json = Json::parse(output, error); + if (!error.empty()) + throw ErrorInfo("Failed to parse json", error); + + error = json["error"].string_value(); + if (!error.empty()) + throw ErrorInfo(error, json["error_description"].string_value()); + + name = json["name"].string_value(); + key_ = json["stream_key"].string_value(); + + return true; +} catch (ErrorInfo info) { + QString title = QTStr("Auth.ChannelFailure.Title"); + QString text = QTStr("Auth.ChannelFailure.Text") + .arg(service(), info.message.c_str(), info.error.c_str()); + + QMessageBox::warning(OBSBasic::Get(), title, text); + + blog(LOG_WARNING, "%s: %s: %s", + __FUNCTION__, + info.message.c_str(), + info.error.c_str()); + return false; +} + +void TwitchAuth::SaveInternal() +{ + OBSBasic *main = OBSBasic::Get(); + config_set_string(main->Config(), service(), "Name", name.c_str()); + if (uiLoaded) { + config_set_string(main->Config(), service(), "DockState", + main->saveState().toBase64().constData()); + } + OAuthStreamKey::SaveInternal(); +} + +static inline std::string get_config_str( + OBSBasic *main, + const char *section, + const char *name) +{ + const char *val = config_get_string(main->Config(), section, name); + return val ? val : ""; +} + +bool TwitchAuth::LoadInternal() +{ + OBSBasic *main = OBSBasic::Get(); + name = get_config_str(main, service(), "Name"); + firstLoad = false; + return OAuthStreamKey::LoadInternal(); +} + +class TwitchWidget : public QDockWidget { +public: + inline TwitchWidget() : QDockWidget() {} + + QScopedPointer widget; + + inline void SetWidget(QCefWidget *widget_) + { + setWidget(widget_); + widget.reset(widget_); + } +}; + +static const char *ffz_script = "\ +var ffz = document.createElement('script');\ +ffz.setAttribute('src','https://cdn.frankerfacez.com/script/script.min.js');\ +document.head.appendChild(ffz);"; + +static const char *bttv_script = "\ +localStorage.setItem('bttv_darkenedMode', true);\ +var bttv = document.createElement('script');\ +bttv.setAttribute('src','https://cdn.betterttv.net/betterttv.js');\ +document.head.appendChild(bttv);"; + +static const char *referrer_script1 = "\ +Object.defineProperty(document, 'referrer', {get : function() { return '"; +static const char *referrer_script2 = "'; }});"; + +void TwitchAuth::LoadUI() +{ + if (uiLoaded) + return; + if (!GetChannelInfo()) + return; + + OBSBasic::InitBrowserPanelSafeBlock(true); + OBSBasic *main = OBSBasic::Get(); + + QCefWidget *browser; + std::string url; + std::string script; + + /* ----------------------------------- */ + + url = "https://www.twitch.tv/popout/"; + url += name; + url += "/chat"; + + QSize size = main->frameSize(); + QPoint pos = main->pos(); + + chat.reset(new TwitchWidget()); + chat->setObjectName("twitchChat"); + chat->resize(300, 600); + chat->setMinimumSize(200, 300); + chat->setWindowTitle(QTStr("Auth.Chat")); + chat->setAllowedAreas(Qt::AllDockWidgetAreas); + + browser = cef->create_widget(nullptr, url, panel_cookies); + chat->SetWidget(browser); + + script = bttv_script; + script += ffz_script; + browser->setStartupScript(script); + + main->addDockWidget(Qt::RightDockWidgetArea, chat.data()); + chatMenu.reset(main->AddDockWidget(chat.data())); + + /* ----------------------------------- */ + + chat->setFloating(true); + chat->move(pos.x() + size.width() - chat->width() - 50, pos.y() + 50); + + if (firstLoad) { + chat->setVisible(true); + } else { + const char *dockStateStr = config_get_string(main->Config(), + service(), "DockState"); + QByteArray dockState = + QByteArray::fromBase64(QByteArray(dockStateStr)); + main->restoreState(dockState); + } + + TryLoadSecondaryUIPanes(); + + uiLoaded = true; +} + +void TwitchAuth::LoadSecondaryUIPanes() +{ + OBSBasic *main = OBSBasic::Get(); + + QCefWidget *browser; + std::string url; + std::string script; + + QPoint pos = main->pos(); + + script = "localStorage.setItem('twilight.theme', 1);"; + script += referrer_script1; + script += "https://www.twitch.tv/"; + script += name; + script += "/dashboard/live"; + script += referrer_script2; + script += bttv_script; + script += ffz_script; + + /* ----------------------------------- */ + + url = "https://www.twitch.tv/popout/"; + url += name; + url += "/dashboard/live/stream-info"; + + info.reset(new TwitchWidget()); + info->setObjectName("twitchInfo"); + info->resize(300, 650); + info->setMinimumSize(200, 300); + info->setWindowTitle(QTStr("Auth.StreamInfo")); + info->setAllowedAreas(Qt::AllDockWidgetAreas); + + browser = cef->create_widget(nullptr, url, panel_cookies); + info->SetWidget(browser); + browser->setStartupScript(script); + + main->addDockWidget(Qt::RightDockWidgetArea, info.data()); + infoMenu.reset(main->AddDockWidget(info.data())); + + /* ----------------------------------- */ + + url = "https://www.twitch.tv/popout/"; + url += name; + url += "/dashboard/live/stats"; + + stat.reset(new TwitchWidget()); + stat->setObjectName("twitchStats"); + stat->resize(200, 200); + stat->setMinimumSize(200, 200); + stat->setWindowTitle(QTStr("TwitchAuth.Stats")); + stat->setAllowedAreas(Qt::AllDockWidgetAreas); + + browser = cef->create_widget(nullptr, url, panel_cookies); + stat->SetWidget(browser); + browser->setStartupScript(script); + + main->addDockWidget(Qt::RightDockWidgetArea, stat.data()); + statMenu.reset(main->AddDockWidget(stat.data())); + + /* ----------------------------------- */ + + info->setFloating(true); + stat->setFloating(true); + + info->move(pos.x() + 50, pos.y() + 50); + + if (firstLoad) { + info->setVisible(true); + stat->setVisible(false); + } else { + const char *dockStateStr = config_get_string(main->Config(), + service(), "DockState"); + QByteArray dockState = + QByteArray::fromBase64(QByteArray(dockStateStr)); + main->restoreState(dockState); + } +} + +/* Twitch.tv has an OAuth for itself. If we try to load multiple panel pages + * at once before it's OAuth'ed itself, they will all try to perform the auth + * process at the same time, get their own request codes, and only the last + * code will be valid -- so one or more panels are guaranteed to fail. + * + * To solve this, we want to load just one panel first (the chat), and then all + * subsequent panels should only be loaded once we know that Twitch has auth'ed + * itself (if the cookie "auth-token" exists for twitch.tv). + * + * This is annoying to deal with. */ +void TwitchAuth::TryLoadSecondaryUIPanes() +{ + QPointer this_ = this; + + auto cb = [this_] (bool found) + { + if (!this_) { + return; + } + + if (!found) { + QMetaObject::invokeMethod(&this_->uiLoadTimer, + "start"); + } else { + QMetaObject::invokeMethod(this_, "LoadSecondaryUIPanes"); + } + }; + + panel_cookies->CheckForCookie("https://www.twitch.tv", "auth-token", cb); +} + +bool TwitchAuth::RetryLogin() +{ + OAuthLogin login(OBSBasic::Get(), TWITCH_AUTH_URL, false); + if (login.exec() == QDialog::Rejected) { + return false; + } + + std::shared_ptr auth = std::make_shared(twitchDef); + std::string client_id = TWITCH_CLIENTID; + deobfuscate_str(&client_id[0], TWITCH_HASH); + + return GetToken(TWITCH_TOKEN_URL, client_id, TWITCH_SCOPE_VERSION, + QT_TO_UTF8(login.GetCode()), true); +} + +std::shared_ptr TwitchAuth::Login(QWidget *parent) +{ + OAuthLogin login(parent, TWITCH_AUTH_URL, false); + if (login.exec() == QDialog::Rejected) { + return nullptr; + } + + std::shared_ptr auth = std::make_shared(twitchDef); + + std::string client_id = TWITCH_CLIENTID; + deobfuscate_str(&client_id[0], TWITCH_HASH); + + if (!auth->GetToken(TWITCH_TOKEN_URL, client_id, TWITCH_SCOPE_VERSION, + QT_TO_UTF8(login.GetCode()))) { + return nullptr; + } + + std::string error; + if (auth->GetChannelInfo()) { + return auth; + } + + return nullptr; +} + +static std::shared_ptr CreateTwitchAuth() +{ + return std::make_shared(twitchDef); +} + +static void DeleteCookies() +{ + if (panel_cookies) + panel_cookies->DeleteCookies("twitch.tv", std::string()); +} + +void RegisterTwitchAuth() +{ + OAuth::RegisterOAuth( + twitchDef, + CreateTwitchAuth, + TwitchAuth::Login, + DeleteCookies); +} diff --git a/UI/auth-twitch.hpp b/UI/auth-twitch.hpp new file mode 100644 index 000000000..82c42dc1d --- /dev/null +++ b/UI/auth-twitch.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include + +#include "auth-oauth.hpp" + +class TwitchWidget; + +class TwitchAuth : public OAuthStreamKey { + Q_OBJECT + + friend class TwitchLogin; + + QSharedPointer chat; + QSharedPointer info; + QSharedPointer stat; + QSharedPointer chatMenu; + QSharedPointer infoMenu; + QSharedPointer statMenu; + bool uiLoaded = false; + + std::string name; + + virtual bool RetryLogin() override; + + virtual void SaveInternal() override; + virtual bool LoadInternal() override; + + bool GetChannelInfo(); + + virtual void LoadUI() override; + +public: + TwitchAuth(const Def &d); + + static std::shared_ptr Login(QWidget *parent); + + QTimer uiLoadTimer; + +public slots: + void TryLoadSecondaryUIPanes(); + void LoadSecondaryUIPanes(); +}; diff --git a/UI/data/locale/en-US.ini b/UI/data/locale/en-US.ini index e66a979e9..597f077ff 100644 --- a/UI/data/locale/en-US.ini +++ b/UI/data/locale/en-US.ini @@ -103,6 +103,8 @@ Auth.LoadingChannel.Text="Loading channel information for %1, please wait.." Auth.ChannelFailure.Title="Failed to load channel" Auth.ChannelFailure.Text="Failed to load channel information for %1\n\n%2: %3" Auth.Chat="Chat" +Auth.StreamInfo="Stream Information" +TwitchAuth.Stats="Twitch Stats" # copy filters Copy.Filters="Copy Filters" diff --git a/UI/ui-config.h.in b/UI/ui-config.h.in index 58a102008..b31de90d8 100644 --- a/UI/ui-config.h.in +++ b/UI/ui-config.h.in @@ -16,6 +16,10 @@ #define OFF 0 #endif +#define TWITCH_ENABLED @TWITCH_ENABLED@ +#define TWITCH_CLIENTID "@TWITCH_CLIENTID@" +#define TWITCH_HASH 0x@TWITCH_HASH@ + #define MIXER_ENABLED @MIXER_ENABLED@ #define MIXER_CLIENTID "@MIXER_CLIENTID@" #define MIXER_HASH 0x@MIXER_HASH@ diff --git a/UI/window-basic-main.cpp b/UI/window-basic-main.cpp index b51d9c9ca..2c2b9a3d0 100644 --- a/UI/window-basic-main.cpp +++ b/UI/window-basic-main.cpp @@ -191,6 +191,7 @@ void assignDockToggle(QDockWidget *dock, QAction *action) handleMenuToggle); } +extern void RegisterTwitchAuth(); extern void RegisterMixerAuth(); OBSBasic::OBSBasic(QWidget *parent) @@ -199,6 +200,9 @@ OBSBasic::OBSBasic(QWidget *parent) { setAttribute(Qt::WA_NativeWindow); +#if TWITCH_ENABLED + RegisterTwitchAuth(); +#endif #if MIXER_ENABLED RegisterMixerAuth(); #endif