From 5ba809804e610f2e62db4cf6c7a98655f4a15c6c Mon Sep 17 00:00:00 2001 From: Mm2PL Date: Sun, 2 Apr 2023 15:31:53 +0200 Subject: [PATCH] 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 Co-authored-by: pajlada --- .clang-tidy | 4 + .github/workflows/build.yml | 15 +- .gitmodules | 3 + CHANGELOG.md | 1 + CMakeLists.txt | 6 + docs/chatterino.d.ts | 22 ++ docs/plugin-info.schema.json | 49 +++ docs/wip-plugins.md | 177 +++++++++ lib/lua/CMakeLists.txt | 53 +++ lib/lua/src | 1 + resources/licenses/fluenticons.txt | 21 ++ resources/licenses/lua.txt | 7 + resources/settings/plugins.svg | 13 + src/Application.cpp | 6 + src/Application.hpp | 7 + src/CMakeLists.txt | 22 ++ src/common/CompletionModel.cpp | 7 +- src/common/CompletionModel.hpp | 3 + src/common/QLogging.cpp | 1 + src/common/QLogging.hpp | 1 + .../commands/CommandController.cpp | 27 ++ .../commands/CommandController.hpp | 12 + src/controllers/plugins/LuaAPI.cpp | 338 ++++++++++++++++++ src/controllers/plugins/LuaAPI.hpp | 28 ++ src/controllers/plugins/LuaUtilities.cpp | 196 ++++++++++ src/controllers/plugins/LuaUtilities.hpp | 191 ++++++++++ src/controllers/plugins/Plugin.cpp | 177 +++++++++ src/controllers/plugins/Plugin.hpp | 98 +++++ src/controllers/plugins/PluginController.cpp | 302 ++++++++++++++++ src/controllers/plugins/PluginController.hpp | 68 ++++ src/singletons/Paths.cpp | 1 + src/singletons/Paths.hpp | 3 + src/singletons/Settings.hpp | 4 + src/widgets/dialogs/SettingsDialog.cpp | 4 + src/widgets/settingspages/AboutPage.cpp | 7 + src/widgets/settingspages/PluginsPage.cpp | 185 ++++++++++ src/widgets/settingspages/PluginsPage.hpp | 30 ++ 37 files changed, 2087 insertions(+), 3 deletions(-) create mode 100644 docs/chatterino.d.ts create mode 100644 docs/plugin-info.schema.json create mode 100644 docs/wip-plugins.md create mode 100644 lib/lua/CMakeLists.txt create mode 160000 lib/lua/src create mode 100644 resources/licenses/fluenticons.txt create mode 100644 resources/licenses/lua.txt create mode 100644 resources/settings/plugins.svg create mode 100644 src/controllers/plugins/LuaAPI.cpp create mode 100644 src/controllers/plugins/LuaAPI.hpp create mode 100644 src/controllers/plugins/LuaUtilities.cpp create mode 100644 src/controllers/plugins/LuaUtilities.hpp create mode 100644 src/controllers/plugins/Plugin.cpp create mode 100644 src/controllers/plugins/Plugin.hpp create mode 100644 src/controllers/plugins/PluginController.cpp create mode 100644 src/controllers/plugins/PluginController.hpp create mode 100644 src/widgets/settingspages/PluginsPage.cpp create mode 100644 src/widgets/settingspages/PluginsPage.hpp diff --git a/.clang-tidy b/.clang-tidy index b701ee666..d75c5dce2 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -53,3 +53,7 @@ CheckOptions: value: camelBack - key: readability-implicit-bool-conversion.AllowPointerConditions value: true + + # Lua state + - key: readability-identifier-naming.LocalPointerIgnoredRegexp + value: ^L$ diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cbb2d3045..01a9d68d9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/.gitmodules b/.gitmodules index 741e31041..571cc0f44 100644 --- a/.gitmodules +++ b/.gitmodules @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index f3c0478bd..7dcd34cde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CMakeLists.txt b/CMakeLists.txt index cdaa5719f..bc3844e60 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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() diff --git a/docs/chatterino.d.ts b/docs/chatterino.d.ts new file mode 100644 index 000000000..c2efdb1ba --- /dev/null +++ b/docs/chatterino.d.ts @@ -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; +} diff --git a/docs/plugin-info.schema.json b/docs/plugin-info.schema.json new file mode 100644 index 000000000..da4750c1a --- /dev/null +++ b/docs/plugin-info.schema.json @@ -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"] +} diff --git a/docs/wip-plugins.md b/docs/wip-plugins.md new file mode 100644 index 000000000..d27f0ceda --- /dev/null +++ b/docs/wip-plugins.md @@ -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...)` diff --git a/lib/lua/CMakeLists.txt b/lib/lua/CMakeLists.txt new file mode 100644 index 000000000..086f59495 --- /dev/null +++ b/lib/lua/CMakeLists.txt @@ -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) diff --git a/lib/lua/src b/lib/lua/src new file mode 160000 index 000000000..5d708c3f9 --- /dev/null +++ b/lib/lua/src @@ -0,0 +1 @@ +Subproject commit 5d708c3f9cae12820e415d4f89c9eacbe2ab964b diff --git a/resources/licenses/fluenticons.txt b/resources/licenses/fluenticons.txt new file mode 100644 index 000000000..bc9c36b28 --- /dev/null +++ b/resources/licenses/fluenticons.txt @@ -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. diff --git a/resources/licenses/lua.txt b/resources/licenses/lua.txt new file mode 100644 index 000000000..b6ed3539e --- /dev/null +++ b/resources/licenses/lua.txt @@ -0,0 +1,7 @@ +Copyright © 1994–2021 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. diff --git a/resources/settings/plugins.svg b/resources/settings/plugins.svg new file mode 100644 index 000000000..e4314ddf2 --- /dev/null +++ b/resources/settings/plugins.svg @@ -0,0 +1,13 @@ + + + + + + + diff --git a/src/Application.cpp b/src/Application.cpp index 7804169ce..b794a7966 100644 --- a/src/Application.cpp +++ b/src/Application.cpp @@ -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()) , userData(&this->emplace()) , sound(&this->emplace()) +#ifdef CHATTERINO_HAVE_PLUGINS + , plugins(&this->emplace()) +#endif , logging(&this->emplace()) { this->instance = this; diff --git a/src/Application.hpp b/src/Application.hpp index dada8d02a..7c5525505 100644 --- a/src/Application.hpp +++ b/src/Application.hpp @@ -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 diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 98368033c..e5bb47413 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -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} diff --git a/src/common/CompletionModel.cpp b/src/common/CompletionModel.cpp index 08bddf2e8..9b123aa4c 100644 --- a/src/common/CompletionModel.cpp +++ b/src/common/CompletionModel.cpp @@ -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) { diff --git a/src/common/CompletionModel.hpp b/src/common/CompletionModel.hpp index c2670c08e..5b46fb2de 100644 --- a/src/common/CompletionModel.hpp +++ b/src/common/CompletionModel.hpp @@ -34,6 +34,9 @@ class CompletionModel : public QAbstractListModel CustomCommand, ChatterinoCommand, TwitchCommand, +#ifdef CHATTERINO_HAVE_PLUGINS + PluginCommand, +#endif }; TaggedString(QString _string, Type type); diff --git a/src/common/QLogging.cpp b/src/common/QLogging.cpp index de902b18a..fb9afa263 100644 --- a/src/common/QLogging.cpp +++ b/src/common/QLogging.cpp @@ -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", diff --git a/src/common/QLogging.hpp b/src/common/QLogging.hpp index 8c8f0d6c4..d3585f18c 100644 --- a/src/common/QLogging.hpp +++ b/src/common/QLogging.hpp @@ -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); diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index a77dc722f..ae1ce9093 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -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) { diff --git a/src/controllers/commands/CommandController.hpp b/src/controllers/commands/CommandController.hpp index 3816fa71d..b7135279c 100644 --- a/src/controllers/commands/CommandController.hpp +++ b/src/controllers/commands/CommandController.hpp @@ -42,6 +42,15 @@ public: const QStringList &words, const Command &command, bool dryRun, ChannelPtr channel, const Message *message = nullptr, std::unordered_map 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 diff --git a/src/controllers/plugins/LuaAPI.cpp b/src/controllers/plugins/LuaAPI.cpp new file mode 100644 index 000000000..ff57d7e28 --- /dev/null +++ b/src/controllers/plugins/LuaAPI.cpp @@ -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 +# include +# include +# include +# include +# include + +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 diff --git a/src/controllers/plugins/LuaAPI.hpp b/src/controllers/plugins/LuaAPI.hpp new file mode 100644 index 000000000..dfa95447e --- /dev/null +++ b/src/controllers/plugins/LuaAPI.hpp @@ -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 diff --git a/src/controllers/plugins/LuaUtilities.cpp b/src/controllers/plugins/LuaUtilities.cpp new file mode 100644 index 000000000..3477e4e38 --- /dev/null +++ b/src/controllers/plugins/LuaUtilities.cpp @@ -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 +# include + +# include +# include + +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 diff --git a/src/controllers/plugins/LuaUtilities.hpp b/src/controllers/plugins/LuaUtilities.hpp new file mode 100644 index 000000000..6a75b774a --- /dev/null +++ b/src/controllers/plugins/LuaUtilities.hpp @@ -0,0 +1,191 @@ +#pragma once + +#ifdef CHATTERINO_HAVE_PLUGINS + +# include +# include +# include +# include + +# include +# include +# include +# include +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 , 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 opt = magic_enum::enum_cast(tmp); + if (opt.has_value()) + { + *out = opt.value(); + return true; + } + + return false; +} + +/** + * @brief Converts a vector to Lua and pushes it onto the stack. + * + * Needs StackIdx push(lua_State*, T); to work. + * + * @return Stack index of newly created table. + */ +template +StackIdx push(lua_State *L, std::vector 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 to Lua and pushes it onto the stack. + * + * Needs StackIdx push(lua_State*, T); to work. + * + * @return Stack index of newly created table. + */ +template +StackIdx push(lua_State *L, QList 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 >> +StackIdx push(lua_State *L, T inp) +{ + std::string_view name = magic_enum::enum_name(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 +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 +StackIdx pushEnumTable(lua_State *L) +{ + // std::array + auto values = magic_enum::enum_values(); + StackIdx out = lua::pushEmptyTable(L, values.size()); + for (const T v : values) + { + std::string_view name = magic_enum::enum_name(v); + std::string str(name); + + lua::push(L, str); + lua_setfield(L, out, str.c_str()); + } + return out; +} + +} // namespace chatterino::lua + +#endif diff --git a/src/controllers/plugins/Plugin.cpp b/src/controllers/plugins/Plugin.cpp new file mode 100644 index 000000000..3fdc8e4dc --- /dev/null +++ b/src/controllers/plugins/Plugin.cpp @@ -0,0 +1,177 @@ +#ifdef CHATTERINO_HAVE_PLUGINS +# include "controllers/plugins/Plugin.hpp" + +# include "controllers/commands/CommandController.hpp" + +# include +# include +# include +# include + +# include +# include + +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 Plugin::listRegisteredCommands() +{ + std::unordered_set 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 diff --git a/src/controllers/plugins/Plugin.hpp b/src/controllers/plugins/Plugin.hpp new file mode 100644 index 000000000..456ac4ff1 --- /dev/null +++ b/src/controllers/plugins/Plugin.hpp @@ -0,0 +1,98 @@ +#pragma once + +#ifdef CHATTERINO_HAVE_PLUGINS +# include "Application.hpp" + +# include +# include +# include + +# include +# include +# include + +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 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 tags; + + // errors that occurred while parsing info.json + std::vector 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 listRegisteredCommands(); + + const QDir &loadDirectory() const + { + return this->loadDirectory_; + } + +private: + QDir loadDirectory_; + lua_State *state_; + + // maps command name -> function name + std::unordered_map ownedCommands; + + friend class PluginController; +}; +} // namespace chatterino +#endif diff --git a/src/controllers/plugins/PluginController.cpp b/src/controllers/plugins/PluginController.cpp new file mode 100644 index 000000000..e98a30720 --- /dev/null +++ b/src/controllers/plugins/PluginController.cpp @@ -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 +# include +# include +# include + +# include +# include + +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(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 loadedlibs = { + luaL_Reg{LUA_GNAME, luaopen_base}, + // - load - don't allow in release mode + + //luaL_Reg{LUA_COLIBNAME, luaopen_coroutine}, + // - needs special support + luaL_Reg{LUA_TABLIBNAME, luaopen_table}, + // luaL_Reg{LUA_IOLIBNAME, luaopen_io}, + // - explicit fs access, needs wrapper with permissions, no usage ideas yet + // luaL_Reg{LUA_OSLIBNAME, luaopen_os}, + // - fs access + // - environ access + // - exit + luaL_Reg{LUA_STRLIBNAME, luaopen_string}, + luaL_Reg{LUA_MATHLIBNAME, luaopen_math}, + luaL_Reg{LUA_UTF8LIBNAME, luaopen_utf8}, + }; + // Warning: Do not add debug library to this, it would make the security of + // this a living nightmare due to stuff like registry access + // - Mm2PL + + for (const auto ® : loadedlibs) + { + luaL_requiref(L, reg.name, reg.func, int(true)); + lua_pop(L, 1); + } + + // NOLINTNEXTLINE(*-avoid-c-arrays) + static const luaL_Reg c2Lib[] = { + {"system_msg", lua::api::c2_system_msg}, + {"register_command", lua::api::c2_register_command}, + {"send_msg", lua::api::c2_send_msg}, + {"log", lua::api::c2_log}, + {nullptr, nullptr}, + }; + lua_pushglobaltable(L); + auto global = lua_gettop(L); + + // count of elements in C2LIB + LogLevel + auto c2libIdx = lua::pushEmptyTable(L, 5); + + luaL_setfuncs(L, c2Lib, 0); + + lua::pushEnumTable(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(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> &PluginController::plugins() + const +{ + return this->plugins_; +} + +}; // namespace chatterino +#endif diff --git a/src/controllers/plugins/PluginController.hpp b/src/controllers/plugins/PluginController.hpp new file mode 100644 index 000000000..9630e889b --- /dev/null +++ b/src/controllers/plugins/PluginController.hpp @@ -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 +# include +# include +# include +# include + +# include +# include +# include +# include +# include + +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> &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> plugins_; +}; + +}; // namespace chatterino +#endif diff --git a/src/singletons/Paths.cpp b/src/singletons/Paths.cpp index 8fd6b13cb..79344ac72 100644 --- a/src/singletons/Paths.cpp +++ b/src/singletons/Paths.cpp @@ -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"); } diff --git a/src/singletons/Paths.hpp b/src/singletons/Paths.hpp index 7ff5f8e17..f20195fef 100644 --- a/src/singletons/Paths.hpp +++ b/src/singletons/Paths.hpp @@ -34,6 +34,9 @@ public: // Profile avatars for Twitch /cache/twitch QString twitchProfileAvatars; + // Plugin files live here. /Plugins + QString pluginsDirectory; + bool createFolder(const QString &folderPath); bool isPortable(); diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp index bd1620e5e..467681412 100644 --- a/src/singletons/Settings.hpp +++ b/src/singletons/Settings.hpp @@ -528,6 +528,10 @@ public: {"d", 1}, {"w", 1}}}; + BoolSetting pluginsEnabled = {"/plugins/supportEnabled", false}; + ChatterinoSetting> enabledPlugins = { + "/plugins/enabledPlugins", {}}; + private: void updateModerationActions(); }; diff --git a/src/widgets/dialogs/SettingsDialog.cpp b/src/widgets/dialogs/SettingsDialog.cpp index ebc33774a..5bd218860 100644 --- a/src/widgets/dialogs/SettingsDialog.cpp +++ b/src/widgets/dialogs/SettingsDialog.cpp @@ -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 #include @@ -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 diff --git a/src/widgets/settingspages/AboutPage.cpp b/src/widgets/settingspages/AboutPage.cpp index 712549e1c..05037621e 100644 --- a/src/widgets/settingspages/AboutPage.cpp +++ b/src/widgets/settingspages/AboutPage.cpp @@ -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", diff --git a/src/widgets/settingspages/PluginsPage.cpp b/src/widgets/settingspages/PluginsPage.cpp new file mode 100644 index 000000000..20e65fd3c --- /dev/null +++ b/src/widgets/settingspages/PluginsPage.cpp @@ -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 +# include +# include +# include +# include +# include +# include + +namespace chatterino { + +PluginsPage::PluginsPage() + : scrollAreaWidget_(nullptr) + , dataFrame_(nullptr) +{ + LayoutCreator layoutCreator(this); + auto scrollArea = layoutCreator.emplace(); + + auto widget = scrollArea.emplaceScrollAreaWidget(); + this->scrollAreaWidget_ = widget; + removeScrollAreaBackground(scrollArea.getElement(), widget.getElement()); + + auto layout = widget.setLayoutType(); + + { + auto group = layout.emplace("General plugin settings"); + this->generalGroup = group.getElement(); + auto groupLayout = group.setLayoutType(); + 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(new QFrame(this)); + this->dataFrame_ = frame.getElement(); + this->scrollAreaWidget_.append(this->dataFrame_); + auto layout = frame.setLayoutType(); + 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(groupHeaderText); + groupBox->setParent(this->dataFrame_); + auto pluginEntry = groupBox.setLayoutType(); + pluginEntry->setParent(groupBox.getElement()); + + if (!plugin->meta.isValid()) + { + QString errors = "
    "; + for (const auto &err : plugin->meta.errors) + { + errors += "
  • " + err.toHtmlEscaped() + "
  • "; + } + errors += "
"; + + 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 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 diff --git a/src/widgets/settingspages/PluginsPage.hpp b/src/widgets/settingspages/PluginsPage.hpp new file mode 100644 index 000000000..c27dd0870 --- /dev/null +++ b/src/widgets/settingspages/PluginsPage.hpp @@ -0,0 +1,30 @@ +#pragma once + +#ifdef CHATTERINO_HAVE_PLUGINS +# include "util/LayoutCreator.hpp" +# include "widgets/settingspages/SettingsPage.hpp" + +# include +# include +# include +# include + +namespace chatterino { +class Plugin; + +class PluginsPage : public SettingsPage +{ +public: + PluginsPage(); + +private: + void rebuildContent(); + + LayoutCreator scrollAreaWidget_; + QGroupBox *generalGroup; + QFrame *dataFrame_; +}; + +} // namespace chatterino + +#endif