Add basic lua scripting capabilities (#4341)
The scripting capabilities is locked behind a cmake flag, and is not enabled by default. Co-authored-by: nerix <nerixdev@outlook.de> Co-authored-by: pajlada <rasmus.karlsson@pajlada.com>
This commit is contained in:
@@ -10,6 +10,9 @@
|
||||
#include "controllers/hotkeys/HotkeyController.hpp"
|
||||
#include "controllers/ignores/IgnoreController.hpp"
|
||||
#include "controllers/notifications/NotificationController.hpp"
|
||||
#ifdef CHATTERINO_HAVE_PLUGINS
|
||||
# include "controllers/plugins/PluginController.hpp"
|
||||
#endif
|
||||
#include "controllers/sound/SoundController.hpp"
|
||||
#include "controllers/userdata/UserDataController.hpp"
|
||||
#include "debug/AssertInGuiThread.hpp"
|
||||
@@ -85,6 +88,9 @@ Application::Application(Settings &_settings, Paths &_paths)
|
||||
, seventvBadges(&this->emplace<SeventvBadges>())
|
||||
, userData(&this->emplace<UserDataController>())
|
||||
, sound(&this->emplace<SoundController>())
|
||||
#ifdef CHATTERINO_HAVE_PLUGINS
|
||||
, plugins(&this->emplace<PluginController>())
|
||||
#endif
|
||||
, logging(&this->emplace<Logging>())
|
||||
{
|
||||
this->instance = this;
|
||||
|
||||
@@ -20,6 +20,9 @@ class HotkeyController;
|
||||
class IUserDataController;
|
||||
class UserDataController;
|
||||
class SoundController;
|
||||
#ifdef CHATTERINO_HAVE_PLUGINS
|
||||
class PluginController;
|
||||
#endif
|
||||
|
||||
class Theme;
|
||||
class WindowManager;
|
||||
@@ -95,6 +98,10 @@ public:
|
||||
UserDataController *const userData{};
|
||||
SoundController *const sound{};
|
||||
|
||||
#ifdef CHATTERINO_HAVE_PLUGINS
|
||||
PluginController *const plugins{};
|
||||
#endif
|
||||
|
||||
/*[[deprecated]]*/ Logging *const logging{};
|
||||
|
||||
Theme *getThemes() override
|
||||
|
||||
@@ -136,6 +136,15 @@ set(SOURCE_FILES
|
||||
controllers/pings/MutedChannelModel.cpp
|
||||
controllers/pings/MutedChannelModel.hpp
|
||||
|
||||
controllers/plugins/LuaAPI.cpp
|
||||
controllers/plugins/LuaAPI.hpp
|
||||
controllers/plugins/Plugin.cpp
|
||||
controllers/plugins/Plugin.hpp
|
||||
controllers/plugins/PluginController.hpp
|
||||
controllers/plugins/PluginController.cpp
|
||||
controllers/plugins/LuaUtilities.cpp
|
||||
controllers/plugins/LuaUtilities.hpp
|
||||
|
||||
controllers/userdata/UserDataController.cpp
|
||||
controllers/userdata/UserDataController.hpp
|
||||
controllers/userdata/UserData.hpp
|
||||
@@ -545,6 +554,8 @@ set(SOURCE_FILES
|
||||
widgets/settingspages/NicknamesPage.hpp
|
||||
widgets/settingspages/NotificationPage.cpp
|
||||
widgets/settingspages/NotificationPage.hpp
|
||||
widgets/settingspages/PluginsPage.cpp
|
||||
widgets/settingspages/PluginsPage.hpp
|
||||
widgets/settingspages/SettingsPage.cpp
|
||||
widgets/settingspages/SettingsPage.hpp
|
||||
|
||||
@@ -590,6 +601,14 @@ list(APPEND SOURCE_FILES ${RES_AUTOGEN_FILES})
|
||||
|
||||
add_library(${LIBRARY_PROJECT} OBJECT ${SOURCE_FILES})
|
||||
|
||||
if(CHATTERINO_PLUGINS)
|
||||
target_compile_definitions(${LIBRARY_PROJECT}
|
||||
PRIVATE
|
||||
CHATTERINO_HAVE_PLUGINS
|
||||
)
|
||||
message(STATUS "Building Chatterino with lua plugin support enabled.")
|
||||
endif()
|
||||
|
||||
if (CHATTERINO_GENERATE_COVERAGE)
|
||||
include(CodeCoverage)
|
||||
append_coverage_compiler_flags_to_target(${LIBRARY_PROJECT})
|
||||
@@ -624,6 +643,9 @@ target_link_libraries(${LIBRARY_PROJECT}
|
||||
LRUCache
|
||||
MagicEnum
|
||||
)
|
||||
if (CHATTERINO_PLUGINS)
|
||||
target_link_libraries(${LIBRARY_PROJECT} PUBLIC lua)
|
||||
endif()
|
||||
|
||||
if (BUILD_WITH_QT6)
|
||||
target_link_libraries(${LIBRARY_PROJECT}
|
||||
|
||||
@@ -230,7 +230,12 @@ void CompletionModel::refresh(const QString &prefix, bool isFirstWord)
|
||||
{
|
||||
addString(emote.first.string, TaggedString::Type::BTTVGlobalEmote);
|
||||
}
|
||||
|
||||
#ifdef CHATTERINO_HAVE_PLUGINS
|
||||
for (const auto &command : getApp()->commands->pluginCommands())
|
||||
{
|
||||
addString(command, TaggedString::PluginCommand);
|
||||
}
|
||||
#endif
|
||||
// Custom Chatterino commands
|
||||
for (const auto &command : getApp()->commands->items)
|
||||
{
|
||||
|
||||
@@ -34,6 +34,9 @@ class CompletionModel : public QAbstractListModel
|
||||
CustomCommand,
|
||||
ChatterinoCommand,
|
||||
TwitchCommand,
|
||||
#ifdef CHATTERINO_HAVE_PLUGINS
|
||||
PluginCommand,
|
||||
#endif
|
||||
};
|
||||
|
||||
TaggedString(QString _string, Type type);
|
||||
|
||||
@@ -24,6 +24,7 @@ Q_LOGGING_CATEGORY(chatterinoIrc, "chatterino.irc", logThreshold);
|
||||
Q_LOGGING_CATEGORY(chatterinoIvr, "chatterino.ivr", logThreshold);
|
||||
Q_LOGGING_CATEGORY(chatterinoLiveupdates, "chatterino.liveupdates",
|
||||
logThreshold);
|
||||
Q_LOGGING_CATEGORY(chatterinoLua, "chatterino.lua", logThreshold);
|
||||
Q_LOGGING_CATEGORY(chatterinoMain, "chatterino.main", logThreshold);
|
||||
Q_LOGGING_CATEGORY(chatterinoMessage, "chatterino.message", logThreshold);
|
||||
Q_LOGGING_CATEGORY(chatterinoNativeMessage, "chatterino.nativemessage",
|
||||
|
||||
@@ -19,6 +19,7 @@ Q_DECLARE_LOGGING_CATEGORY(chatterinoImage);
|
||||
Q_DECLARE_LOGGING_CATEGORY(chatterinoIrc);
|
||||
Q_DECLARE_LOGGING_CATEGORY(chatterinoIvr);
|
||||
Q_DECLARE_LOGGING_CATEGORY(chatterinoLiveupdates);
|
||||
Q_DECLARE_LOGGING_CATEGORY(chatterinoLua);
|
||||
Q_DECLARE_LOGGING_CATEGORY(chatterinoMain);
|
||||
Q_DECLARE_LOGGING_CATEGORY(chatterinoMessage);
|
||||
Q_DECLARE_LOGGING_CATEGORY(chatterinoNativeMessage);
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include "controllers/commands/Command.hpp"
|
||||
#include "controllers/commands/CommandContext.hpp"
|
||||
#include "controllers/commands/CommandModel.hpp"
|
||||
#include "controllers/plugins/PluginController.hpp"
|
||||
#include "controllers/userdata/UserDataController.hpp"
|
||||
#include "messages/Message.hpp"
|
||||
#include "messages/MessageBuilder.hpp"
|
||||
@@ -3261,6 +3262,32 @@ QString CommandController::execCommand(const QString &textNoEmoji,
|
||||
return text;
|
||||
}
|
||||
|
||||
#ifdef CHATTERINO_HAVE_PLUGINS
|
||||
bool CommandController::registerPluginCommand(const QString &commandName)
|
||||
{
|
||||
if (this->commands_.contains(commandName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
this->commands_[commandName] = [commandName](const CommandContext &ctx) {
|
||||
return getApp()->plugins->tryExecPluginCommand(commandName, ctx);
|
||||
};
|
||||
this->pluginCommands_.append(commandName);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool CommandController::unregisterPluginCommand(const QString &commandName)
|
||||
{
|
||||
if (!this->pluginCommands_.contains(commandName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
this->pluginCommands_.removeAll(commandName);
|
||||
return this->commands_.erase(commandName) != 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
void CommandController::registerCommand(const QString &commandName,
|
||||
CommandFunctionVariants commandFunction)
|
||||
{
|
||||
|
||||
@@ -42,6 +42,15 @@ public:
|
||||
const QStringList &words, const Command &command, bool dryRun,
|
||||
ChannelPtr channel, const Message *message = nullptr,
|
||||
std::unordered_map<QString, QString> context = {});
|
||||
#ifdef CHATTERINO_HAVE_PLUGINS
|
||||
bool registerPluginCommand(const QString &commandName);
|
||||
bool unregisterPluginCommand(const QString &commandName);
|
||||
|
||||
const QStringList &pluginCommands()
|
||||
{
|
||||
return this->pluginCommands_;
|
||||
}
|
||||
#endif
|
||||
|
||||
private:
|
||||
void load(Paths &paths);
|
||||
@@ -73,6 +82,9 @@ private:
|
||||
commandsSetting_;
|
||||
|
||||
QStringList defaultChatterinoCommandAutoCompletions_;
|
||||
#ifdef CHATTERINO_HAVE_PLUGINS
|
||||
QStringList pluginCommands_;
|
||||
#endif
|
||||
};
|
||||
|
||||
} // namespace chatterino
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
#ifdef CHATTERINO_HAVE_PLUGINS
|
||||
# include "controllers/plugins/LuaAPI.hpp"
|
||||
|
||||
# include "Application.hpp"
|
||||
# include "common/QLogging.hpp"
|
||||
# include "controllers/commands/CommandController.hpp"
|
||||
# include "controllers/plugins/LuaUtilities.hpp"
|
||||
# include "controllers/plugins/PluginController.hpp"
|
||||
# include "messages/MessageBuilder.hpp"
|
||||
# include "providers/twitch/TwitchIrcServer.hpp"
|
||||
|
||||
# include <lauxlib.h>
|
||||
# include <lua.h>
|
||||
# include <lualib.h>
|
||||
# include <QFileInfo>
|
||||
# include <QLoggingCategory>
|
||||
# include <QTextCodec>
|
||||
|
||||
namespace {
|
||||
using namespace chatterino;
|
||||
|
||||
void logHelper(lua_State *L, Plugin *pl, QDebug stream, int argc)
|
||||
{
|
||||
stream.noquote();
|
||||
stream << "[" + pl->id + ":" + pl->meta.name + "]";
|
||||
for (int i = 1; i <= argc; i++)
|
||||
{
|
||||
stream << lua::toString(L, i);
|
||||
}
|
||||
lua_pop(L, argc);
|
||||
}
|
||||
|
||||
QDebug qdebugStreamForLogLevel(lua::api::LogLevel lvl)
|
||||
{
|
||||
auto base =
|
||||
(QMessageLogger(QT_MESSAGELOG_FILE, QT_MESSAGELOG_LINE,
|
||||
QT_MESSAGELOG_FUNC, chatterinoLua().categoryName()));
|
||||
|
||||
using LogLevel = lua::api::LogLevel;
|
||||
|
||||
switch (lvl)
|
||||
{
|
||||
case LogLevel::Debug:
|
||||
return base.debug();
|
||||
case LogLevel::Info:
|
||||
return base.info();
|
||||
case LogLevel::Warning:
|
||||
return base.warning();
|
||||
case LogLevel::Critical:
|
||||
return base.critical();
|
||||
default:
|
||||
assert(false && "if this happens magic_enum must have failed us");
|
||||
return {(QString *)nullptr};
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// NOLINTBEGIN(*vararg)
|
||||
// luaL_error is a c-style vararg function, this makes clang-tidy not dislike it so much
|
||||
namespace chatterino::lua::api {
|
||||
|
||||
int c2_register_command(lua_State *L)
|
||||
{
|
||||
auto *pl = getApp()->plugins->getPluginByStatePtr(L);
|
||||
if (pl == nullptr)
|
||||
{
|
||||
luaL_error(L, "internal error: no plugin");
|
||||
return 0;
|
||||
}
|
||||
|
||||
QString name;
|
||||
if (!lua::peek(L, &name, 1))
|
||||
{
|
||||
luaL_error(L, "cannot get command name (1st arg of register_command, "
|
||||
"expected a string)");
|
||||
return 0;
|
||||
}
|
||||
if (lua_isnoneornil(L, 2))
|
||||
{
|
||||
luaL_error(L, "missing argument for register_command: function "
|
||||
"\"pointer\"");
|
||||
return 0;
|
||||
}
|
||||
|
||||
auto callbackSavedName = QString("c2commandcb-%1").arg(name);
|
||||
lua_setfield(L, LUA_REGISTRYINDEX, callbackSavedName.toStdString().c_str());
|
||||
auto ok = pl->registerCommand(name, callbackSavedName);
|
||||
|
||||
// delete both name and callback
|
||||
lua_pop(L, 2);
|
||||
|
||||
lua::push(L, ok);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int c2_send_msg(lua_State *L)
|
||||
{
|
||||
QString text;
|
||||
QString channel;
|
||||
if (lua_gettop(L) != 2)
|
||||
{
|
||||
luaL_error(L, "send_msg needs exactly 2 arguments (channel and text)");
|
||||
lua::push(L, false);
|
||||
return 1;
|
||||
}
|
||||
if (!lua::pop(L, &text))
|
||||
{
|
||||
luaL_error(
|
||||
L, "cannot get text (2nd argument of send_msg, expected a string)");
|
||||
lua::push(L, false);
|
||||
return 1;
|
||||
}
|
||||
if (!lua::pop(L, &channel))
|
||||
{
|
||||
luaL_error(
|
||||
L,
|
||||
"cannot get channel (1st argument of send_msg, expected a string)");
|
||||
lua::push(L, false);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const auto chn = getApp()->twitch->getChannelOrEmpty(channel);
|
||||
if (chn->isEmpty())
|
||||
{
|
||||
auto *pl = getApp()->plugins->getPluginByStatePtr(L);
|
||||
|
||||
qCWarning(chatterinoLua)
|
||||
<< "Plugin" << pl->id
|
||||
<< "tried to send a message (using send_msg) to channel" << channel
|
||||
<< "which is not known";
|
||||
lua::push(L, false);
|
||||
return 1;
|
||||
}
|
||||
QString message = text;
|
||||
message = message.replace('\n', ' ');
|
||||
QString outText = getApp()->commands->execCommand(message, chn, false);
|
||||
chn->sendMessage(outText);
|
||||
lua::push(L, true);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int c2_system_msg(lua_State *L)
|
||||
{
|
||||
if (lua_gettop(L) != 2)
|
||||
{
|
||||
luaL_error(L,
|
||||
"system_msg needs exactly 2 arguments (channel and text)");
|
||||
lua::push(L, false);
|
||||
return 1;
|
||||
}
|
||||
QString channel;
|
||||
QString text;
|
||||
|
||||
if (!lua::pop(L, &text))
|
||||
{
|
||||
luaL_error(
|
||||
L,
|
||||
"cannot get text (2nd argument of system_msg, expected a string)");
|
||||
lua::push(L, false);
|
||||
return 1;
|
||||
}
|
||||
if (!lua::pop(L, &channel))
|
||||
{
|
||||
luaL_error(L, "cannot get channel (1st argument of system_msg, "
|
||||
"expected a string)");
|
||||
lua::push(L, false);
|
||||
return 1;
|
||||
}
|
||||
const auto chn = getApp()->twitch->getChannelOrEmpty(channel);
|
||||
if (chn->isEmpty())
|
||||
{
|
||||
auto *pl = getApp()->plugins->getPluginByStatePtr(L);
|
||||
qCWarning(chatterinoLua)
|
||||
<< "Plugin" << pl->id
|
||||
<< "tried to show a system message (using system_msg) in channel"
|
||||
<< channel << "which is not known";
|
||||
lua::push(L, false);
|
||||
return 1;
|
||||
}
|
||||
chn->addMessage(makeSystemMessage(text));
|
||||
lua::push(L, true);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int c2_log(lua_State *L)
|
||||
{
|
||||
auto *pl = getApp()->plugins->getPluginByStatePtr(L);
|
||||
if (pl == nullptr)
|
||||
{
|
||||
luaL_error(L, "c2_log: internal error: no plugin?");
|
||||
return 0;
|
||||
}
|
||||
auto logc = lua_gettop(L) - 1;
|
||||
// This is almost the expansion of qCDebug() macro, actual thing is wrapped in a for loop
|
||||
LogLevel lvl{};
|
||||
if (!lua::pop(L, &lvl, 1))
|
||||
{
|
||||
luaL_error(L, "Invalid log level, use one from c2.LogLevel.");
|
||||
return 0;
|
||||
}
|
||||
QDebug stream = qdebugStreamForLogLevel(lvl);
|
||||
logHelper(L, pl, stream, logc);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int g_load(lua_State *L)
|
||||
{
|
||||
# ifdef NDEBUG
|
||||
luaL_error(L, "load() is only usable in debug mode");
|
||||
return 0;
|
||||
# else
|
||||
auto countArgs = lua_gettop(L);
|
||||
QByteArray data;
|
||||
if (lua::peek(L, &data, 1))
|
||||
{
|
||||
auto *utf8 = QTextCodec::codecForName("UTF-8");
|
||||
QTextCodec::ConverterState state;
|
||||
utf8->toUnicode(data.constData(), data.size(), &state);
|
||||
if (state.invalidChars != 0)
|
||||
{
|
||||
luaL_error(L, "invalid utf-8 in load() is not allowed");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
luaL_error(L, "using reader function in load() is not allowed");
|
||||
return 0;
|
||||
}
|
||||
|
||||
for (int i = 0; i < countArgs; i++)
|
||||
{
|
||||
lua_seti(L, LUA_REGISTRYINDEX, i);
|
||||
}
|
||||
|
||||
// fetch load and call it
|
||||
lua_getfield(L, LUA_REGISTRYINDEX, "real_load");
|
||||
|
||||
for (int i = 0; i < countArgs; i++)
|
||||
{
|
||||
lua_geti(L, LUA_REGISTRYINDEX, i);
|
||||
lua_pushnil(L);
|
||||
lua_seti(L, LUA_REGISTRYINDEX, i);
|
||||
}
|
||||
|
||||
lua_call(L, countArgs, LUA_MULTRET);
|
||||
|
||||
return lua_gettop(L);
|
||||
# endif
|
||||
}
|
||||
|
||||
int g_import(lua_State *L)
|
||||
{
|
||||
auto countArgs = lua_gettop(L);
|
||||
// Lua allows dofile() which loads from stdin, but this is very useless in our case
|
||||
if (countArgs == 0)
|
||||
{
|
||||
lua_pushnil(L);
|
||||
luaL_error(L, "it is not allowed to call import() without arguments");
|
||||
return 1;
|
||||
}
|
||||
|
||||
auto *pl = getApp()->plugins->getPluginByStatePtr(L);
|
||||
QString fname;
|
||||
if (!lua::pop(L, &fname))
|
||||
{
|
||||
lua_pushnil(L);
|
||||
luaL_error(L, "chatterino g_import: expected a string for a filename");
|
||||
return 1;
|
||||
}
|
||||
auto dir = QUrl(pl->loadDirectory().canonicalPath() + "/");
|
||||
auto file = dir.resolved(fname);
|
||||
|
||||
qCDebug(chatterinoLua) << "plugin" << pl->id << "is trying to load" << file
|
||||
<< "(its dir is" << dir << ")";
|
||||
if (!dir.isParentOf(file))
|
||||
{
|
||||
lua_pushnil(L);
|
||||
luaL_error(L, "chatterino g_import: filename must be inside of the "
|
||||
"plugin directory");
|
||||
return 1;
|
||||
}
|
||||
|
||||
auto path = file.path(QUrl::FullyDecoded);
|
||||
QFile qf(path);
|
||||
qf.open(QIODevice::ReadOnly);
|
||||
if (qf.size() > 10'000'000)
|
||||
{
|
||||
lua_pushnil(L);
|
||||
luaL_error(L, "chatterino g_import: size limit of 10MB exceeded, what "
|
||||
"the hell are you doing");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// validate utf-8 to block bytecode exploits
|
||||
auto data = qf.readAll();
|
||||
auto *utf8 = QTextCodec::codecForName("UTF-8");
|
||||
QTextCodec::ConverterState state;
|
||||
utf8->toUnicode(data.constData(), data.size(), &state);
|
||||
if (state.invalidChars != 0)
|
||||
{
|
||||
lua_pushnil(L);
|
||||
luaL_error(L, "invalid utf-8 in import() target (%s) is not allowed",
|
||||
fname.toStdString().c_str());
|
||||
return 1;
|
||||
}
|
||||
|
||||
// fetch dofile and call it
|
||||
lua_getfield(L, LUA_REGISTRYINDEX, "real_dofile");
|
||||
// maybe data race here if symlink was swapped?
|
||||
lua::push(L, path);
|
||||
lua_call(L, 1, LUA_MULTRET);
|
||||
|
||||
return lua_gettop(L);
|
||||
}
|
||||
|
||||
int g_print(lua_State *L)
|
||||
{
|
||||
auto *pl = getApp()->plugins->getPluginByStatePtr(L);
|
||||
if (pl == nullptr)
|
||||
{
|
||||
luaL_error(L, "c2_print: internal error: no plugin?");
|
||||
return 0;
|
||||
}
|
||||
auto argc = lua_gettop(L);
|
||||
// This is almost the expansion of qCDebug() macro, actual thing is wrapped in a for loop
|
||||
auto stream =
|
||||
(QMessageLogger(QT_MESSAGELOG_FILE, QT_MESSAGELOG_LINE,
|
||||
QT_MESSAGELOG_FUNC, chatterinoLua().categoryName())
|
||||
.debug());
|
||||
logHelper(L, pl, stream, argc);
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace chatterino::lua::api
|
||||
// NOLINTEND(*vararg)
|
||||
#endif
|
||||
@@ -0,0 +1,28 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef CHATTERINO_HAVE_PLUGINS
|
||||
|
||||
struct lua_State;
|
||||
namespace chatterino::lua::api {
|
||||
// names in this namespace reflect what's visible inside Lua and follow the lua naming scheme
|
||||
|
||||
// NOLINTBEGIN(readability-identifier-naming)
|
||||
// Following functions are exposed in c2 table.
|
||||
int c2_register_command(lua_State *L);
|
||||
int c2_send_msg(lua_State *L);
|
||||
int c2_system_msg(lua_State *L);
|
||||
int c2_log(lua_State *L);
|
||||
|
||||
// These ones are global
|
||||
int g_load(lua_State *L);
|
||||
int g_print(lua_State *L);
|
||||
int g_import(lua_State *L);
|
||||
// NOLINTEND(readability-identifier-naming)
|
||||
|
||||
// Exposed as c2.LogLevel
|
||||
// Represents "calls" to qCDebug, qCInfo ...
|
||||
enum class LogLevel { Debug, Info, Warning, Critical };
|
||||
|
||||
} // namespace chatterino::lua::api
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,196 @@
|
||||
#ifdef CHATTERINO_HAVE_PLUGINS
|
||||
# include "controllers/plugins/LuaUtilities.hpp"
|
||||
|
||||
# include "common/Channel.hpp"
|
||||
# include "common/QLogging.hpp"
|
||||
# include "controllers/commands/CommandContext.hpp"
|
||||
|
||||
# include <lauxlib.h>
|
||||
# include <lua.h>
|
||||
|
||||
# include <climits>
|
||||
# include <cstdlib>
|
||||
|
||||
namespace chatterino::lua {
|
||||
|
||||
void stackDump(lua_State *L, const QString &tag)
|
||||
{
|
||||
qCDebug(chatterinoLua) << "--------------------";
|
||||
auto count = lua_gettop(L);
|
||||
if (!tag.isEmpty())
|
||||
{
|
||||
qCDebug(chatterinoLua) << "Tag: " << tag;
|
||||
}
|
||||
qCDebug(chatterinoLua) << "Count elems: " << count;
|
||||
for (int i = 1; i <= count; i++)
|
||||
{
|
||||
auto typeint = lua_type(L, i);
|
||||
if (typeint == LUA_TSTRING)
|
||||
{
|
||||
QString str;
|
||||
lua::peek(L, &str, i);
|
||||
qCDebug(chatterinoLua)
|
||||
<< "At" << i << "is a" << lua_typename(L, typeint) << "("
|
||||
<< typeint << "): " << str;
|
||||
}
|
||||
else if (typeint == LUA_TTABLE)
|
||||
{
|
||||
qCDebug(chatterinoLua)
|
||||
<< "At" << i << "is a" << lua_typename(L, typeint) << "("
|
||||
<< typeint << ")"
|
||||
<< "its length is " << lua_rawlen(L, i);
|
||||
}
|
||||
else
|
||||
{
|
||||
qCDebug(chatterinoLua)
|
||||
<< "At" << i << "is a" << lua_typename(L, typeint) << "("
|
||||
<< typeint << ")";
|
||||
}
|
||||
}
|
||||
qCDebug(chatterinoLua) << "--------------------";
|
||||
}
|
||||
|
||||
QString humanErrorText(lua_State *L, int errCode)
|
||||
{
|
||||
QString errName;
|
||||
switch (errCode)
|
||||
{
|
||||
case LUA_OK:
|
||||
return "ok";
|
||||
case LUA_ERRRUN:
|
||||
errName = "(runtime error)";
|
||||
break;
|
||||
case LUA_ERRMEM:
|
||||
errName = "(memory error)";
|
||||
break;
|
||||
case LUA_ERRERR:
|
||||
errName = "(error while handling another error)";
|
||||
break;
|
||||
case LUA_ERRSYNTAX:
|
||||
errName = "(syntax error)";
|
||||
break;
|
||||
case LUA_YIELD:
|
||||
errName = "(illegal coroutine yield)";
|
||||
break;
|
||||
case LUA_ERRFILE:
|
||||
errName = "(file error)";
|
||||
break;
|
||||
default:
|
||||
errName = "(unknown error type)";
|
||||
}
|
||||
QString errText;
|
||||
if (peek(L, &errText))
|
||||
{
|
||||
errName += " " + errText;
|
||||
}
|
||||
return errName;
|
||||
}
|
||||
|
||||
StackIdx pushEmptyArray(lua_State *L, int countArray)
|
||||
{
|
||||
lua_createtable(L, countArray, 0);
|
||||
return lua_gettop(L);
|
||||
}
|
||||
|
||||
StackIdx pushEmptyTable(lua_State *L, int countProperties)
|
||||
{
|
||||
lua_createtable(L, 0, countProperties);
|
||||
return lua_gettop(L);
|
||||
}
|
||||
|
||||
StackIdx push(lua_State *L, const QString &str)
|
||||
{
|
||||
return lua::push(L, str.toStdString());
|
||||
}
|
||||
|
||||
StackIdx push(lua_State *L, const std::string &str)
|
||||
{
|
||||
lua_pushstring(L, str.c_str());
|
||||
return lua_gettop(L);
|
||||
}
|
||||
|
||||
StackIdx push(lua_State *L, const CommandContext &ctx)
|
||||
{
|
||||
auto outIdx = pushEmptyTable(L, 2);
|
||||
|
||||
push(L, ctx.words);
|
||||
lua_setfield(L, outIdx, "words");
|
||||
push(L, ctx.channel->getName());
|
||||
lua_setfield(L, outIdx, "channel_name");
|
||||
|
||||
return outIdx;
|
||||
}
|
||||
|
||||
StackIdx push(lua_State *L, const bool &b)
|
||||
{
|
||||
lua_pushboolean(L, int(b));
|
||||
return lua_gettop(L);
|
||||
}
|
||||
|
||||
bool peek(lua_State *L, double *out, StackIdx idx)
|
||||
{
|
||||
int ok{0};
|
||||
auto v = lua_tonumberx(L, idx, &ok);
|
||||
if (ok != 0)
|
||||
{
|
||||
*out = v;
|
||||
}
|
||||
return ok != 0;
|
||||
}
|
||||
|
||||
bool peek(lua_State *L, QString *out, StackIdx idx)
|
||||
{
|
||||
size_t len{0};
|
||||
const char *str = lua_tolstring(L, idx, &len);
|
||||
if (str == nullptr)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (len >= INT_MAX)
|
||||
{
|
||||
assert(false && "string longer than INT_MAX, shit's fucked, yo");
|
||||
}
|
||||
*out = QString::fromUtf8(str, int(len));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool peek(lua_State *L, QByteArray *out, StackIdx idx)
|
||||
{
|
||||
size_t len{0};
|
||||
const char *str = lua_tolstring(L, idx, &len);
|
||||
if (str == nullptr)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (len >= INT_MAX)
|
||||
{
|
||||
assert(false && "string longer than INT_MAX, shit's fucked, yo");
|
||||
}
|
||||
*out = QByteArray(str, int(len));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool peek(lua_State *L, std::string *out, StackIdx idx)
|
||||
{
|
||||
size_t len{0};
|
||||
const char *str = lua_tolstring(L, idx, &len);
|
||||
if (str == nullptr)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (len >= INT_MAX)
|
||||
{
|
||||
assert(false && "string longer than INT_MAX, shit's fucked, yo");
|
||||
}
|
||||
*out = std::string(str, len);
|
||||
return true;
|
||||
}
|
||||
|
||||
QString toString(lua_State *L, StackIdx idx)
|
||||
{
|
||||
size_t len{};
|
||||
const auto *ptr = luaL_tolstring(L, idx, &len);
|
||||
return QString::fromUtf8(ptr, int(len));
|
||||
}
|
||||
} // namespace chatterino::lua
|
||||
#endif
|
||||
@@ -0,0 +1,191 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef CHATTERINO_HAVE_PLUGINS
|
||||
|
||||
# include <lua.h>
|
||||
# include <lualib.h>
|
||||
# include <magic_enum.hpp>
|
||||
# include <QList>
|
||||
|
||||
# include <string>
|
||||
# include <string_view>
|
||||
# include <type_traits>
|
||||
# include <vector>
|
||||
struct lua_State;
|
||||
class QJsonObject;
|
||||
namespace chatterino {
|
||||
struct CommandContext;
|
||||
} // namespace chatterino
|
||||
|
||||
namespace chatterino::lua {
|
||||
|
||||
/**
|
||||
* @brief Dumps the Lua stack into qCDebug(chatterinoLua)
|
||||
*
|
||||
* @param tag is a string to let you know which dump is which when browsing logs
|
||||
*/
|
||||
void stackDump(lua_State *L, const QString &tag);
|
||||
|
||||
/**
|
||||
* @brief Converts a lua error code and potentially string on top of the stack into a human readable message
|
||||
*/
|
||||
QString humanErrorText(lua_State *L, int errCode);
|
||||
|
||||
/**
|
||||
* Represents an index into Lua's stack
|
||||
*/
|
||||
using StackIdx = int;
|
||||
|
||||
/**
|
||||
* @brief Creates a table with countArray array properties on the Lua stack
|
||||
* @return stack index of the newly created table
|
||||
*/
|
||||
StackIdx pushEmptyArray(lua_State *L, int countArray);
|
||||
|
||||
/**
|
||||
* @brief Creates a table with countProperties named properties on the Lua stack
|
||||
* @return stack index of the newly created table
|
||||
*/
|
||||
StackIdx pushEmptyTable(lua_State *L, int countProperties);
|
||||
|
||||
StackIdx push(lua_State *L, const CommandContext &ctx);
|
||||
StackIdx push(lua_State *L, const QString &str);
|
||||
StackIdx push(lua_State *L, const std::string &str);
|
||||
StackIdx push(lua_State *L, const bool &b);
|
||||
|
||||
// returns OK?
|
||||
bool peek(lua_State *L, double *out, StackIdx idx = -1);
|
||||
bool peek(lua_State *L, QString *out, StackIdx idx = -1);
|
||||
bool peek(lua_State *L, QByteArray *out, StackIdx idx = -1);
|
||||
bool peek(lua_State *L, std::string *out, StackIdx idx = -1);
|
||||
|
||||
/**
|
||||
* @brief Converts Lua object at stack index idx to a string.
|
||||
*/
|
||||
QString toString(lua_State *L, StackIdx idx = -1);
|
||||
|
||||
/// TEMPLATES
|
||||
|
||||
/**
|
||||
* @brief Converts object at stack index idx to enum given by template parameter T
|
||||
*/
|
||||
template <typename T,
|
||||
typename std::enable_if<std::is_enum_v<T>, bool>::type = true>
|
||||
bool peek(lua_State *L, T *out, StackIdx idx = -1)
|
||||
{
|
||||
std::string tmp;
|
||||
if (!lua::peek(L, &tmp, idx))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
std::optional<T> opt = magic_enum::enum_cast<T>(tmp);
|
||||
if (opt.has_value())
|
||||
{
|
||||
*out = opt.value();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Converts a vector<T> to Lua and pushes it onto the stack.
|
||||
*
|
||||
* Needs StackIdx push(lua_State*, T); to work.
|
||||
*
|
||||
* @return Stack index of newly created table.
|
||||
*/
|
||||
template <typename T>
|
||||
StackIdx push(lua_State *L, std::vector<T> vec)
|
||||
{
|
||||
auto out = pushEmptyArray(L, vec.size());
|
||||
int i = 1;
|
||||
for (const auto &el : vec)
|
||||
{
|
||||
push(L, el);
|
||||
lua_seti(L, out, i);
|
||||
i += 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Converts a QList<T> to Lua and pushes it onto the stack.
|
||||
*
|
||||
* Needs StackIdx push(lua_State*, T); to work.
|
||||
*
|
||||
* @return Stack index of newly created table.
|
||||
*/
|
||||
template <typename T>
|
||||
StackIdx push(lua_State *L, QList<T> vec)
|
||||
{
|
||||
auto out = pushEmptyArray(L, vec.size());
|
||||
int i = 1;
|
||||
for (const auto &el : vec)
|
||||
{
|
||||
push(L, el);
|
||||
lua_seti(L, out, i);
|
||||
i += 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Converts an enum given by T to Lua (into a string) and pushes it onto the stack.
|
||||
*
|
||||
* @return Stack index of newly created string.
|
||||
*/
|
||||
template <typename T, std::enable_if<std::is_enum_v<T>>>
|
||||
StackIdx push(lua_State *L, T inp)
|
||||
{
|
||||
std::string_view name = magic_enum::enum_name<T>(inp);
|
||||
return lua::push(L, std::string(name));
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Converts a Lua object into c++ and removes it from the stack.
|
||||
*
|
||||
* Relies on bool peek(lua_State*, T*, StackIdx) existing.
|
||||
*/
|
||||
template <typename T>
|
||||
bool pop(lua_State *L, T *out, StackIdx idx = -1)
|
||||
{
|
||||
auto ok = peek(L, out, idx);
|
||||
if (ok)
|
||||
{
|
||||
if (idx < 0)
|
||||
{
|
||||
idx = lua_gettop(L) + idx + 1;
|
||||
}
|
||||
lua_remove(L, idx);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Creates a table mapping enum names to unique values.
|
||||
*
|
||||
* Values in this table may change.
|
||||
*
|
||||
* @returns stack index of newly created table
|
||||
*/
|
||||
template <typename T>
|
||||
StackIdx pushEnumTable(lua_State *L)
|
||||
{
|
||||
// std::array<T, _>
|
||||
auto values = magic_enum::enum_values<T>();
|
||||
StackIdx out = lua::pushEmptyTable(L, values.size());
|
||||
for (const T v : values)
|
||||
{
|
||||
std::string_view name = magic_enum::enum_name<T>(v);
|
||||
std::string str(name);
|
||||
|
||||
lua::push(L, str);
|
||||
lua_setfield(L, out, str.c_str());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace chatterino::lua
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,177 @@
|
||||
#ifdef CHATTERINO_HAVE_PLUGINS
|
||||
# include "controllers/plugins/Plugin.hpp"
|
||||
|
||||
# include "controllers/commands/CommandController.hpp"
|
||||
|
||||
# include <lua.h>
|
||||
# include <magic_enum.hpp>
|
||||
# include <QJsonArray>
|
||||
# include <QJsonObject>
|
||||
|
||||
# include <unordered_map>
|
||||
# include <unordered_set>
|
||||
|
||||
namespace chatterino {
|
||||
|
||||
PluginMeta::PluginMeta(const QJsonObject &obj)
|
||||
{
|
||||
auto homepageObj = obj.value("homepage");
|
||||
if (homepageObj.isString())
|
||||
{
|
||||
this->homepage = homepageObj.toString();
|
||||
}
|
||||
else if (!homepageObj.isUndefined())
|
||||
{
|
||||
QString type = magic_enum::enum_name(homepageObj.type()).data();
|
||||
this->errors.emplace_back(
|
||||
QString("homepage is defined but is not a string (its type is %1)")
|
||||
.arg(type));
|
||||
}
|
||||
auto nameObj = obj.value("name");
|
||||
if (nameObj.isString())
|
||||
{
|
||||
this->name = nameObj.toString();
|
||||
}
|
||||
else
|
||||
{
|
||||
QString type = magic_enum::enum_name(nameObj.type()).data();
|
||||
this->errors.emplace_back(
|
||||
QString("name is not a string (its type is %1)").arg(type));
|
||||
}
|
||||
|
||||
auto descrObj = obj.value("description");
|
||||
if (descrObj.isString())
|
||||
{
|
||||
this->description = descrObj.toString();
|
||||
}
|
||||
else
|
||||
{
|
||||
QString type = magic_enum::enum_name(descrObj.type()).data();
|
||||
this->errors.emplace_back(
|
||||
QString("description is not a string (its type is %1)").arg(type));
|
||||
}
|
||||
|
||||
auto authorsObj = obj.value("authors");
|
||||
if (authorsObj.isArray())
|
||||
{
|
||||
auto authorsArr = authorsObj.toArray();
|
||||
for (int i = 0; i < authorsArr.size(); i++)
|
||||
{
|
||||
const auto &t = authorsArr.at(i);
|
||||
if (!t.isString())
|
||||
{
|
||||
QString type = magic_enum::enum_name(t.type()).data();
|
||||
this->errors.push_back(
|
||||
QString("authors element #%1 is not a string (it is a %2)")
|
||||
.arg(i)
|
||||
.arg(type));
|
||||
break;
|
||||
}
|
||||
this->authors.push_back(t.toString());
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
QString type = magic_enum::enum_name(authorsObj.type()).data();
|
||||
this->errors.emplace_back(
|
||||
QString("authors is not an array (its type is %1)").arg(type));
|
||||
}
|
||||
|
||||
auto licenseObj = obj.value("license");
|
||||
if (licenseObj.isString())
|
||||
{
|
||||
this->license = licenseObj.toString();
|
||||
}
|
||||
else
|
||||
{
|
||||
QString type = magic_enum::enum_name(licenseObj.type()).data();
|
||||
this->errors.emplace_back(
|
||||
QString("license is not a string (its type is %1)").arg(type));
|
||||
}
|
||||
|
||||
auto verObj = obj.value("version");
|
||||
if (verObj.isString())
|
||||
{
|
||||
auto v = semver::from_string_noexcept(verObj.toString().toStdString());
|
||||
if (v.has_value())
|
||||
{
|
||||
this->version = v.value();
|
||||
}
|
||||
else
|
||||
{
|
||||
this->errors.emplace_back("unable to parse version (use semver)");
|
||||
this->version = semver::version(0, 0, 0);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
QString type = magic_enum::enum_name(verObj.type()).data();
|
||||
this->errors.emplace_back(
|
||||
QString("version is not a string (its type is %1)").arg(type));
|
||||
this->version = semver::version(0, 0, 0);
|
||||
}
|
||||
auto tagsObj = obj.value("tags");
|
||||
if (!tagsObj.isUndefined())
|
||||
{
|
||||
if (!tagsObj.isArray())
|
||||
{
|
||||
QString type = magic_enum::enum_name(tagsObj.type()).data();
|
||||
this->errors.emplace_back(
|
||||
QString("tags is not an array (its type is %1)").arg(type));
|
||||
return;
|
||||
}
|
||||
|
||||
auto tagsArr = tagsObj.toArray();
|
||||
for (int i = 0; i < tagsArr.size(); i++)
|
||||
{
|
||||
const auto &t = tagsArr.at(i);
|
||||
if (!t.isString())
|
||||
{
|
||||
QString type = magic_enum::enum_name(t.type()).data();
|
||||
this->errors.push_back(
|
||||
QString("tags element #%1 is not a string (its type is %2)")
|
||||
.arg(i)
|
||||
.arg(type));
|
||||
return;
|
||||
}
|
||||
this->tags.push_back(t.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool Plugin::registerCommand(const QString &name, const QString &functionName)
|
||||
{
|
||||
if (this->ownedCommands.find(name) != this->ownedCommands.end())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
auto ok = getApp()->commands->registerPluginCommand(name);
|
||||
if (!ok)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
this->ownedCommands.insert({name, functionName});
|
||||
return true;
|
||||
}
|
||||
|
||||
std::unordered_set<QString> Plugin::listRegisteredCommands()
|
||||
{
|
||||
std::unordered_set<QString> out;
|
||||
for (const auto &[name, _] : this->ownedCommands)
|
||||
{
|
||||
out.insert(name);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Plugin::~Plugin()
|
||||
{
|
||||
if (this->state_ != nullptr)
|
||||
{
|
||||
lua_close(this->state_);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace chatterino
|
||||
#endif
|
||||
@@ -0,0 +1,98 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef CHATTERINO_HAVE_PLUGINS
|
||||
# include "Application.hpp"
|
||||
|
||||
# include <QDir>
|
||||
# include <QString>
|
||||
# include <semver/semver.hpp>
|
||||
|
||||
# include <unordered_map>
|
||||
# include <unordered_set>
|
||||
# include <vector>
|
||||
|
||||
struct lua_State;
|
||||
|
||||
namespace chatterino {
|
||||
|
||||
struct PluginMeta {
|
||||
// for more info on these fields see docs/plugin-info.schema.json
|
||||
|
||||
// display name of the plugin
|
||||
QString name;
|
||||
|
||||
// description shown to the user
|
||||
QString description;
|
||||
|
||||
// plugin authors shown to the user
|
||||
std::vector<QString> authors;
|
||||
|
||||
// license name
|
||||
QString license;
|
||||
|
||||
// version of the plugin
|
||||
semver::version version;
|
||||
|
||||
// optionally a homepage link
|
||||
QString homepage;
|
||||
|
||||
// optionally tags that might help in searching for the plugin
|
||||
std::vector<QString> tags;
|
||||
|
||||
// errors that occurred while parsing info.json
|
||||
std::vector<QString> errors;
|
||||
|
||||
bool isValid() const
|
||||
{
|
||||
return this->errors.empty();
|
||||
}
|
||||
|
||||
explicit PluginMeta(const QJsonObject &obj);
|
||||
};
|
||||
|
||||
class Plugin
|
||||
{
|
||||
public:
|
||||
QString id;
|
||||
PluginMeta meta;
|
||||
|
||||
Plugin(QString id, lua_State *state, PluginMeta meta,
|
||||
const QDir &loadDirectory)
|
||||
: id(std::move(id))
|
||||
, meta(std::move(meta))
|
||||
, loadDirectory_(loadDirectory)
|
||||
, state_(state)
|
||||
{
|
||||
}
|
||||
|
||||
~Plugin();
|
||||
|
||||
/**
|
||||
* @brief Perform all necessary tasks to bind a command name to this plugin
|
||||
* @param name name of the command to create
|
||||
* @param functionName name of the function that should be called when the command is executed
|
||||
* @return true if addition succeeded, false otherwise (for example because the command name is already taken)
|
||||
*/
|
||||
bool registerCommand(const QString &name, const QString &functionName);
|
||||
|
||||
/**
|
||||
* @brief Get names of all commands belonging to this plugin
|
||||
*/
|
||||
std::unordered_set<QString> listRegisteredCommands();
|
||||
|
||||
const QDir &loadDirectory() const
|
||||
{
|
||||
return this->loadDirectory_;
|
||||
}
|
||||
|
||||
private:
|
||||
QDir loadDirectory_;
|
||||
lua_State *state_;
|
||||
|
||||
// maps command name -> function name
|
||||
std::unordered_map<QString, QString> ownedCommands;
|
||||
|
||||
friend class PluginController;
|
||||
};
|
||||
} // namespace chatterino
|
||||
#endif
|
||||
@@ -0,0 +1,302 @@
|
||||
#ifdef CHATTERINO_HAVE_PLUGINS
|
||||
# include "controllers/plugins/PluginController.hpp"
|
||||
|
||||
# include "Application.hpp"
|
||||
# include "common/QLogging.hpp"
|
||||
# include "controllers/commands/CommandContext.hpp"
|
||||
# include "controllers/commands/CommandController.hpp"
|
||||
# include "controllers/plugins/LuaAPI.hpp"
|
||||
# include "controllers/plugins/LuaUtilities.hpp"
|
||||
# include "messages/MessageBuilder.hpp"
|
||||
# include "singletons/Paths.hpp"
|
||||
# include "singletons/Settings.hpp"
|
||||
|
||||
# include <lauxlib.h>
|
||||
# include <lua.h>
|
||||
# include <lualib.h>
|
||||
# include <QJsonDocument>
|
||||
|
||||
# include <memory>
|
||||
# include <utility>
|
||||
|
||||
namespace chatterino {
|
||||
|
||||
void PluginController::initialize(Settings &settings, Paths &paths)
|
||||
{
|
||||
(void)paths;
|
||||
|
||||
// actuallyInitialize will be called by this connection
|
||||
settings.pluginsEnabled.connect([this](bool enabled) {
|
||||
if (enabled)
|
||||
{
|
||||
this->loadPlugins();
|
||||
}
|
||||
else
|
||||
{
|
||||
// uninitialize plugins
|
||||
this->plugins_.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void PluginController::loadPlugins()
|
||||
{
|
||||
this->plugins_.clear();
|
||||
auto dir = QDir(getPaths()->pluginsDirectory);
|
||||
qCDebug(chatterinoLua) << "Loading plugins in" << dir.path();
|
||||
for (const auto &info :
|
||||
dir.entryInfoList(QDir::NoFilter | QDir::NoDotAndDotDot))
|
||||
{
|
||||
if (info.isDir())
|
||||
{
|
||||
auto pluginDir = QDir(info.absoluteFilePath());
|
||||
this->tryLoadFromDir(pluginDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
bool PluginController::tryLoadFromDir(const QDir &pluginDir)
|
||||
{
|
||||
// look for init.lua
|
||||
auto index = QFileInfo(pluginDir.filePath("init.lua"));
|
||||
qCDebug(chatterinoLua) << "Looking for init.lua and info.json in"
|
||||
<< pluginDir.path();
|
||||
if (!index.exists())
|
||||
{
|
||||
qCWarning(chatterinoLua)
|
||||
<< "Missing init.lua in plugin directory:" << pluginDir.path();
|
||||
return false;
|
||||
}
|
||||
qCDebug(chatterinoLua) << "Found init.lua, now looking for info.json!";
|
||||
auto infojson = QFileInfo(pluginDir.filePath("info.json"));
|
||||
if (!infojson.exists())
|
||||
{
|
||||
qCWarning(chatterinoLua)
|
||||
<< "Missing info.json in plugin directory" << pluginDir.path();
|
||||
return false;
|
||||
}
|
||||
QFile infoFile(infojson.absoluteFilePath());
|
||||
infoFile.open(QIODevice::ReadOnly);
|
||||
auto everything = infoFile.readAll();
|
||||
auto doc = QJsonDocument::fromJson(everything);
|
||||
if (!doc.isObject())
|
||||
{
|
||||
qCWarning(chatterinoLua)
|
||||
<< "info.json root is not an object" << pluginDir.path();
|
||||
return false;
|
||||
}
|
||||
|
||||
auto meta = PluginMeta(doc.object());
|
||||
if (!meta.isValid())
|
||||
{
|
||||
qCWarning(chatterinoLua)
|
||||
<< "Plugin from" << pluginDir << "is invalid because:";
|
||||
for (const auto &why : meta.errors)
|
||||
{
|
||||
qCWarning(chatterinoLua) << "- " << why;
|
||||
}
|
||||
auto plugin = std::make_unique<Plugin>(pluginDir.dirName(), nullptr,
|
||||
meta, pluginDir);
|
||||
this->plugins_.insert({pluginDir.dirName(), std::move(plugin)});
|
||||
return false;
|
||||
}
|
||||
this->load(index, pluginDir, meta);
|
||||
return true;
|
||||
}
|
||||
|
||||
void PluginController::openLibrariesFor(lua_State *L,
|
||||
const PluginMeta & /*meta*/)
|
||||
{
|
||||
// Stuff to change, remove or hide behind a permission system:
|
||||
static const std::vector<luaL_Reg> loadedlibs = {
|
||||
luaL_Reg{LUA_GNAME, luaopen_base},
|
||||
// - load - don't allow in release mode
|
||||
|
||||
//luaL_Reg{LUA_COLIBNAME, luaopen_coroutine},
|
||||
// - needs special support
|
||||
luaL_Reg{LUA_TABLIBNAME, luaopen_table},
|
||||
// luaL_Reg{LUA_IOLIBNAME, luaopen_io},
|
||||
// - explicit fs access, needs wrapper with permissions, no usage ideas yet
|
||||
// luaL_Reg{LUA_OSLIBNAME, luaopen_os},
|
||||
// - fs access
|
||||
// - environ access
|
||||
// - exit
|
||||
luaL_Reg{LUA_STRLIBNAME, luaopen_string},
|
||||
luaL_Reg{LUA_MATHLIBNAME, luaopen_math},
|
||||
luaL_Reg{LUA_UTF8LIBNAME, luaopen_utf8},
|
||||
};
|
||||
// Warning: Do not add debug library to this, it would make the security of
|
||||
// this a living nightmare due to stuff like registry access
|
||||
// - Mm2PL
|
||||
|
||||
for (const auto ® : loadedlibs)
|
||||
{
|
||||
luaL_requiref(L, reg.name, reg.func, int(true));
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
|
||||
// NOLINTNEXTLINE(*-avoid-c-arrays)
|
||||
static const luaL_Reg c2Lib[] = {
|
||||
{"system_msg", lua::api::c2_system_msg},
|
||||
{"register_command", lua::api::c2_register_command},
|
||||
{"send_msg", lua::api::c2_send_msg},
|
||||
{"log", lua::api::c2_log},
|
||||
{nullptr, nullptr},
|
||||
};
|
||||
lua_pushglobaltable(L);
|
||||
auto global = lua_gettop(L);
|
||||
|
||||
// count of elements in C2LIB + LogLevel
|
||||
auto c2libIdx = lua::pushEmptyTable(L, 5);
|
||||
|
||||
luaL_setfuncs(L, c2Lib, 0);
|
||||
|
||||
lua::pushEnumTable<lua::api::LogLevel>(L);
|
||||
lua_setfield(L, c2libIdx, "LogLevel");
|
||||
|
||||
lua_setfield(L, global, "c2");
|
||||
|
||||
// ban functions
|
||||
// Note: this might not be fully secure? some kind of metatable fuckery might come up?
|
||||
|
||||
lua_pushglobaltable(L);
|
||||
auto gtable = lua_gettop(L);
|
||||
|
||||
// possibly randomize this name at runtime to prevent some attacks?
|
||||
|
||||
# ifndef NDEBUG
|
||||
lua_getfield(L, gtable, "load");
|
||||
lua_setfield(L, LUA_REGISTRYINDEX, "real_load");
|
||||
# endif
|
||||
|
||||
lua_getfield(L, gtable, "dofile");
|
||||
lua_setfield(L, LUA_REGISTRYINDEX, "real_dofile");
|
||||
|
||||
// NOLINTNEXTLINE(*-avoid-c-arrays)
|
||||
static const luaL_Reg replacementFuncs[] = {
|
||||
{"load", lua::api::g_load},
|
||||
{"print", lua::api::g_print},
|
||||
|
||||
// This function replaces both `dofile` and `require`, see docs/wip-plugins.md for more info
|
||||
{"import", lua::api::g_import},
|
||||
{nullptr, nullptr},
|
||||
};
|
||||
luaL_setfuncs(L, replacementFuncs, 0);
|
||||
|
||||
lua_pushnil(L);
|
||||
lua_setfield(L, gtable, "loadfile");
|
||||
|
||||
lua_pushnil(L);
|
||||
lua_setfield(L, gtable, "dofile");
|
||||
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
|
||||
void PluginController::load(const QFileInfo &index, const QDir &pluginDir,
|
||||
const PluginMeta &meta)
|
||||
{
|
||||
lua_State *l = luaL_newstate();
|
||||
PluginController::openLibrariesFor(l, meta);
|
||||
|
||||
auto pluginName = pluginDir.dirName();
|
||||
auto plugin = std::make_unique<Plugin>(pluginName, l, meta, pluginDir);
|
||||
this->plugins_.insert({pluginName, std::move(plugin)});
|
||||
if (!PluginController::isPluginEnabled(pluginName) ||
|
||||
!getSettings()->pluginsEnabled)
|
||||
{
|
||||
qCDebug(chatterinoLua) << "Skipping loading" << pluginName << "("
|
||||
<< meta.name << ") because it is disabled";
|
||||
return;
|
||||
}
|
||||
qCDebug(chatterinoLua) << "Running lua file:" << index;
|
||||
int err = luaL_dofile(l, index.absoluteFilePath().toStdString().c_str());
|
||||
if (err != 0)
|
||||
{
|
||||
qCWarning(chatterinoLua)
|
||||
<< "Failed to load" << pluginName << "plugin from" << index << ": "
|
||||
<< lua::humanErrorText(l, err);
|
||||
return;
|
||||
}
|
||||
qCInfo(chatterinoLua) << "Loaded" << pluginName << "plugin from" << index;
|
||||
}
|
||||
|
||||
bool PluginController::reload(const QString &id)
|
||||
{
|
||||
auto it = this->plugins_.find(id);
|
||||
if (it == this->plugins_.end())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (it->second->state_ != nullptr)
|
||||
{
|
||||
lua_close(it->second->state_);
|
||||
it->second->state_ = nullptr;
|
||||
}
|
||||
for (const auto &[cmd, _] : it->second->ownedCommands)
|
||||
{
|
||||
getApp()->commands->unregisterPluginCommand(cmd);
|
||||
}
|
||||
it->second->ownedCommands.clear();
|
||||
QDir loadDir = it->second->loadDirectory_;
|
||||
this->plugins_.erase(id);
|
||||
this->tryLoadFromDir(loadDir);
|
||||
return true;
|
||||
}
|
||||
|
||||
QString PluginController::tryExecPluginCommand(const QString &commandName,
|
||||
const CommandContext &ctx)
|
||||
{
|
||||
for (auto &[name, plugin] : this->plugins_)
|
||||
{
|
||||
if (auto it = plugin->ownedCommands.find(commandName);
|
||||
it != plugin->ownedCommands.end())
|
||||
{
|
||||
const auto &funcName = it->second;
|
||||
|
||||
auto *L = plugin->state_;
|
||||
lua_getfield(L, LUA_REGISTRYINDEX, funcName.toStdString().c_str());
|
||||
lua::push(L, ctx);
|
||||
|
||||
auto res = lua_pcall(L, 1, 0, 0);
|
||||
if (res != LUA_OK)
|
||||
{
|
||||
ctx.channel->addMessage(makeSystemMessage(
|
||||
"Lua error: " + lua::humanErrorText(L, res)));
|
||||
return "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
qCCritical(chatterinoLua)
|
||||
<< "Something's seriously up, no plugin owns command" << commandName
|
||||
<< "yet a call to execute it came in";
|
||||
assert(false && "missing plugin command owner");
|
||||
return "";
|
||||
}
|
||||
|
||||
bool PluginController::isPluginEnabled(const QString &id)
|
||||
{
|
||||
auto vec = getSettings()->enabledPlugins.getValue();
|
||||
auto it = std::find(vec.begin(), vec.end(), id);
|
||||
return it != vec.end();
|
||||
}
|
||||
|
||||
Plugin *PluginController::getPluginByStatePtr(lua_State *L)
|
||||
{
|
||||
for (auto &[name, plugin] : this->plugins_)
|
||||
{
|
||||
if (plugin->state_ == L)
|
||||
{
|
||||
return plugin.get();
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const std::map<QString, std::unique_ptr<Plugin>> &PluginController::plugins()
|
||||
const
|
||||
{
|
||||
return this->plugins_;
|
||||
}
|
||||
|
||||
}; // namespace chatterino
|
||||
#endif
|
||||
@@ -0,0 +1,68 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef CHATTERINO_HAVE_PLUGINS
|
||||
|
||||
# include "common/Singleton.hpp"
|
||||
# include "controllers/commands/CommandContext.hpp"
|
||||
# include "controllers/plugins/Plugin.hpp"
|
||||
|
||||
# include <QDir>
|
||||
# include <QFileInfo>
|
||||
# include <QJsonArray>
|
||||
# include <QJsonObject>
|
||||
# include <QString>
|
||||
|
||||
# include <algorithm>
|
||||
# include <map>
|
||||
# include <memory>
|
||||
# include <utility>
|
||||
# include <vector>
|
||||
|
||||
struct lua_State;
|
||||
|
||||
namespace chatterino {
|
||||
|
||||
class Paths;
|
||||
|
||||
class PluginController : public Singleton
|
||||
{
|
||||
public:
|
||||
void initialize(Settings &settings, Paths &paths) override;
|
||||
|
||||
QString tryExecPluginCommand(const QString &commandName,
|
||||
const CommandContext &ctx);
|
||||
|
||||
// NOTE: this pointer does not own the Plugin, unique_ptr still owns it
|
||||
// This is required to be public because of c functions
|
||||
Plugin *getPluginByStatePtr(lua_State *L);
|
||||
|
||||
const std::map<QString, std::unique_ptr<Plugin>> &plugins() const;
|
||||
|
||||
/**
|
||||
* @brief Reload plugin given by id
|
||||
*
|
||||
* @param id This is the unique identifier of the plugin, the name of the directory it is in
|
||||
*/
|
||||
bool reload(const QString &id);
|
||||
|
||||
/**
|
||||
* @brief Checks settings to tell if a plugin named by id is enabled.
|
||||
*
|
||||
* It is the callers responsibility to check Settings::pluginsEnabled
|
||||
*/
|
||||
static bool isPluginEnabled(const QString &id);
|
||||
|
||||
private:
|
||||
void loadPlugins();
|
||||
void load(const QFileInfo &index, const QDir &pluginDir,
|
||||
const PluginMeta &meta);
|
||||
|
||||
// This function adds lua standard libraries into the state
|
||||
static void openLibrariesFor(lua_State *L, const PluginMeta & /*meta*/);
|
||||
static void loadChatterinoLib(lua_State *l);
|
||||
bool tryLoadFromDir(const QDir &pluginDir);
|
||||
std::map<QString, std::unique_ptr<Plugin>> plugins_;
|
||||
};
|
||||
|
||||
}; // namespace chatterino
|
||||
#endif
|
||||
@@ -141,6 +141,7 @@ void Paths::initSubDirectories()
|
||||
this->messageLogDirectory = makePath("Logs");
|
||||
this->miscDirectory = makePath("Misc");
|
||||
this->twitchProfileAvatars = makePath("ProfileAvatars");
|
||||
this->pluginsDirectory = makePath("Plugins");
|
||||
this->crashdumpDirectory = makePath("Crashes");
|
||||
//QDir().mkdir(this->twitchProfileAvatars + "/twitch");
|
||||
}
|
||||
|
||||
@@ -34,6 +34,9 @@ public:
|
||||
// Profile avatars for Twitch <appDataDirectory>/cache/twitch
|
||||
QString twitchProfileAvatars;
|
||||
|
||||
// Plugin files live here. <appDataDirectory>/Plugins
|
||||
QString pluginsDirectory;
|
||||
|
||||
bool createFolder(const QString &folderPath);
|
||||
bool isPortable();
|
||||
|
||||
|
||||
@@ -528,6 +528,10 @@ public:
|
||||
{"d", 1},
|
||||
{"w", 1}}};
|
||||
|
||||
BoolSetting pluginsEnabled = {"/plugins/supportEnabled", false};
|
||||
ChatterinoSetting<std::vector<QString>> enabledPlugins = {
|
||||
"/plugins/enabledPlugins", {}};
|
||||
|
||||
private:
|
||||
void updateModerationActions();
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
#include "widgets/settingspages/ModerationPage.hpp"
|
||||
#include "widgets/settingspages/NicknamesPage.hpp"
|
||||
#include "widgets/settingspages/NotificationPage.hpp"
|
||||
#include "widgets/settingspages/PluginsPage.hpp"
|
||||
|
||||
#include <QDialogButtonBox>
|
||||
#include <QFile>
|
||||
@@ -216,6 +217,9 @@ void SettingsDialog::addTabs()
|
||||
this->addTab([]{return new ModerationPage;}, "Moderation", ":/settings/moderation.svg", SettingsTabId::Moderation);
|
||||
this->addTab([]{return new NotificationPage;}, "Live Notifications", ":/settings/notification2.svg");
|
||||
this->addTab([]{return new ExternalToolsPage;}, "External tools", ":/settings/externaltools.svg");
|
||||
#ifdef CHATTERINO_HAVE_PLUGINS
|
||||
this->addTab([]{return new PluginsPage;}, "Plugins", ":/settings/plugins.svg");
|
||||
#endif
|
||||
this->ui_.tabContainer->addStretch(1);
|
||||
this->addTab([]{return new AboutPage;}, "About", ":/settings/about.svg", SettingsTabId(), Qt::AlignBottom);
|
||||
// clang-format on
|
||||
|
||||
@@ -114,6 +114,13 @@ AboutPage::AboutPage()
|
||||
addLicense(form.getElement(), "miniaudio",
|
||||
"https://github.com/mackron/miniaudio",
|
||||
":/licenses/miniaudio.txt");
|
||||
#ifdef CHATTERINO_HAVE_PLUGINS
|
||||
addLicense(form.getElement(), "lua", "https://lua.org",
|
||||
":/licenses/lua.txt");
|
||||
addLicense(form.getElement(), "Fluent icons",
|
||||
"https://github.com/microsoft/fluentui-system-icons",
|
||||
":/licenses/fluenticons.txt");
|
||||
#endif
|
||||
#ifdef CHATTERINO_WITH_CRASHPAD
|
||||
addLicense(form.getElement(), "sentry-crashpad",
|
||||
"https://github.com/getsentry/crashpad",
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
#ifdef CHATTERINO_HAVE_PLUGINS
|
||||
# include "widgets/settingspages/PluginsPage.hpp"
|
||||
|
||||
# include "Application.hpp"
|
||||
# include "controllers/plugins/PluginController.hpp"
|
||||
# include "singletons/Paths.hpp"
|
||||
# include "singletons/Settings.hpp"
|
||||
# include "util/Helpers.hpp"
|
||||
# include "util/LayoutCreator.hpp"
|
||||
# include "util/RemoveScrollAreaBackground.hpp"
|
||||
|
||||
# include <QCheckBox>
|
||||
# include <QFormLayout>
|
||||
# include <QGroupBox>
|
||||
# include <QLabel>
|
||||
# include <QObject>
|
||||
# include <QPushButton>
|
||||
# include <QWidget>
|
||||
|
||||
namespace chatterino {
|
||||
|
||||
PluginsPage::PluginsPage()
|
||||
: scrollAreaWidget_(nullptr)
|
||||
, dataFrame_(nullptr)
|
||||
{
|
||||
LayoutCreator<PluginsPage> layoutCreator(this);
|
||||
auto scrollArea = layoutCreator.emplace<QScrollArea>();
|
||||
|
||||
auto widget = scrollArea.emplaceScrollAreaWidget();
|
||||
this->scrollAreaWidget_ = widget;
|
||||
removeScrollAreaBackground(scrollArea.getElement(), widget.getElement());
|
||||
|
||||
auto layout = widget.setLayoutType<QVBoxLayout>();
|
||||
|
||||
{
|
||||
auto group = layout.emplace<QGroupBox>("General plugin settings");
|
||||
this->generalGroup = group.getElement();
|
||||
auto groupLayout = group.setLayoutType<QFormLayout>();
|
||||
auto *description = new QLabel(
|
||||
"You can load plugins by putting them into " +
|
||||
formatRichNamedLink("file:///" + getPaths()->pluginsDirectory,
|
||||
"the Plugins directory") +
|
||||
". Each one is a new directory.");
|
||||
description->setOpenExternalLinks(true);
|
||||
description->setWordWrap(true);
|
||||
description->setStyleSheet("color: #bbb");
|
||||
groupLayout->addRow(description);
|
||||
|
||||
auto *box = this->createCheckBox("Enable plugins",
|
||||
getSettings()->pluginsEnabled);
|
||||
QObject::connect(box, &QCheckBox::released, [this]() {
|
||||
this->rebuildContent();
|
||||
});
|
||||
groupLayout->addRow(box);
|
||||
}
|
||||
|
||||
this->rebuildContent();
|
||||
}
|
||||
|
||||
void PluginsPage::rebuildContent()
|
||||
{
|
||||
if (this->dataFrame_ != nullptr)
|
||||
{
|
||||
this->dataFrame_->deleteLater();
|
||||
this->dataFrame_ = nullptr;
|
||||
}
|
||||
auto frame = LayoutCreator<QFrame>(new QFrame(this));
|
||||
this->dataFrame_ = frame.getElement();
|
||||
this->scrollAreaWidget_.append(this->dataFrame_);
|
||||
auto layout = frame.setLayoutType<QVBoxLayout>();
|
||||
layout->setParent(this->dataFrame_);
|
||||
for (const auto &[id, plugin] : getApp()->plugins->plugins())
|
||||
{
|
||||
auto groupHeaderText =
|
||||
QString("%1 (%2, from %3)")
|
||||
.arg(plugin->meta.name,
|
||||
QString::fromStdString(plugin->meta.version.to_string()),
|
||||
id);
|
||||
auto groupBox = layout.emplace<QGroupBox>(groupHeaderText);
|
||||
groupBox->setParent(this->dataFrame_);
|
||||
auto pluginEntry = groupBox.setLayoutType<QFormLayout>();
|
||||
pluginEntry->setParent(groupBox.getElement());
|
||||
|
||||
if (!plugin->meta.isValid())
|
||||
{
|
||||
QString errors = "<ul>";
|
||||
for (const auto &err : plugin->meta.errors)
|
||||
{
|
||||
errors += "<li>" + err.toHtmlEscaped() + "</li>";
|
||||
}
|
||||
errors += "</ul>";
|
||||
|
||||
auto *warningLabel = new QLabel(
|
||||
"There were errors while loading metadata for this plugin:" +
|
||||
errors,
|
||||
this->dataFrame_);
|
||||
warningLabel->setTextFormat(Qt::RichText);
|
||||
warningLabel->setStyleSheet("color: #f00");
|
||||
pluginEntry->addRow(warningLabel);
|
||||
}
|
||||
|
||||
auto *description =
|
||||
new QLabel(plugin->meta.description, this->dataFrame_);
|
||||
description->setWordWrap(true);
|
||||
description->setStyleSheet("color: #bbb");
|
||||
pluginEntry->addRow(description);
|
||||
|
||||
QString authorsTxt;
|
||||
for (const auto &author : plugin->meta.authors)
|
||||
{
|
||||
if (!authorsTxt.isEmpty())
|
||||
{
|
||||
authorsTxt += ", ";
|
||||
}
|
||||
|
||||
authorsTxt += author;
|
||||
}
|
||||
pluginEntry->addRow("Authors",
|
||||
new QLabel(authorsTxt, this->dataFrame_));
|
||||
|
||||
if (!plugin->meta.homepage.isEmpty())
|
||||
{
|
||||
auto *homepage = new QLabel(formatRichLink(plugin->meta.homepage),
|
||||
this->dataFrame_);
|
||||
homepage->setOpenExternalLinks(true);
|
||||
pluginEntry->addRow("Homepage", homepage);
|
||||
}
|
||||
pluginEntry->addRow("License",
|
||||
new QLabel(plugin->meta.license, this->dataFrame_));
|
||||
|
||||
QString commandsTxt;
|
||||
for (const auto &cmdName : plugin->listRegisteredCommands())
|
||||
{
|
||||
if (!commandsTxt.isEmpty())
|
||||
{
|
||||
commandsTxt += ", ";
|
||||
}
|
||||
|
||||
commandsTxt += cmdName;
|
||||
}
|
||||
pluginEntry->addRow("Commands",
|
||||
new QLabel(commandsTxt, this->dataFrame_));
|
||||
|
||||
if (plugin->meta.isValid())
|
||||
{
|
||||
QString toggleTxt = "Enable";
|
||||
if (PluginController::isPluginEnabled(id))
|
||||
{
|
||||
toggleTxt = "Disable";
|
||||
}
|
||||
|
||||
auto *toggleButton = new QPushButton(toggleTxt, this->dataFrame_);
|
||||
QObject::connect(
|
||||
toggleButton, &QPushButton::pressed, [name = id, this]() {
|
||||
std::vector<QString> val =
|
||||
getSettings()->enabledPlugins.getValue();
|
||||
if (PluginController::isPluginEnabled(name))
|
||||
{
|
||||
val.erase(std::remove(val.begin(), val.end(), name),
|
||||
val.end());
|
||||
}
|
||||
else
|
||||
{
|
||||
val.push_back(name);
|
||||
}
|
||||
getSettings()->enabledPlugins.setValue(val);
|
||||
getApp()->plugins->reload(name);
|
||||
this->rebuildContent();
|
||||
});
|
||||
pluginEntry->addRow(toggleButton);
|
||||
}
|
||||
|
||||
auto *reloadButton = new QPushButton("Reload", this->dataFrame_);
|
||||
QObject::connect(reloadButton, &QPushButton::pressed,
|
||||
[name = id, this]() {
|
||||
getApp()->plugins->reload(name);
|
||||
this->rebuildContent();
|
||||
});
|
||||
pluginEntry->addRow(reloadButton);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace chatterino
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef CHATTERINO_HAVE_PLUGINS
|
||||
# include "util/LayoutCreator.hpp"
|
||||
# include "widgets/settingspages/SettingsPage.hpp"
|
||||
|
||||
# include <QDebug>
|
||||
# include <QFormLayout>
|
||||
# include <QGroupBox>
|
||||
# include <QWidget>
|
||||
|
||||
namespace chatterino {
|
||||
class Plugin;
|
||||
|
||||
class PluginsPage : public SettingsPage
|
||||
{
|
||||
public:
|
||||
PluginsPage();
|
||||
|
||||
private:
|
||||
void rebuildContent();
|
||||
|
||||
LayoutCreator<QWidget> scrollAreaWidget_;
|
||||
QGroupBox *generalGroup;
|
||||
QFrame *dataFrame_;
|
||||
};
|
||||
|
||||
} // namespace chatterino
|
||||
|
||||
#endif
|
||||
Reference in New Issue
Block a user