&options)
+{
+ if (options.empty()) {
+ return "";
+ }
+
+ std::string scope;
+ for (const auto &option : options) {
+ scope += option.apiId + "+";
+ }
+ scope.pop_back(); // Remove trailing +
+ return scope;
+}
+
+static std::string getHtml(const QString &redirect)
+{
+ const char *html = R"(
+
+
+ Advanced scene switcher
+
+
+ Please click this link to continue if not automatically redirected
+ Login with Twitch
+
+
+ )";
+
+ return QString(html).arg(redirect).toStdString();
+}
+
+void TwitchTokenSettingsDialog::RequestToken()
+{
+ // Don't allow parallel RequestToken() calls
+ _requestToken->setDisabled(true);
+
+ auto scope = QString::fromStdString(
+ generateScopeString(GetEnabledOptions()));
+ _tokenGrabber.SetTokenScope(scope);
+ _tokenGrabber.start();
+ _tokenStatus->setText(obs_module_text(
+ "AdvSceneSwitcher.twitchToken.request.waiting"));
+}
+
+void TwitchTokenSettingsDialog::GotToken(const std::optional &value)
+{
+ _currentTokenValue->setText(value.value_or(""));
+ if (value.has_value()) {
+ _tokenStatus->setText(obs_module_text(
+ "AdvSceneSwitcher.twitchToken.request.success"));
+ _currentToken.SetToken(value.value().toStdString());
+ auto name = QString::fromStdString(_currentToken._name);
+ _name->setText(name);
+ _name->textEdited(name);
+ } else {
+ _tokenStatus->setText(obs_module_text(
+ "AdvSceneSwitcher.twitchToken.request.fail"));
+ _name->setText("");
+ }
+ _requestToken->setEnabled(true);
+}
+
+std::set TwitchTokenSettingsDialog::GetEnabledOptions()
+{
+ std::set result;
+ for (const auto &[id, checkBox] : _optionWidgets) {
+ if (checkBox->isChecked()) {
+ TokenOption option;
+ option.apiId = id;
+ result.emplace(option);
+ }
+ }
+ return result;
+}
+
+bool TwitchTokenSettingsDialog::AskForSettings(QWidget *parent,
+ TwitchToken &settings)
+{
+ TwitchTokenSettingsDialog dialog(parent, settings);
+ dialog.setWindowTitle(obs_module_text("AdvSceneSwitcher.windowTitle"));
+ if (dialog.exec() != DialogCode::Accepted) {
+ return false;
+ }
+
+ settings = dialog._currentToken;
+ settings._tokenOptions = dialog.GetEnabledOptions();
+ return true;
+}
+
+int TokenGrabberThread::_timeout = 15;
+
+TokenGrabberThread::~TokenGrabberThread()
+{
+ _stopWaiting = true;
+ _cv.notify_all();
+ Stop();
+ _server.stop();
+}
+
+static std::string getAuthErrorString(const char *errDetail)
+{
+ QString err = obs_module_text(
+ "AdvSceneSwitcher.twitchToken.request.fail.browser");
+ return err.arg(obs_module_text(errDetail)).toStdString();
+}
+
+void TokenGrabberThread::run()
+{
+ // Reset
+ _server.stop();
+ _server.~Server();
+ new (&_server) httplib::Server();
+ if (_serverThread.joinable()) {
+ _serverThread.join();
+ }
+ _stopWaiting = {false};
+
+ // Generate URI to request token
+ auto state = generateStateString();
+ auto getTokenURI = "https://id.twitch.tv/oauth2/authorize"
+ "?response_type=token"
+ "&client_id=" +
+ QString(GetClientID()) +
+ "&redirect_uri=http://localhost:8080/auth"
+ "&scope=" +
+ _scope + "&state=" + QString::fromStdString(state);
+
+ // Setup server receiving token string
+ auto html = getHtml(getTokenURI);
+ _server.Get("/auth", [html, state](const httplib::Request &req,
+ httplib::Response &res) {
+ // Check for errors
+ if (req.has_param("error")) {
+ auto recvState = req.get_param_value("state");
+ if (recvState != state) {
+ blog(LOG_WARNING,
+ "state string does not match in error handling?! "
+ "Got \"%s\" - expected \"%s\"\n"
+ "ignoring error ...",
+ recvState.c_str(), state.c_str());
+ return;
+ }
+ auto errorStr =
+ req.get_param_value("error_description");
+ res.set_content(getAuthErrorString(errorStr.c_str()),
+ "text/plain");
+ return;
+ }
+
+ // Parse fragments and redirect to /token with
+ // corresponding parameters.
+ res.set_content(html, "text/html");
+ });
+ _server.Get("/token", [&](const httplib::Request &req,
+ httplib::Response &res) {
+ // Check if valid request and grab the token string
+ std::lock_guard lk(_mutex);
+ auto recvState = req.get_param_value("state");
+ if (recvState != state) {
+ blog(LOG_WARNING,
+ "state string does not match! "
+ "Got \"%s\" - expected \"%s\"",
+ recvState.c_str(), state.c_str());
+ res.set_content(
+ getAuthErrorString(
+ "AdvSceneSwitcher.twitchToken.request.fail.stateMismatch"),
+ "text/plain");
+ } else {
+ _tokenString = QString::fromStdString(
+ req.get_param_value("access_token"));
+ res.set_content(
+ obs_module_text(
+ "AdvSceneSwitcher.twitchToken.request.success.browser"),
+ "text/plain");
+ }
+ _stopWaiting = true;
+ _cv.notify_all();
+ });
+
+ // Request user to grant token
+ QDesktopServices::openUrl(getTokenURI);
+
+ // Start the server and wait
+ std::unique_lock lock(_mutex);
+ _serverThread =
+ std::thread([this]() { _server.listen("localhost", 8080); });
+ auto time = std::chrono::high_resolution_clock::now() +
+ std::chrono::seconds(_timeout);
+ while (!_stopWaiting) {
+ if (_cv.wait_until(lock, time) == std::cv_status::timeout) {
+ break;
+ }
+ }
+
+ emit GotToken(_tokenString);
+}
+
+void TokenGrabberThread::Stop()
+{
+ if (_server.is_running()) {
+ _server.stop();
+ }
+ if (_serverThread.joinable()) {
+ _serverThread.join();
+ }
+ wait();
+}
+
+TwitchConnectionSignalManager *TwitchConnectionSignalManager::Instance()
+{
+ static TwitchConnectionSignalManager manager;
+ return &manager;
+}
+
+} // namespace advss
diff --git a/src/macro-external/twitch/token.hpp b/src/macro-external/twitch/token.hpp
new file mode 100644
index 00000000..f9014edf
--- /dev/null
+++ b/src/macro-external/twitch/token.hpp
@@ -0,0 +1,140 @@
+#pragma once
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace advss {
+
+class TwitchConnectionSelection;
+class TwitchTokenSettingsDialog;
+
+class TokenOption {
+public:
+ void Load(obs_data_t *obj);
+ void Save(obs_data_t *obj) const;
+ std::string GetLocale() const;
+
+ static const std::unordered_map &
+ GetTokenMap();
+ bool operator<(const TokenOption &other) const;
+ std::string apiId = "";
+
+private:
+ const static std::unordered_map apiIdToLocale;
+};
+
+class TwitchToken : public Item {
+public:
+ static std::shared_ptr- Create()
+ {
+ return std::make_shared();
+ }
+
+ void Load(obs_data_t *obj);
+ void Save(obs_data_t *obj) const;
+ std::string GetName() { return _name; }
+ bool OptionIsEnabled(const TokenOption &) const;
+ void SetToken(const std::string &);
+ bool IsEmpty() const { return _token.empty(); }
+ std::string GetToken() const { return _token; }
+ std::string GetUserID() const { return _userID; }
+
+private:
+ std::string _token;
+ std::string _userID;
+ std::set _tokenOptions = {{"channel:manage:broadcast"}};
+
+ static bool _setup;
+
+ friend TwitchConnectionSelection;
+ friend TwitchTokenSettingsDialog;
+};
+
+class TokenGrabberThread : public QThread {
+ Q_OBJECT
+
+public:
+ ~TokenGrabberThread();
+ void SetTokenScope(const QString &value) { _scope = value; }
+
+signals:
+ void GotToken(const std::optional &);
+
+protected:
+ void run() override;
+
+private:
+ void Stop();
+
+ QString _scope;
+ std::optional _tokenString;
+
+ static int _timeout;
+ std::mutex _mutex;
+ std::atomic_bool _stopWaiting = {false};
+ std::condition_variable _cv;
+ std::thread _serverThread;
+ httplib::Server _server;
+};
+
+class TwitchTokenSettingsDialog : public ItemSettingsDialog {
+ Q_OBJECT
+
+public:
+ TwitchTokenSettingsDialog(QWidget *parent, const TwitchToken &);
+ static bool AskForSettings(QWidget *parent, TwitchToken &settings);
+
+private slots:
+ void ShowToken();
+ void HideToken();
+ void RequestToken();
+ void GotToken(const std::optional &);
+
+private:
+ std::set GetEnabledOptions();
+
+ QPushButton *_requestToken;
+ QPushButton *_showToken;
+ QLineEdit *_currentTokenValue;
+ QLabel *_tokenStatus;
+ TokenGrabberThread _tokenGrabber;
+ TwitchToken _currentToken;
+ std::unordered_map _optionWidgets;
+};
+
+class TwitchConnectionSelection : public ItemSelection {
+ Q_OBJECT
+
+public:
+ TwitchConnectionSelection(QWidget *parent = 0);
+ void SetToken(const std::string &);
+ void SetToken(const std::weak_ptr &);
+};
+
+// Helper class so that it is not required to add signals to the
+// AdvSceneSwitcher class for handling adding and removing Twitch connections
+class TwitchConnectionSignalManager : public QObject {
+ Q_OBJECT
+public:
+ static TwitchConnectionSignalManager *Instance();
+
+private:
+signals:
+ // Rename signal not required as name is based on Twitch account name
+ // and item name cannot be manually changed
+ void Add(const QString &);
+ void Remove(const QString &);
+};
+
+TwitchToken *GetTwitchTokenByName(const QString &);
+TwitchToken *GetTwitchTokenByName(const std::string &);
+std::weak_ptr GetWeakTwitchTokenByName(const std::string &name);
+std::weak_ptr GetWeakTwitchTokenByQString(const QString &name);
+std::string GetWeakTwitchTokenName(std::weak_ptr);
+
+} // namespace advss
+
+Q_DECLARE_METATYPE(advss::TwitchToken *);
diff --git a/src/macro-external/twitch/twitch-helpers.cpp b/src/macro-external/twitch/twitch-helpers.cpp
new file mode 100644
index 00000000..931ba791
--- /dev/null
+++ b/src/macro-external/twitch/twitch-helpers.cpp
@@ -0,0 +1,76 @@
+#include "twitch-helpers.hpp"
+#include "token.hpp"
+
+namespace advss {
+
+static constexpr std::string_view clientID = "ds5tt4ogliifsqc04mz3d3etnck3e5";
+
+static httplib::Headers getTokenRequestHeaders(const TwitchToken &token)
+{
+ return {
+ {"Authorization", "Bearer " + token.GetToken()},
+ {"Client-Id", clientID.data()},
+ };
+}
+
+RequestResult SendGetRequest(const std::string &uri, const std::string &path,
+ const TwitchToken &token,
+ const httplib::Params ¶ms)
+{
+ httplib::Client cli(uri);
+ auto headers = getTokenRequestHeaders(token);
+ auto response = cli.Get(path, params, headers);
+ RequestResult result;
+ result.status = response->status;
+ if (response->body.empty()) {
+ return result;
+ }
+ OBSDataAutoRelease replyData =
+ obs_data_create_from_json(response->body.c_str());
+ result.data = replyData;
+ return result;
+}
+
+RequestResult SendPostRequest(const std::string &uri, const std::string &path,
+ const TwitchToken &token,
+ const httplib::Params ¶ms)
+{
+ httplib::Client cli(uri);
+ auto headers = getTokenRequestHeaders(token);
+ auto response = cli.Post(path, headers, params);
+ RequestResult result;
+ result.status = response->status;
+ if (response->body.empty()) {
+ return result;
+ }
+ OBSDataAutoRelease replyData =
+ obs_data_create_from_json(response->body.c_str());
+ result.data = replyData;
+ return result;
+}
+
+RequestResult SendPatchRequest(const std::string &uri, const std::string &path,
+ const TwitchToken &token, const OBSData &data)
+{
+ httplib::Client cli(uri);
+ auto headers = getTokenRequestHeaders(token);
+ auto json = obs_data_get_json(data);
+ std::string body = json ? json : "";
+ auto response = cli.Patch(path, headers, body, "application/json");
+ RequestResult result;
+ result.status = response->status;
+ if (response->body.empty()) {
+ return result;
+ }
+ OBSDataAutoRelease replyData =
+ obs_data_create_from_json(response->body.c_str());
+ result.data = replyData;
+ return result;
+}
+
+const char *GetClientID()
+{
+ return clientID.data();
+}
+
+} // namespace advss
diff --git a/src/macro-external/twitch/twitch-helpers.hpp b/src/macro-external/twitch/twitch-helpers.hpp
new file mode 100644
index 00000000..d0f61bf4
--- /dev/null
+++ b/src/macro-external/twitch/twitch-helpers.hpp
@@ -0,0 +1,25 @@
+#pragma once
+#include
+#include
+#include
+
+namespace advss {
+
+class TwitchToken;
+
+struct RequestResult {
+ int status = 0;
+ OBSData data = nullptr;
+};
+
+RequestResult SendGetRequest(const std::string &uri, const std::string &path,
+ const TwitchToken &token,
+ const httplib::Params & = {});
+RequestResult SendPostRequest(const std::string &uri, const std::string &path,
+ const TwitchToken &token,
+ const httplib::Params ¶ms = {});
+RequestResult SendPatchRequest(const std::string &uri, const std::string &path,
+ const TwitchToken &token, const OBSData &data);
+const char *GetClientID();
+
+} // namespace advss