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:
Mm2PL
2023-04-02 15:31:53 +02:00
committed by GitHub
parent 5836073d52
commit 5ba809804e
37 changed files with 2087 additions and 3 deletions
+338
View File
@@ -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
+28
View File
@@ -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
+196
View File
@@ -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
+191
View File
@@ -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
+177
View File
@@ -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
+98
View File
@@ -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 &reg : 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