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
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 2087 additions and 3 deletions

View file

@ -53,3 +53,7 @@ CheckOptions:
value: camelBack
- key: readability-implicit-bool-conversion.AllowPointerConditions
value: true
# Lua state
- key: readability-identifier-naming.LocalPointerIgnoredRegexp
value: ^L$

View file

@ -27,6 +27,7 @@ jobs:
qt-version: [5.15.2, 5.12.12]
pch: [true]
force-lto: [false]
plugins: [false]
skip_artifact: ["no"]
crashpad: [true]
include:
@ -45,12 +46,13 @@ jobs:
qt-version: 6.2.4
pch: false
force-lto: false
# Test for disabling Precompiled Headers & enabling link-time optimization
# Test for disabling Precompiled Headers & enabling link-time optimization and plugins
- os: ubuntu-22.04
qt-version: 5.15.2
pch: false
force-lto: true
skip_artifact: "yes"
plugins: true
# Test for disabling crashpad on Windows
- os: windows-latest
qt-version: 5.15.2
@ -58,6 +60,7 @@ jobs:
force-lto: true
skip_artifact: "yes"
crashpad: false
fail-fast: false
steps:
@ -67,6 +70,12 @@ jobs:
echo "C2_ENABLE_LTO=ON" >> "$GITHUB_ENV"
shell: bash
- name: Enable plugin support
if: matrix.plugins == true
run: |
echo "C2_PLUGINS=ON" >> "$GITHUB_ENV"
shell: bash
- name: Set Crashpad
if: matrix.crashpad == true
run: |
@ -160,6 +169,7 @@ jobs:
-DUSE_PRECOMPILED_HEADERS=${{ matrix.pch }} `
-DBUILD_WITH_CRASHPAD="$Env:C2_ENABLE_CRASHPAD" `
-DCHATTERINO_LTO="$Env:C2_ENABLE_LTO" `
-DCHATTERINO_PLUGINS="$Env:C2_PLUGINS" `
-DBUILD_WITH_QT6="$Env:C2_BUILD_WITH_QT6" `
..
set cl=/MP
@ -246,6 +256,7 @@ jobs:
-DUSE_PRECOMPILED_HEADERS=${{ matrix.pch }} \
-DCMAKE_EXPORT_COMPILE_COMMANDS=On \
-DCHATTERINO_LTO="$C2_ENABLE_LTO" \
-DCHATTERINO_PLUGINS="$C2_PLUGINS" \
-DBUILD_WITH_QT6="$C2_BUILD_WITH_QT6" \
..
make -j"$(nproc)"
@ -310,6 +321,7 @@ jobs:
-DOPENSSL_ROOT_DIR=/usr/local/opt/openssl \
-DUSE_PRECOMPILED_HEADERS=${{ matrix.pch }} \
-DCHATTERINO_LTO="$C2_ENABLE_LTO" \
-DCHATTERINO_PLUGINS="$C2_PLUGINS" \
-DBUILD_WITH_QT6="$C2_BUILD_WITH_QT6" \
..
make -j"$(sysctl -n hw.logicalcpu)"
@ -331,7 +343,6 @@ jobs:
with:
name: chatterino-osx-${{ matrix.qt-version }}.dmg
path: build/chatterino-osx.dmg
create-release:
needs: build
runs-on: ubuntu-latest

3
.gitmodules vendored
View file

@ -35,6 +35,9 @@
[submodule "lib/miniaudio"]
path = lib/miniaudio
url = https://github.com/mackron/miniaudio.git
[submodule "lib/lua/src"]
path = lib/lua/src
url = https://github.com/lua/lua
[submodule "lib/crashpad"]
path = lib/crashpad
url = https://github.com/getsentry/crashpad

View file

@ -22,6 +22,7 @@
- Dev: Only log debug messages when NDEBUG is not defined. (#4442)
- Dev: Cleaned up theme related code. (#4450)
- Dev: Ensure tests have default-initialized settings. (#4498)
- Dev: Add scripting capabilities with Lua (#4341)
- Dev: Conan 2.0 is now used instead of Conan 1.0. (#4417)
## 2.4.2

View file

@ -25,6 +25,7 @@ option(CHATTERINO_GENERATE_COVERAGE "Generate coverage files" OFF)
option(BUILD_TRANSLATIONS "" OFF)
option(BUILD_SHARED_LIBS "" OFF)
option(CHATTERINO_LTO "Enable LTO for all targets" OFF)
option(CHATTERINO_PLUGINS "Enable EXPERIMENTAL plugin support in Chatterino" OFF)
if(CHATTERINO_LTO)
include(CheckIPOSupported)
@ -156,6 +157,11 @@ else()
add_subdirectory("${CMAKE_SOURCE_DIR}/lib/settings" EXCLUDE_FROM_ALL)
endif()
if (CHATTERINO_PLUGINS)
set(LUA_INCLUDE_DIRS "${CMAKE_SOURCE_DIR}/lib/lua/src")
add_subdirectory(lib/lua)
endif()
if (BUILD_WITH_CRASHPAD)
add_subdirectory("${CMAKE_SOURCE_DIR}/lib/crashpad" EXCLUDE_FROM_ALL)
endif()

22
docs/chatterino.d.ts vendored Normal file
View file

@ -0,0 +1,22 @@
/** @noSelfInFile */
declare module c2 {
enum LogLevel {
Debug,
Info,
Warning,
Critical,
}
class CommandContext {
words: String[];
channel_name: String;
}
function log(level: LogLevel, ...data: any[]): void;
function register_command(
name: String,
handler: (ctx: CommandContext) => void
): boolean;
function send_msg(channel: String, text: String): boolean;
function system_msg(channel: String, text: String): boolean;
}

View file

@ -0,0 +1,49 @@
{
"$schema": "http://json-schema.org/schema",
"$id": "https://raw.githubusercontent.com/Chatterino/chatterino2/master/docs/plugin-info.schema.json",
"title": "Plugin info",
"description": "Describes a Chatterino2 plugin (draft)",
"type": "object",
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"description": "Plugin name shown to the user."
},
"description": {
"type": "string",
"description": "Plugin description shown to the user."
},
"authors": {
"type": "array",
"description": "An array of authors of this Plugin.",
"items": {
"type": "string"
}
},
"homepage": {
"type": "string",
"description": "Optional URL to your Plugin's homepage. This could be your GitHub repo for example."
},
"tags": {
"description": "Something that could in the future be used to find your plugin.",
"type": "array",
"items": {
"type": "string",
"examples": ["moderation", "utility", "commands"]
},
"uniqueItems": true
},
"version": {
"type": "string",
"description": "Semver version string, for more info see https://semver.org.",
"examples": ["0.0.1", "1.0.0-rc.1"]
},
"license": {
"type": "string",
"description": "A small description of your license.",
"examples": ["MIT", "GPL-2.0-or-later"]
}
},
"required": ["name", "description", "authors", "version", "license"]
}

177
docs/wip-plugins.md Normal file
View file

@ -0,0 +1,177 @@
# Plugins
If Chatterino is compiled with the `CHATTERINO_PLUGINS` CMake option, it can
load and execute Lua files. Note that while there are attempts at making this
decently safe, we cannot guarantee safety.
## Plugin structure
Chatterino searches for plugins in the `Plugins` directory in the app data, right next to `Settings` and `Logs`.
Each plugin should have its own directory.
```
Chatterino Plugins dir/
└── plugin_name/
├── init.lua
└── info.json
```
`init.lua` will be the file loaded when the plugin is enabled. You may load other files using [`import` global function](#importfilename=).
`info.json` contains metadata about the plugin, like its name, description,
authors, homepage link, tags, version, license name. The version field **must**
be [semver 2.0](https://semver.org/) compliant. The general idea of `info.json`
will not change however the exact contents probably will, for example with
permission system ideas.
Example file:
```json
{
"$schema": "https://raw.githubusercontent.com/Chatterino/chatterino2/master/docs/plugin-info.schema.json",
"name": "Test plugin",
"description": "This plugin is for testing stuff.",
"authors": "Mm2PL",
"homepage": "https://github.com/Chatterino/Chatterino2",
"tags": ["test"],
"version": "0.0.0",
"license": "MIT"
}
```
An example plugin is available at [https://github.com/Mm2PL/Chatterino-test-plugin](https://github.com/Mm2PL/Chatterino-test-plugin)
## Plugins with Typescript
If you prefer, you may use [TypescriptToLua](https://typescripttolua.github.io)
to typecheck your plugins. There is a `chatterino.d.ts` file describing the API
in this directory. However this has several drawbacks like harder debugging at
runtime.
## API
The following parts of the Lua standard library are loaded:
- `_G` (most globals)
- `table`
- `string`
- `math`
- `utf8`
The official manual for them is available [here](https://www.lua.org/manual/5.4/manual.html#6).
### Chatterino API
All Chatterino functions are exposed in a global table called `c2`. The following members are available:
#### `log(level, args...)`
Writes a message to the Chatterino log. The `level` argument should be a
`LogLevel` member. All `args` should be convertible to a string with
`tostring()`.
Example:
```lua
c2.log(c2.LogLevel.Warning, "Hello, this should show up in the Chatterino log by default")
c2.log(c2.LogLevel.Debug, "Hello world")
-- Equivalent to doing qCDebug(chatterinoLua) << "[pluginDirectory:Plugin Name]" << "Hello, world"; from C++
```
#### `LogLevel` enum
This table describes log levels available to Lua Plugins. The values behind the names may change, do not count on them. It has the following keys:
- `Debug`
- `Info`
- `Warning`
- `Critical`
#### `register_command(name, handler)`
Registers a new command called `name` which when executed will call `handler`.
Returns `true` if everything went ok, `false` if there already exists another
command with this name.
Example:
```lua
function cmdWords(ctx)
-- ctx contains:
-- words - table of words supplied to the command including the trigger
-- channelName - name of the channel the command is being run in
c2.system_msg(ctx.channelName, "Words are: " .. table.concat(ctx.words, " "))
end
c2.register_command("/words", cmdWords)
```
Limitations/known issues:
- Commands registered in functions, not in the global scope might not show up in the settings UI,
rebuilding the window content caused by reloading another plugin will solve this.
- Spaces in command names aren't handled very well (https://github.com/Chatterino/chatterino2/issues/1517).
#### `send_msg(channel, text)`
Sends a message to `channel` with the specified text. Also executes commands.
Example:
```lua
function cmdShout(ctx)
table.remove(ctx.words, 1)
local output = table.concat(ctx.words, " ")
c2.send_msg(ctx.channelName, string.upper(output))
end
c2.register_command("/shout", cmdShout)
```
Limitations/Known issues:
- It is possible to trigger your own Lua command with this causing a potentially infinite loop.
#### `system_msg(channel, text)`
Creates a system message and adds it to the twitch channel specified by
`channel`. Returns `true` if everything went ok, `false` otherwise. It will
throw an error if the number of arguments received doesn't match what it
expects.
Example:
```lua
local ok = c2.system_msg("pajlada", "test")
if (not ok)
-- channel not found
end
```
### Changed globals
#### `load(chunk [, chunkname [, mode [, env]]])`
This function is only available if Chatterino is compiled in debug mode. It is meant for debugging with little exception.
This function behaves really similarity to Lua's `load`, however it does not allow for bytecode to be executed.
It achieves this by forcing all inputs to be encoded with `UTF-8`.
See [official documentation](https://www.lua.org/manual/5.4/manual.html#pdf-load)
#### `import(filename)`
This function mimics Lua's `dofile` however relative paths are relative to your plugin's directory.
You are restricted to loading files in your plugin's directory. You cannot load files with bytecode inside.
Example:
```lua
import("stuff.lua") -- executes Plugins/name/stuff.lua
import("./stuff.lua") -- executes Plugins/name/stuff.lua
import("../stuff.lua") -- tries to load Plugins/stuff.lua and errors
import("luac.out") -- tried to load Plugins/name/luac.out and errors because it contains non-utf8 data
```
#### `print(Args...)`
The `print` global function is equivalent to calling `c2.log(c2.LogLevel.Debug, Args...)`

53
lib/lua/CMakeLists.txt Normal file
View file

@ -0,0 +1,53 @@
project(lua CXX)
#[====[
Updating this list:
remove all listed files
go to line below, ^y2j4j$@" and then reindent the file names
/LUA_SRC
:r!ls lib/lua/src | grep '\.c' | grep -Ev 'lua\.c|onelua\.c' | sed 's#^#src/#'
#]====]
set(LUA_SRC
"src/lapi.c"
"src/lauxlib.c"
"src/lbaselib.c"
"src/lcode.c"
"src/lcorolib.c"
"src/lctype.c"
"src/ldblib.c"
"src/ldebug.c"
"src/ldo.c"
"src/ldump.c"
"src/lfunc.c"
"src/lgc.c"
"src/linit.c"
"src/liolib.c"
"src/llex.c"
"src/lmathlib.c"
"src/lmem.c"
"src/loadlib.c"
"src/lobject.c"
"src/lopcodes.c"
"src/loslib.c"
"src/lparser.c"
"src/lstate.c"
"src/lstring.c"
"src/lstrlib.c"
"src/ltable.c"
"src/ltablib.c"
"src/ltests.c"
"src/ltm.c"
"src/lua.c"
"src/lundump.c"
"src/lutf8lib.c"
"src/lvm.c"
"src/lzio.c"
)
add_library(lua STATIC ${LUA_SRC})
target_include_directories(lua
PUBLIC
${LUA_INCLUDE_DIRS}
)
set_source_files_properties(${LUA_SRC} PROPERTIES LANGUAGE CXX)

1
lib/lua/src Submodule

@ -0,0 +1 @@
Subproject commit 5d708c3f9cae12820e415d4f89c9eacbe2ab964b

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 Microsoft Corporation
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,7 @@
Copyright © 19942021 Lua.org, PUC-Rio.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View file

@ -0,0 +1,13 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<!-- Created using Krita: https://krita.org -->
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:krita="http://krita.org/namespaces/svg/krita"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
width="1.728pt"
height="1.728pt"
viewBox="0 0 1.728 1.728">
<defs/>
<path id="shape0" transform="matrix(0.072 0 0 0.072 0.143115521579684 0.144884750976744)" fill="#ffffff" stroke="#000000" stroke-width="0.72" stroke-linecap="square" stroke-linejoin="bevel" d="M19.7193 1.69471C19.9014 1.50618 20.0022 1.25341 20 0.991311C19.9977 0.72921 19.8924 0.478233 19.7071 0.292893C19.5218 0.107553 19.2708 0.00231534 19.0087 3.77513e-05C18.7466 -0.00223983 18.4938 0.0986205 18.3053 0.280712L16.5083 2.07771C15.6935 1.57117 14.7307 1.35503 13.7775 1.46467C12.8244 1.57431 11.9358 2.00339 11.2573 2.68171L10.1893 3.75071C9.86149 4.07874 9.67716 4.52397 9.67716 4.98771C9.67716 5.45145 9.86149 5.89668 10.1893 6.22471L13.7743 9.81071C13.9915 10.0279 14.2621 10.1842 14.5588 10.2637C14.8555 10.3432 15.168 10.3432 15.4647 10.2637C15.7615 10.1842 16.0321 10.0279 16.2493 9.81071L17.3173 8.74271C17.9955 8.06436 18.4246 7.17606 18.5345 6.22312C18.6443 5.27017 18.4284 4.30755 17.9223 3.49271ZM8.71928 9.69471C8.90138 9.50618 9.00224 9.25341 8.99996 8.99131C8.99768 8.72921 8.89244 8.47823 8.7071 8.29289C8.52176 8.10755 8.27079 8.00232 8.00869 8.00004C7.74658 7.99776 7.49382 8.09862 7.30528 8.28071L5.83528 9.75071L5.54228 9.45771C5.40171 9.31732 5.21096 9.23837 5.01228 9.23837C4.81361 9.23837 4.62286 9.31732 4.48228 9.45771L2.70728 11.2327C2.02905 11.9111 1.59993 12.7994 1.49011 13.7523C1.38029 14.7052 1.59612 15.6679 2.10228 16.4827L0.305284 18.2807C0.151702 18.429 0.0495919 18.6227 0.0139927 18.8333C-0.0216064 19.0438 0.0111426 19.2603 0.107417 19.4509C0.203692 19.6415 0.358521 19.7963 0.549105 19.8926C0.739689 19.9889 0.956189 20.0216 1.16672 19.986C1.37725 19.9504 1.57095 19.8483 1.71928 19.6947L3.51728 17.8977C4.33212 18.4039 5.29475 18.6197 6.24769 18.5099C7.20063 18.4001 8.08894 17.9709 8.76728 17.2927L10.5423 15.5177C10.6827 15.3771 10.7616 15.1864 10.7616 14.9877C10.7616 14.789 10.6827 14.5983 10.5423 14.4577L10.2493 14.1647L11.7193 12.6947C11.9014 12.5062 12.0022 12.2534 12 11.9913C11.9977 11.7292 11.8924 11.4782 11.7071 11.2929C11.5218 11.1076 11.2708 11.0023 11.0087 11C10.7466 10.9978 10.4938 11.0986 10.3053 11.2807L8.83528 12.7507L7.24928 11.1647Z" sodipodi:nodetypes="cccccccccccccccccccccccccccccccccccccccccccccccccccc"/>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View file

@ -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;

View file

@ -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

View file

@ -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}

View file

@ -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)
{

View file

@ -34,6 +34,9 @@ class CompletionModel : public QAbstractListModel
CustomCommand,
ChatterinoCommand,
TwitchCommand,
#ifdef CHATTERINO_HAVE_PLUGINS
PluginCommand,
#endif
};
TaggedString(QString _string, Type type);

View file

@ -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",

View file

@ -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);

View file

@ -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)
{

View file

@ -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

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

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

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

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

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

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

View file

@ -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

View file

@ -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

View file

@ -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");
}

View file

@ -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();

View file

@ -528,6 +528,10 @@ public:
{"d", 1},
{"w", 1}}};
BoolSetting pluginsEnabled = {"/plugins/supportEnabled", false};
ChatterinoSetting<std::vector<QString>> enabledPlugins = {
"/plugins/enabledPlugins", {}};
private:
void updateModerationActions();
};

View file

@ -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

View file

@ -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",

View file

@ -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

View file

@ -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