diff --git a/.github/workflows/test-macos.yml b/.github/workflows/test-macos.yml index b35e7aea5..3eb1ef633 100644 --- a/.github/workflows/test-macos.yml +++ b/.github/workflows/test-macos.yml @@ -25,7 +25,7 @@ jobs: matrix: os: [macos-13] qt-version: [5.15.2, 6.7.1] - plugins: [false] + plugins: [true] fail-fast: false env: C2_BUILD_WITH_QT6: ${{ startsWith(matrix.qt-version, '6.') && 'ON' || 'OFF' }} diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index 926d5f907..b6b4c6014 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -26,7 +26,7 @@ jobs: matrix: os: [windows-latest] qt-version: [5.15.2, 6.7.1] - plugins: [false] + plugins: [true] skip-artifact: [false] skip-crashpad: [false] fail-fast: false diff --git a/.gitmodules b/.gitmodules index e58a5bbd4..e15a27575 100644 --- a/.gitmodules +++ b/.gitmodules @@ -44,3 +44,6 @@ [submodule "lib/expected-lite"] path = lib/expected-lite url = https://github.com/martinmoene/expected-lite +[submodule "lib/sol2"] + path = lib/sol2 + url = https://github.com/ThePhD/sol2.git diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fc9ad991..9ae647165 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,6 +108,7 @@ - Dev: Twitch messages are now sent using Twitch's Helix API instead of IRC by default. (#5607) - Dev: `GIFTimer` is no longer initialized in tests. (#5608) - Dev: Emojis now use flags instead of a set of strings for capabilities. (#5616) +- Dev: Move plugins to Sol2. (#5622) - Dev: Refactored static `MessageBuilder` helpers to standalone functions. (#5652) - Dev: Decoupled reply parsing from `MessageBuilder`. (#5660) diff --git a/CMakeLists.txt b/CMakeLists.txt index 6fb323286..023135891 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -212,6 +212,8 @@ endif() if (CHATTERINO_PLUGINS) set(LUA_INCLUDE_DIRS "${CMAKE_SOURCE_DIR}/lib/lua/src") add_subdirectory(lib/lua) + + find_package(Sol2 REQUIRED) endif() if (BUILD_WITH_CRASHPAD) diff --git a/cmake/FindSol2.cmake b/cmake/FindSol2.cmake new file mode 100644 index 000000000..be64d000c --- /dev/null +++ b/cmake/FindSol2.cmake @@ -0,0 +1,21 @@ +include(FindPackageHandleStandardArgs) + +find_path(Sol2_INCLUDE_DIR sol/sol.hpp HINTS ${CMAKE_SOURCE_DIR}/lib/sol2/include) + +find_package_handle_standard_args(Sol2 DEFAULT_MSG Sol2_INCLUDE_DIR) + +if (Sol2_FOUND) + add_library(Sol2 INTERFACE IMPORTED) + set_target_properties(Sol2 PROPERTIES + INTERFACE_INCLUDE_DIRECTORIES "${Sol2_INCLUDE_DIR}" + ) + target_compile_definitions(Sol2 INTERFACE + SOL_ALL_SAFETIES_ON=1 + SOL_USING_CXX_LUA=1 + SOL_NO_NIL=0 + ) + target_link_libraries(Sol2 INTERFACE lua) + add_library(sol2::sol2 ALIAS Sol2) +endif () + +mark_as_advanced(Sol2_INCLUDE_DIR) diff --git a/docs/plugin-meta.lua b/docs/plugin-meta.lua index d4b1ac25b..5a86efca0 100644 --- a/docs/plugin-meta.lua +++ b/docs/plugin-meta.lua @@ -5,14 +5,23 @@ -- Add the folder this file is in to "Lua.workspace.library". c2 = {} ----@alias c2.LogLevel integer ----@type { Debug: c2.LogLevel, Info: c2.LogLevel, Warning: c2.LogLevel, Critical: c2.LogLevel } +---@alias c2.LogLevel.Debug "c2.LogLevel.Debug" +---@alias c2.LogLevel.Info "c2.LogLevel.Info" +---@alias c2.LogLevel.Warning "c2.LogLevel.Warning" +---@alias c2.LogLevel.Critical "c2.LogLevel.Critical" +---@alias c2.LogLevel c2.LogLevel.Debug|c2.LogLevel.Info|c2.LogLevel.Warning|c2.LogLevel.Critical +---@type { Debug: c2.LogLevel.Debug, Info: c2.LogLevel.Info, Warning: c2.LogLevel.Warning, Critical: c2.LogLevel.Critical } c2.LogLevel = {} ----@alias c2.EventType integer ----@type { CompletionRequested: c2.EventType } +-- Begin src/controllers/plugins/api/EventType.hpp + +---@alias c2.EventType.CompletionRequested "c2.EventType.CompletionRequested" +---@alias c2.EventType c2.EventType.CompletionRequested +---@type { CompletionRequested: c2.EventType.CompletionRequested } c2.EventType = {} +-- End src/controllers/plugins/api/EventType.hpp + ---@class CommandContext ---@field words string[] The words typed when executing the command. For example `/foo bar baz` will result in `{"/foo", "bar", "baz"}`. ---@field channel c2.Channel The channel the command was executed in. @@ -29,19 +38,40 @@ c2.EventType = {} -- Begin src/common/Channel.hpp ----@alias c2.ChannelType integer ----@type { None: c2.ChannelType, Direct: c2.ChannelType, Twitch: c2.ChannelType, TwitchWhispers: c2.ChannelType, TwitchWatching: c2.ChannelType, TwitchMentions: c2.ChannelType, TwitchLive: c2.ChannelType, TwitchAutomod: c2.ChannelType, TwitchEnd: c2.ChannelType, Irc: c2.ChannelType, Misc: c2.ChannelType } +---@alias c2.ChannelType.None "c2.ChannelType.None" +---@alias c2.ChannelType.Direct "c2.ChannelType.Direct" +---@alias c2.ChannelType.Twitch "c2.ChannelType.Twitch" +---@alias c2.ChannelType.TwitchWhispers "c2.ChannelType.TwitchWhispers" +---@alias c2.ChannelType.TwitchWatching "c2.ChannelType.TwitchWatching" +---@alias c2.ChannelType.TwitchMentions "c2.ChannelType.TwitchMentions" +---@alias c2.ChannelType.TwitchLive "c2.ChannelType.TwitchLive" +---@alias c2.ChannelType.TwitchAutomod "c2.ChannelType.TwitchAutomod" +---@alias c2.ChannelType.TwitchEnd "c2.ChannelType.TwitchEnd" +---@alias c2.ChannelType.Misc "c2.ChannelType.Misc" +---@alias c2.ChannelType c2.ChannelType.None|c2.ChannelType.Direct|c2.ChannelType.Twitch|c2.ChannelType.TwitchWhispers|c2.ChannelType.TwitchWatching|c2.ChannelType.TwitchMentions|c2.ChannelType.TwitchLive|c2.ChannelType.TwitchAutomod|c2.ChannelType.TwitchEnd|c2.ChannelType.Misc +---@type { None: c2.ChannelType.None, Direct: c2.ChannelType.Direct, Twitch: c2.ChannelType.Twitch, TwitchWhispers: c2.ChannelType.TwitchWhispers, TwitchWatching: c2.ChannelType.TwitchWatching, TwitchMentions: c2.ChannelType.TwitchMentions, TwitchLive: c2.ChannelType.TwitchLive, TwitchAutomod: c2.ChannelType.TwitchAutomod, TwitchEnd: c2.ChannelType.TwitchEnd, Misc: c2.ChannelType.Misc } c2.ChannelType = {} -- End src/common/Channel.hpp -- Begin src/controllers/plugins/api/ChannelRef.hpp ----@alias c2.Platform integer ---- This enum describes a platform for the purpose of searching for a channel. ---- Currently only Twitch is supported because identifying IRC channels is tricky. ----@type { Twitch: c2.Platform } -c2.Platform = {} +-- Begin src/providers/twitch/TwitchChannel.hpp + +---@class StreamStatus +---@field live boolean +---@field viewer_count number +---@field title string Stream title or last stream title +---@field game_name string +---@field game_id string +---@field uptime number Seconds since the stream started. + +---@class RoomModes +---@field subscriber_only boolean +---@field unique_chat boolean You might know this as r9kbeta or robot9000. +---@field emotes_only boolean Whether or not text is allowed in messages. Note that "emotes" here only means Twitch emotes, not Unicode emoji, nor 3rd party text-based emotes + +-- End src/providers/twitch/TwitchChannel.hpp ---@class c2.Channel c2.Channel = {} @@ -72,7 +102,7 @@ function c2.Channel:get_display_name() end --- Note that this does not execute client-commands. --- ---@param message string ----@param execute_commands boolean Should commands be run on the text? +---@param execute_commands? boolean Should commands be run on the text? function c2.Channel:send_message(message, execute_commands) end --- Adds a system message client-side @@ -131,9 +161,8 @@ function c2.Channel:__tostring() end --- - /automod --- ---@param name string Which channel are you looking for? ----@param platform c2.Platform Where to search for the channel? ---@return c2.Channel? -function c2.Channel.by_name(name, platform) end +function c2.Channel.by_name(name) end --- Finds a channel by the Twitch user ID of its owner. --- @@ -141,21 +170,6 @@ function c2.Channel.by_name(name, platform) end ---@return c2.Channel? function c2.Channel.by_twitch_id(id) end ----@class RoomModes ----@field unique_chat boolean You might know this as r9kbeta or robot9000. ----@field subscriber_only boolean ----@field emotes_only boolean Whether or not text is allowed in messages. Note that "emotes" here only means Twitch emotes, not Unicode emoji, nor 3rd party text-based emotes ----@field follower_only number? Time in minutes you need to follow to chat or nil. ----@field slow_mode number? Time in seconds you need to wait before sending messages or nil. - ----@class StreamStatus ----@field live boolean ----@field viewer_count number ----@field uptime number Seconds since the stream started. ----@field title string Stream title or last stream title ----@field game_name string ----@field game_id string - -- End src/controllers/plugins/api/ChannelRef.hpp -- Begin src/controllers/plugins/api/HTTPResponse.hpp @@ -176,6 +190,9 @@ function HTTPResponse:status() end --- function HTTPResponse:error() end +---@return string +function HTTPResponse:__tostring() end + -- End src/controllers/plugins/api/HTTPResponse.hpp -- Begin src/controllers/plugins/api/HTTPRequest.hpp @@ -219,6 +236,9 @@ function HTTPRequest:set_header(name, value) end --- function HTTPRequest:execute() end +---@return string +function HTTPRequest:__tostring() end + --- Creates a new HTTPRequest --- ---@param method HTTPMethod Method to use @@ -230,8 +250,13 @@ function HTTPRequest.create(method, url) end -- Begin src/common/network/NetworkCommon.hpp ----@alias HTTPMethod integer ----@type { Get: HTTPMethod, Post: HTTPMethod, Put: HTTPMethod, Delete: HTTPMethod, Patch: HTTPMethod } +---@alias HTTPMethod.Get "HTTPMethod.Get" +---@alias HTTPMethod.Post "HTTPMethod.Post" +---@alias HTTPMethod.Put "HTTPMethod.Put" +---@alias HTTPMethod.Delete "HTTPMethod.Delete" +---@alias HTTPMethod.Patch "HTTPMethod.Patch" +---@alias HTTPMethod HTTPMethod.Get|HTTPMethod.Post|HTTPMethod.Put|HTTPMethod.Delete|HTTPMethod.Patch +---@type { Get: HTTPMethod.Get, Post: HTTPMethod.Post, Put: HTTPMethod.Put, Delete: HTTPMethod.Delete, Patch: HTTPMethod.Patch } HTTPMethod = {} -- End src/common/network/NetworkCommon.hpp @@ -245,7 +270,7 @@ function c2.register_command(name, handler) end --- Registers a callback to be invoked when completions for a term are requested. --- ----@param type "CompletionRequested" +---@param type c2.EventType.CompletionRequested ---@param func fun(event: CompletionEvent): CompletionList The callback to be invoked. function c2.register_callback(type, func) end diff --git a/docs/wip-plugins.md b/docs/wip-plugins.md index cd38fa18c..2fffb7429 100644 --- a/docs/wip-plugins.md +++ b/docs/wip-plugins.md @@ -171,7 +171,7 @@ function cmd_words(ctx) -- ctx contains: -- words - table of words supplied to the command including the trigger -- channel - the channel the command is being run in - channel:add_system_message("Words are: " .. table.concat(ctx.words, " ")) + ctx.channel:add_system_message("Words are: " .. table.concat(ctx.words, " ")) end c2.register_command("/words", cmd_words) @@ -183,7 +183,7 @@ Limitations/known issues: 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). -#### `register_callback("CompletionRequested", handler)` +#### `register_callback(c2.EventType.CompletionRequested, handler)` Registers a callback (`handler`) to process completions. The callback takes a single table with the following entries: @@ -207,7 +207,7 @@ function string.startswith(s, other) end c2.register_callback( - "CompletionRequested", + c2.EventType.CompletionRequested, function(event) if ("!join"):startswith(event.query) then ---@type CompletionList @@ -219,15 +219,6 @@ c2.register_callback( ) ``` -#### `Platform` enum - -This table describes platforms that can be accessed. Chatterino supports IRC -however plugins do not yet have explicit access to get IRC channels objects. -The values behind the names may change, do not count on them. It has the -following keys: - -- `Twitch` - #### `ChannelType` enum This table describes channel types Chatterino supports. The values behind the @@ -260,9 +251,9 @@ used on non-Twitch channels. Special channels while marked as is an actual Twitch chatroom use `Channel:get_type()` instead of `Channel:is_twitch_channel()`. -##### `Channel:by_name(name, platform)` +##### `Channel:by_name(name)` -Finds a channel given by `name` on `platform` (see [`Platform` enum](#Platform-enum)). Returns the channel or `nil` if not open. +Finds a channel given by `name`. Returns the channel or `nil` if not open. Some miscellaneous channels are marked as if they are specifically Twitch channels: @@ -275,7 +266,7 @@ Some miscellaneous channels are marked as if they are specifically Twitch channe Example: ```lua -local pajladas = c2.Channel.by_name("pajlada", c2.Platform.Twitch) +local pajladas = c2.Channel.by_name("pajlada") ``` ##### `Channel:by_twitch_id(id)` @@ -363,7 +354,7 @@ pajladas:add_system_message("Hello, world!") Returns `true` if the channel is a Twitch channel, that is its type name has the `Twitch` prefix. This returns `true` for special channels like Mentions. -You might want `Channel:get_type() == "Twitch"` if you want to use +You might want `Channel:get_type() == c2.ChannelType.Twitch` if you want to use Twitch-specific functions. ##### `Channel:get_twitch_id()` diff --git a/lib/lua/CMakeLists.txt b/lib/lua/CMakeLists.txt index cf2fad9bd..45824fdfa 100644 --- a/lib/lua/CMakeLists.txt +++ b/lib/lua/CMakeLists.txt @@ -1,48 +1,44 @@ 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/#' - +This list contains all .c files except lua.c and onelua.c +Use the following command from the repository root to get these file: +perl -e 'print s/^lib\/lua\///r . "\n" for grep { /\.c$/ && !/(lua|onelua)\.c$/ } glob "lib/lua/src/*.c"' #]====] 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" + 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/lundump.c + src/lutf8lib.c + src/lvm.c + src/lzio.c ) add_library(lua STATIC ${LUA_SRC}) @@ -50,4 +46,14 @@ target_include_directories(lua PUBLIC ${LUA_INCLUDE_DIRS} ) -set_source_files_properties(${LUA_SRC} PROPERTIES LANGUAGE C) +set_target_properties(${liblua} PROPERTIES + LANGUAGE CXX + LINKER_LANGUAGE CXX + CXX_STANDARD 98 + CXX_EXTENSIONS TRUE +) +target_compile_options(lua PRIVATE + -w # this makes clang shut up about c-as-c++ + $<$,$>:/EHsc> # enable exceptions in clang-cl +) +set_source_files_properties(${LUA_SRC} PROPERTIES LANGUAGE CXX) diff --git a/lib/lua/src b/lib/lua/src index 0897c0a42..1ab3208a1 160000 --- a/lib/lua/src +++ b/lib/lua/src @@ -1 +1 @@ -Subproject commit 0897c0a4289ef3a8d45761266124613f364bef60 +Subproject commit 1ab3208a1fceb12fca8f24ba57d6e13c5bff15e3 diff --git a/lib/sol2 b/lib/sol2 new file mode 160000 index 000000000..2b0d2fe8b --- /dev/null +++ b/lib/sol2 @@ -0,0 +1 @@ +Subproject commit 2b0d2fe8ba0074e16b499940c4f3126b9c7d3471 diff --git a/scripts/make_luals_meta.py b/scripts/make_luals_meta.py index f9f44e7ed..b1420e780 100644 --- a/scripts/make_luals_meta.py +++ b/scripts/make_luals_meta.py @@ -196,7 +196,7 @@ def write_func(path: Path, line: int, comments: list[str], out: TextIOWrapper): if not comments[0].startswith("@"): out.write(f"--- {comments[0]}\n---\n") comments = comments[1:] - params = [] + params: list[str] = [] for comment in comments[:-1]: if not comment.startswith("@lua"): panic(path, line, f"Invalid function specification - got '{comment}'") @@ -209,7 +209,7 @@ def write_func(path: Path, line: int, comments: list[str], out: TextIOWrapper): panic(path, line, f"Invalid function exposure - got '{comments[-1]}'") name = comments[-1].split(" ", 1)[1] printmsg(path, line, f"function {name}") - lua_params = ", ".join(params) + lua_params = ", ".join(p.removesuffix("?") for p in params) out.write(f"function {name}({lua_params}) end\n\n") @@ -242,13 +242,21 @@ def read_file(path: Path, out: TextIOWrapper): ) name = header[0].split(" ", 1)[1] printmsg(path, reader.line_no(), f"enum {name}") - out.write(f"---@alias {name} integer\n") + variants = reader.read_enum_variants() + + vtypes = [] + for variant in variants: + vtype = f'{name}.{variant}' + vtypes.append(vtype) + out.write(f'---@alias {vtype} "{vtype}"\n') + + out.write(f"---@alias {name} {'|'.join(vtypes)}\n") if header_comment: out.write(f"--- {header_comment}\n") out.write("---@type { ") out.write( ", ".join( - [f"{variant}: {name}" for variant in reader.read_enum_variants()] + [f"{variant}: {typ}" for variant, typ in zip(variants,vtypes)] ) ) out.write(" }\n") diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 4c24ef572..9d7134b97 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -225,24 +225,28 @@ set(SOURCE_FILES controllers/pings/MutedChannelModel.cpp controllers/pings/MutedChannelModel.hpp + controllers/plugins/api/ChannelRef.cpp controllers/plugins/api/ChannelRef.hpp - controllers/plugins/api/IOWrapper.cpp - controllers/plugins/api/IOWrapper.hpp + controllers/plugins/api/EventType.hpp controllers/plugins/api/HTTPRequest.cpp controllers/plugins/api/HTTPRequest.hpp controllers/plugins/api/HTTPResponse.cpp controllers/plugins/api/HTTPResponse.hpp + controllers/plugins/api/IOWrapper.cpp + controllers/plugins/api/IOWrapper.hpp controllers/plugins/LuaAPI.cpp controllers/plugins/LuaAPI.hpp - controllers/plugins/PluginPermission.cpp - controllers/plugins/PluginPermission.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/plugins/PluginController.cpp + controllers/plugins/PluginController.hpp + controllers/plugins/Plugin.cpp + controllers/plugins/Plugin.hpp + controllers/plugins/PluginPermission.cpp + controllers/plugins/PluginPermission.hpp + controllers/plugins/SolTypes.cpp + controllers/plugins/SolTypes.hpp controllers/sound/ISoundController.hpp controllers/sound/MiniaudioBackend.cpp @@ -791,7 +795,7 @@ target_link_libraries(${LIBRARY_PROJECT} $<$:Wtsapi32> ) if (CHATTERINO_PLUGINS) - target_link_libraries(${LIBRARY_PROJECT} PUBLIC lua) + target_link_libraries(${LIBRARY_PROJECT} PUBLIC lua sol2::sol2) endif() if (BUILD_WITH_QTKEYCHAIN) diff --git a/src/PrecompiledHeader.hpp b/src/PrecompiledHeader.hpp index d7c289bca..a12265950 100644 --- a/src/PrecompiledHeader.hpp +++ b/src/PrecompiledHeader.hpp @@ -129,6 +129,10 @@ # include # include +# ifdef CHATTERINO_HAVE_PLUGINS +# include +# endif + # ifndef UNUSED # define UNUSED(x) (void)(x) # endif diff --git a/src/common/Channel.hpp b/src/common/Channel.hpp index 554327622..ac90573ff 100644 --- a/src/common/Channel.hpp +++ b/src/common/Channel.hpp @@ -165,30 +165,3 @@ private: }; } // namespace chatterino - -template <> -constexpr magic_enum::customize::customize_t - magic_enum::customize::enum_name( - chatterino::Channel::Type value) noexcept -{ - using Type = chatterino::Channel::Type; - switch (value) - { - case Type::Twitch: - return "twitch"; - case Type::TwitchWhispers: - return "whispers"; - case Type::TwitchWatching: - return "watching"; - case Type::TwitchMentions: - return "mentions"; - case Type::TwitchLive: - return "live"; - case Type::TwitchAutomod: - return "automod"; - case Type::Misc: - return "misc"; - default: - return default_tag; - } -} diff --git a/src/controllers/plugins/LuaAPI.cpp b/src/controllers/plugins/LuaAPI.cpp index 03d11750a..4f73c51ee 100644 --- a/src/controllers/plugins/LuaAPI.cpp +++ b/src/controllers/plugins/LuaAPI.cpp @@ -3,34 +3,45 @@ # 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 "controllers/plugins/SolTypes.hpp" // for lua operations on QString{,List} for CompletionList -extern "C" { # include # include # include -} # include +# include # include # include # include +# include +# include +# include +# include +# include +# include +# include +# include + +# include +# include +# include namespace { using namespace chatterino; -void logHelper(lua_State *L, Plugin *pl, QDebug stream, int argc) +void logHelper(lua_State *L, Plugin *pl, QDebug stream, + const sol::variadic_args &args) { stream.noquote(); stream << "[" + pl->id + ":" + pl->meta.name + "]"; - for (int i = 1; i <= argc; i++) + for (const auto &arg : args) { - stream << lua::toString(L, i); + stream << lua::toString(L, arg.stack_index()); + // Remove this from our stack + lua_pop(L, 1); } - lua_pop(L, argc); } QDebug qdebugStreamForLogLevel(lua::api::LogLevel lvl) @@ -63,195 +74,92 @@ QDebug qdebugStreamForLogLevel(lua::api::LogLevel lvl) // 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) +CompletionList::CompletionList(const sol::table &table) + : values(table.get("values")) + , hideOthers(table["hide_others"]) { - auto *pl = getApp()->getPlugins()->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_register_callback(lua_State *L) +sol::table toTable(lua_State *L, const CompletionEvent &ev) { - auto *pl = getApp()->getPlugins()->getPluginByStatePtr(L); - if (pl == nullptr) - { - luaL_error(L, "internal error: no plugin"); - return 0; - } - EventType evtType{}; - if (!lua::peek(L, &evtType, 1)) - { - luaL_error(L, "cannot get event name (1st arg of register_callback, " - "expected a string)"); - return 0; - } - if (lua_isnoneornil(L, 2)) - { - luaL_error(L, "missing argument for register_callback: function " - "\"pointer\""); - return 0; - } - - auto typeName = magic_enum::enum_name(evtType); - std::string callbackSavedName; - callbackSavedName.reserve(5 + typeName.size()); - callbackSavedName += "c2cb-"; - callbackSavedName += typeName; - lua_setfield(L, LUA_REGISTRYINDEX, callbackSavedName.c_str()); - - lua_pop(L, 2); - - return 0; + return sol::state_view(L).create_table_with( + "query", ev.query, // + "full_text_content", ev.full_text_content, // + "cursor_position", ev.cursor_position, // + "is_first_word", ev.is_first_word // + ); } -int c2_log(lua_State *L) +void c2_register_callback(ThisPluginState L, EventType evtType, + sol::protected_function callback) { - auto *pl = getApp()->getPlugins()->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; + L.plugin()->callbacks[evtType] = std::move(callback); } -int c2_later(lua_State *L) +void c2_log(ThisPluginState L, LogLevel lvl, sol::variadic_args args) { - auto *pl = getApp()->getPlugins()->getPluginByStatePtr(L); - if (pl == nullptr) + lua::StackGuard guard(L); { - return luaL_error(L, "c2.later: internal error: no plugin?"); - } - if (lua_gettop(L) != 2) - { - return luaL_error( - L, "c2.later expects two arguments (a callback that takes no " - "arguments and returns nothing and a number the time in " - "milliseconds to wait)\n"); - } - int time{}; - if (!lua::pop(L, &time)) - { - return luaL_error(L, "cannot get time (2nd arg of c2.later, " - "expected a number)"); + QDebug stream = qdebugStreamForLogLevel(lvl); + logHelper(L, L.plugin(), stream, args); } +} - if (!lua_isfunction(L, lua_gettop(L))) +void c2_later(ThisPluginState L, sol::protected_function callback, int time) +{ + if (time <= 0) { - return luaL_error(L, "cannot get callback (1st arg of c2.later, " - "expected a function)"); + throw std::runtime_error( + "c2.later time must be strictly greater than zero."); } + sol::state_view lua(L); auto *timer = new QTimer(); timer->setInterval(time); - auto id = pl->addTimeout(timer); + timer->setSingleShot(true); + auto id = L.plugin()->addTimeout(timer); auto name = QString("timeout_%1").arg(id); - auto *coro = lua_newthread(L); - QObject::connect(timer, &QTimer::timeout, [pl, coro, name, timer]() { - timer->deleteLater(); - pl->removeTimeout(timer); - int nres{}; - lua_resume(coro, nullptr, 0, &nres); + sol::state_view main = sol::main_thread(L); - lua_pushnil(coro); - lua_setfield(coro, LUA_REGISTRYINDEX, name.toStdString().c_str()); - if (lua_gettop(coro) != 0) - { - stackDump(coro, - pl->id + - ": timer returned a value, this shouldn't happen " - "and is probably a plugin bug"); - } - }); - stackDump(L, "before setfield"); - lua_setfield(L, LUA_REGISTRYINDEX, name.toStdString().c_str()); - lua_xmove(L, coro, 1); // move function to thread + sol::thread thread = sol::thread::create(main); + sol::protected_function cb(thread.state(), callback); + main.registry()[name.toStdString()] = thread; + + QObject::connect( + timer, &QTimer::timeout, + [pl = L.plugin(), name, timer, cb, thread, main]() { + timer->deleteLater(); + pl->removeTimeout(timer); + sol::protected_function_result res = cb(); + + if (res.return_count() != 0) + { + stackDump(thread.lua_state(), + pl->id + + ": timer returned a value, this shouldn't happen " + "and is probably a plugin bug"); + } + main.registry()[name.toStdString()] = sol::nil; + }); timer->start(); - - return 0; } -int g_load(lua_State *L) +// TODO: Add tests for this once we run tests in debug mode +sol::variadic_results g_load(ThisPluginState s, sol::object data) { # ifdef NDEBUG - luaL_error(L, "load() is only usable in debug mode"); - return 0; + (void)data; + (void)s; + throw std::runtime_error("load() is only usable in debug mode"); # 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); + // If you're modifying this PLEASE verify it works, Sol is very annoying about serialization + // - Mm2PL + sol::state_view lua(s); + auto load = lua.registry()["real_load"]; + sol::protected_function_result ret = load(data, "=(load)", "t"); + return ret; # endif } @@ -320,7 +228,7 @@ int searcherAbsolute(lua_State *L) int searcherRelative(lua_State *L) { lua_Debug dbg; - lua_getstack(L, 1, &dbg); + lua_getstack(L, 2, &dbg); lua_getinfo(L, "S", &dbg); auto currentFile = QString::fromUtf8(dbg.source, dbg.srclen); if (currentFile.startsWith("@")) @@ -346,22 +254,14 @@ int searcherRelative(lua_State *L) return loadfile(L, filename); } -int g_print(lua_State *L) +void g_print(ThisPluginState L, sol::variadic_args args) { - auto *pl = getApp()->getPlugins()->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; + logHelper(L, L.plugin(), stream, args); } } // namespace chatterino::lua::api diff --git a/src/controllers/plugins/LuaAPI.hpp b/src/controllers/plugins/LuaAPI.hpp index 904c6daa4..bd83dee5a 100644 --- a/src/controllers/plugins/LuaAPI.hpp +++ b/src/controllers/plugins/LuaAPI.hpp @@ -1,17 +1,17 @@ #pragma once #ifdef CHATTERINO_HAVE_PLUGINS +# include "controllers/plugins/api/ChannelRef.hpp" +# include "controllers/plugins/Plugin.hpp" +# include "controllers/plugins/SolTypes.hpp" -extern "C" { # include -} -# include "controllers/plugins/LuaUtilities.hpp" - +# include # include +# include # include # include -# include struct lua_State; namespace chatterino::lua::api { @@ -30,11 +30,8 @@ namespace chatterino::lua::api { enum class LogLevel { Debug, Info, Warning, Critical }; /** - * @exposeenum c2.EventType + * @includefile controllers/plugins/api/EventType.hpp */ -enum class EventType { - CompletionRequested, -}; /** * @lua@class CommandContext @@ -46,10 +43,12 @@ enum class EventType { * @lua@class CompletionList */ struct CompletionList { + CompletionList(const sol::table &); + /** * @lua@field values string[] The completions */ - std::vector values{}; + QStringList values; /** * @lua@field hide_others boolean Whether other completions from Chatterino should be hidden/ignored. @@ -79,6 +78,8 @@ struct CompletionEvent { bool is_first_word{}; }; +sol::table toTable(lua_State *L, const CompletionEvent &ev); + /** * @includefile common/Channel.hpp * @includefile controllers/plugins/api/ChannelRef.hpp @@ -95,16 +96,16 @@ struct CompletionEvent { * @lua@return boolean ok Returns `true` if everything went ok, `false` if a command with this name exists. * @exposed c2.register_command */ -int c2_register_command(lua_State *L); /** * Registers a callback to be invoked when completions for a term are requested. * - * @lua@param type "CompletionRequested" + * @lua@param type c2.EventType.CompletionRequested * @lua@param func fun(event: CompletionEvent): CompletionList The callback to be invoked. * @exposed c2.register_callback */ -int c2_register_callback(lua_State *L); +void c2_register_callback(ThisPluginState L, EventType evtType, + sol::protected_function callback); /** * Writes a message to the Chatterino log. @@ -113,7 +114,7 @@ int c2_register_callback(lua_State *L); * @lua@param ... any Values to log. Should be convertible to a string with `tostring()`. * @exposed c2.log */ -int c2_log(lua_State *L); +void c2_log(ThisPluginState L, LogLevel lvl, sol::variadic_args args); /** * Calls callback around msec milliseconds later. Does not freeze Chatterino. @@ -122,11 +123,11 @@ int c2_log(lua_State *L); * @lua@param msec number How long to wait. * @exposed c2.later */ -int c2_later(lua_State *L); +void c2_later(ThisPluginState L, sol::protected_function callback, int time); // These ones are global -int g_load(lua_State *L); -int g_print(lua_State *L); +sol::variadic_results g_load(ThisPluginState s, sol::object data); +void g_print(ThisPluginState L, sol::variadic_args args); // NOLINTEND(readability-identifier-naming) // This is for require() exposed as an element of package.searchers diff --git a/src/controllers/plugins/LuaUtilities.cpp b/src/controllers/plugins/LuaUtilities.cpp index 64af18c01..58f648f0a 100644 --- a/src/controllers/plugins/LuaUtilities.cpp +++ b/src/controllers/plugins/LuaUtilities.cpp @@ -1,16 +1,10 @@ #ifdef CHATTERINO_HAVE_PLUGINS # include "controllers/plugins/LuaUtilities.hpp" -# include "common/Channel.hpp" # include "common/QLogging.hpp" -# include "controllers/commands/CommandContext.hpp" -# include "controllers/plugins/api/ChannelRef.hpp" -# include "controllers/plugins/LuaAPI.hpp" -extern "C" { # include # include -} # include # include @@ -79,9 +73,6 @@ QString humanErrorText(lua_State *L, int errCode) case LUA_ERRFILE: errName = "(file error)"; break; - case ERROR_BAD_PEEK: - errName = "(unable to convert value to c++)"; - break; default: errName = "(unknown error type)"; } @@ -93,18 +84,6 @@ QString humanErrorText(lua_State *L, int errCode) 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()); @@ -116,82 +95,6 @@ StackIdx push(lua_State *L, const std::string &str) return lua_gettop(L); } -StackIdx push(lua_State *L, const CommandContext &ctx) -{ - StackGuard guard(L, 1); - auto outIdx = pushEmptyTable(L, 2); - - push(L, ctx.words); - lua_setfield(L, outIdx, "words"); - - push(L, ctx.channel); - lua_setfield(L, outIdx, "channel"); - - return outIdx; -} - -StackIdx push(lua_State *L, const bool &b) -{ - lua_pushboolean(L, int(b)); - return lua_gettop(L); -} - -StackIdx push(lua_State *L, const int &b) -{ - lua_pushinteger(L, b); - return lua_gettop(L); -} - -StackIdx push(lua_State *L, const api::CompletionEvent &ev) -{ - auto idx = pushEmptyTable(L, 4); -# define PUSH(field) \ - lua::push(L, ev.field); \ - lua_setfield(L, idx, #field) - PUSH(query); - PUSH(full_text_content); - PUSH(cursor_position); - PUSH(is_first_word); -# undef PUSH - return idx; -} - -bool peek(lua_State *L, int *out, StackIdx idx) -{ - StackGuard guard(L); - if (lua_isnumber(L, idx) == 0) - { - return false; - } - - *out = lua_tointeger(L, idx); - return true; -} - -bool peek(lua_State *L, bool *out, StackIdx idx) -{ - StackGuard guard(L); - if (!lua_isboolean(L, idx)) - { - return false; - } - - *out = bool(lua_toboolean(L, idx)); - return true; -} - -bool peek(lua_State *L, double *out, StackIdx idx) -{ - StackGuard guard(L); - 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) { StackGuard guard(L); @@ -209,57 +112,6 @@ bool peek(lua_State *L, QString *out, StackIdx idx) return true; } -bool peek(lua_State *L, QByteArray *out, StackIdx idx) -{ - StackGuard guard(L); - 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) -{ - StackGuard guard(L); - 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; -} - -bool peek(lua_State *L, api::CompletionList *out, StackIdx idx) -{ - StackGuard guard(L); - int typ = lua_getfield(L, idx, "values"); - if (typ != LUA_TTABLE) - { - lua_pop(L, 1); - return false; - } - if (!lua::pop(L, &out->values, -1)) - { - return false; - } - lua_getfield(L, idx, "hide_others"); - return lua::pop(L, &out->hideOthers); -} - QString toString(lua_State *L, StackIdx idx) { size_t len{}; diff --git a/src/controllers/plugins/LuaUtilities.hpp b/src/controllers/plugins/LuaUtilities.hpp index 5443a751f..0f7bdc53f 100644 --- a/src/controllers/plugins/LuaUtilities.hpp +++ b/src/controllers/plugins/LuaUtilities.hpp @@ -2,37 +2,20 @@ #ifdef CHATTERINO_HAVE_PLUGINS -# include "common/QLogging.hpp" - -extern "C" { # include # include -} # include # include +# include # include -# include # include # include # include -# include -# include struct lua_State; -class QJsonObject; -namespace chatterino { -struct CommandContext; -} // namespace chatterino namespace chatterino::lua { -namespace api { - struct CompletionList; - struct CompletionEvent; -} // namespace api - -constexpr int ERROR_BAD_PEEK = LUA_OK - 1; - /** * @brief Dumps the Lua stack into qCDebug(chatterinoLua) * @@ -40,6 +23,9 @@ constexpr int ERROR_BAD_PEEK = LUA_OK - 1; */ void stackDump(lua_State *L, const QString &tag); +// This is for calling stackDump out of gdb as it's not easy to create a QString there +const QString GDB_DUMMY = "GDB_DUMMY"; + /** * @brief Converts a lua error code and potentially string on top of the stack into a human readable message */ @@ -50,33 +36,11 @@ QString humanErrorText(lua_State *L, int errCode); */ 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); -StackIdx push(lua_State *L, const int &b); -StackIdx push(lua_State *L, const api::CompletionEvent &ev); // returns OK? -bool peek(lua_State *L, int *out, StackIdx idx = -1); -bool peek(lua_State *L, bool *out, StackIdx idx = -1); -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); -bool peek(lua_State *L, api::CompletionList *out, StackIdx idx = -1); /** * @brief Converts Lua object at stack index idx to a string. @@ -140,246 +104,29 @@ public: } }; -/// TEMPLATES - -template -StackIdx push(lua_State *L, std::optional val) -{ - if (val.has_value()) - { - return lua::push(L, *val); - } - lua_pushnil(L); - return lua_gettop(L); -} - -template -bool peek(lua_State *L, std::optional *out, StackIdx idx = -1) -{ - if (lua_isnil(L, idx)) - { - *out = std::nullopt; - return true; - } - - *out = T(); - return peek(L, out->operator->(), idx); -} - -template -bool peek(lua_State *L, std::vector *vec, StackIdx idx = -1) -{ - StackGuard guard(L); - - if (!lua_istable(L, idx)) - { - lua::stackDump(L, "!table"); - qCDebug(chatterinoLua) - << "value is not a table, type is" << lua_type(L, idx); - return false; - } - auto len = lua_rawlen(L, idx); - if (len == 0) - { - qCDebug(chatterinoLua) << "value has 0 length"; - return true; - } - if (len > 1'000'000) - { - qCDebug(chatterinoLua) << "value is too long"; - return false; - } - // count like lua - for (int i = 1; i <= len; i++) - { - lua_geti(L, idx, i); - std::optional obj; - if (!lua::peek(L, &obj)) - { - //lua_seti(L, LUA_REGISTRYINDEX, 1); // lazy - qCDebug(chatterinoLua) - << "Failed to convert lua object into c++: at array index " << i - << ":"; - stackDump(L, "bad conversion into string"); - return false; - } - lua_pop(L, 1); - vec->push_back(obj.value()); - } - return true; -} - -/** - * @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 , bool> = true> -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. - * If peek fails, the object is still removed from the stack. - * - * Relies on bool peek(lua_State*, T*, StackIdx) existing. - */ -template -bool pop(lua_State *L, T *out, StackIdx idx = -1) -{ - StackGuard guard(L, -1); - auto ok = peek(L, out, idx); - 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 + * @returns Sol reference to the table */ template -StackIdx pushEnumTable(lua_State *L) + requires std::is_enum_v +sol::table createEnumTable(sol::state_view &lua) { - // std::array - auto values = magic_enum::enum_values(); - StackIdx out = lua::pushEmptyTable(L, values.size()); + constexpr auto values = magic_enum::enum_values(); + auto out = lua.create_table(0, 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()); + out.raw_set(str, v); } return out; } -// Represents a Lua function on the stack -template -class CallbackFunction -{ - StackIdx stackIdx_; - lua_State *L; - -public: - CallbackFunction(lua_State *L, StackIdx stackIdx) - : stackIdx_(stackIdx) - , L(L) - { - } - - // this type owns the stackidx, it must not be trivially copiable - CallbackFunction operator=(CallbackFunction &) = delete; - CallbackFunction(CallbackFunction &) = delete; - - // Permit only move - CallbackFunction &operator=(CallbackFunction &&) = default; - CallbackFunction(CallbackFunction &&) = default; - - ~CallbackFunction() - { - lua_remove(L, this->stackIdx_); - } - - std::variant operator()(Args... arguments) - { - lua_pushvalue(this->L, this->stackIdx_); - ( // apparently this calls lua::push() for every Arg - [this, &arguments] { - lua::push(this->L, arguments); - }(), - ...); - - int res = lua_pcall(L, sizeof...(Args), 1, 0); - if (res != LUA_OK) - { - qCDebug(chatterinoLua) << "error is: " << res; - return {res}; - } - - ReturnType val; - if (!lua::pop(L, &val)) - { - return {ERROR_BAD_PEEK}; - } - return {val}; - } -}; - } // namespace chatterino::lua #endif diff --git a/src/controllers/plugins/Plugin.cpp b/src/controllers/plugins/Plugin.cpp index dcf7357e8..739fa6372 100644 --- a/src/controllers/plugins/Plugin.cpp +++ b/src/controllers/plugins/Plugin.cpp @@ -7,14 +7,13 @@ # include "controllers/plugins/PluginPermission.hpp" # include "util/QMagicEnum.hpp" -extern "C" { # include -} # include # include # include # include # include +# include # include # include @@ -190,7 +189,8 @@ PluginMeta::PluginMeta(const QJsonObject &obj) } } -bool Plugin::registerCommand(const QString &name, const QString &functionName) +bool Plugin::registerCommand(const QString &name, + sol::protected_function function) { if (this->ownedCommands.find(name) != this->ownedCommands.end()) { @@ -202,7 +202,7 @@ bool Plugin::registerCommand(const QString &name, const QString &functionName) { return false; } - this->ownedCommands.insert({name, functionName}); + this->ownedCommands.emplace(name, std::move(function)); return true; } @@ -223,14 +223,24 @@ Plugin::~Plugin() QObject::disconnect(timer, nullptr, nullptr, nullptr); timer->deleteLater(); } + this->httpRequests.clear(); qCDebug(chatterinoLua) << "Destroyed" << this->activeTimeouts.size() << "timers for plugin" << this->id << "while destroying the object"; this->activeTimeouts.clear(); if (this->state_ != nullptr) { + // clearing this after the state is gone is not safe to do + this->ownedCommands.clear(); + this->callbacks.clear(); lua_close(this->state_); } + assert(this->ownedCommands.empty() && + "This must be empty or destructor of sol::protected_function would " + "explode malloc structures later"); + assert(this->callbacks.empty() && + "This must be empty or destructor of sol::protected_function would " + "explode malloc structures later"); } int Plugin::addTimeout(QTimer *timer) { diff --git a/src/controllers/plugins/Plugin.hpp b/src/controllers/plugins/Plugin.hpp index f8375247f..a65329468 100644 --- a/src/controllers/plugins/Plugin.hpp +++ b/src/controllers/plugins/Plugin.hpp @@ -2,8 +2,8 @@ #ifdef CHATTERINO_HAVE_PLUGINS # include "Application.hpp" -# include "common/network/NetworkCommon.hpp" -# include "controllers/plugins/LuaAPI.hpp" +# include "controllers/plugins/api/EventType.hpp" +# include "controllers/plugins/api/HTTPRequest.hpp" # include "controllers/plugins/LuaUtilities.hpp" # include "controllers/plugins/PluginPermission.hpp" @@ -11,7 +11,10 @@ # include # include # include +# include +# include +# include # include # include # include @@ -56,6 +59,8 @@ struct PluginMeta { } explicit PluginMeta(const QJsonObject &obj); + // This is for tests + PluginMeta() = default; }; class Plugin @@ -75,13 +80,18 @@ public: ~Plugin(); + Plugin(const Plugin &) = delete; + Plugin(Plugin &&) = delete; + Plugin &operator=(const Plugin &) = delete; + Plugin &operator=(Plugin &&) = delete; + /** * @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 + * @param function 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); + bool registerCommand(const QString &name, sol::protected_function function); /** * @brief Get names of all commands belonging to this plugin @@ -98,35 +108,19 @@ public: return this->loadDirectory_.absoluteFilePath("data"); } - // Note: The CallbackFunction object's destructor will remove the function from the lua stack - using LuaCompletionCallback = - lua::CallbackFunction; - std::optional getCompletionCallback() + std::optional getCompletionCallback() { if (this->state_ == nullptr || !this->error_.isNull()) { return {}; } - // this uses magic enum to help automatic tooling find usages - auto typeName = - magic_enum::enum_name(lua::api::EventType::CompletionRequested); - std::string cbName; - cbName.reserve(5 + typeName.size()); - cbName += "c2cb-"; - cbName += typeName; - auto typ = - lua_getfield(this->state_, LUA_REGISTRYINDEX, cbName.c_str()); - if (typ != LUA_TFUNCTION) + auto it = + this->callbacks.find(lua::api::EventType::CompletionRequested); + if (it == this->callbacks.end()) { - lua_pop(this->state_, 1); return {}; } - - // move - return std::make_optional>( - this->state_, lua_gettop(this->state_)); + return it->second; } /** @@ -143,18 +137,25 @@ public: bool hasFSPermissionFor(bool write, const QString &path); bool hasHTTPPermissionFor(const QUrl &url); + std::map callbacks; + + // In-flight HTTP Requests + // This is a lifetime hack to ensure they get deleted with the plugin. This relies on the Plugin getting deleted on reload! + std::vector> httpRequests; + private: QDir loadDirectory_; lua_State *state_; QString error_; - // maps command name -> function name - std::unordered_map ownedCommands; + // maps command name -> function + std::unordered_map ownedCommands; std::vector activeTimeouts; int lastTimerId = 0; friend class PluginController; + friend class PluginControllerAccess; // this is for tests }; } // namespace chatterino #endif diff --git a/src/controllers/plugins/PluginController.cpp b/src/controllers/plugins/PluginController.cpp index 5ca77ed40..1a2bc3a10 100644 --- a/src/controllers/plugins/PluginController.cpp +++ b/src/controllers/plugins/PluginController.cpp @@ -13,16 +13,20 @@ # include "controllers/plugins/api/IOWrapper.hpp" # include "controllers/plugins/LuaAPI.hpp" # include "controllers/plugins/LuaUtilities.hpp" +# include "controllers/plugins/SolTypes.hpp" # include "messages/MessageBuilder.hpp" # include "singletons/Paths.hpp" # include "singletons/Settings.hpp" -extern "C" { # include # include # include -} # include +# include +# include +# include +# include +# include # include # include @@ -113,10 +117,11 @@ bool PluginController::tryLoadFromDir(const QDir &pluginDir) return true; } -void PluginController::openLibrariesFor(lua_State *L, const PluginMeta &meta, - const QDir &pluginDir) +void PluginController::openLibrariesFor(Plugin *plugin) { + auto *L = plugin->state_; lua::StackGuard guard(L); + sol::state_view lua(L); // Stuff to change, remove or hide behind a permission system: static const std::vector loadedlibs = { luaL_Reg{LUA_GNAME, luaopen_base}, @@ -124,8 +129,6 @@ void PluginController::openLibrariesFor(lua_State *L, const PluginMeta &meta, luaL_Reg{LUA_COLIBNAME, luaopen_coroutine}, 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 @@ -147,155 +150,100 @@ void PluginController::openLibrariesFor(lua_State *L, const PluginMeta &meta, luaL_requiref(L, LUA_IOLIBNAME, luaopen_io, int(false)); lua_setfield(L, LUA_REGISTRYINDEX, lua::api::REG_REAL_IO_NAME); - // NOLINTNEXTLINE(*-avoid-c-arrays) - static const luaL_Reg c2Lib[] = { - {"register_command", lua::api::c2_register_command}, - {"register_callback", lua::api::c2_register_callback}, - {"log", lua::api::c2_log}, - {"later", lua::api::c2_later}, - {nullptr, nullptr}, - }; - lua_pushglobaltable(L); - auto gtable = lua_gettop(L); - - // count of elements in C2LIB + LogLevel + EventType - auto c2libIdx = lua::pushEmptyTable(L, 8); - - luaL_setfuncs(L, c2Lib, 0); - - lua::pushEnumTable(L); - lua_setfield(L, c2libIdx, "LogLevel"); - - lua::pushEnumTable(L); - lua_setfield(L, c2libIdx, "EventType"); - - lua::pushEnumTable(L); - lua_setfield(L, c2libIdx, "Platform"); - - lua::pushEnumTable(L); - lua_setfield(L, c2libIdx, "ChannelType"); - - lua::pushEnumTable(L); - lua_setfield(L, c2libIdx, "HTTPMethod"); - - // Initialize metatables for objects - lua::api::ChannelRef::createMetatable(L); - lua_setfield(L, c2libIdx, "Channel"); - - lua::api::HTTPRequest::createMetatable(L); - lua_setfield(L, c2libIdx, "HTTPRequest"); - - lua::api::HTTPResponse::createMetatable(L); - lua_setfield(L, c2libIdx, "HTTPResponse"); - - lua_setfield(L, gtable, "c2"); + auto r = lua.registry(); + auto g = lua.globals(); + auto c2 = lua.create_table(); + g["c2"] = c2; // ban functions // Note: this might not be fully secure? some kind of metatable fuckery might come up? - // possibly randomize this name at runtime to prevent some attacks? - # ifndef NDEBUG - lua_getfield(L, gtable, "load"); - lua_setfield(L, LUA_REGISTRYINDEX, "real_load"); + lua.registry()["real_load"] = lua.globals()["load"]; # endif + // See chatterino::lua::api::g_load implementation - // NOLINTNEXTLINE(*-avoid-c-arrays) - static const luaL_Reg replacementFuncs[] = { - {"load", lua::api::g_load}, - {"print", lua::api::g_print}, - {nullptr, nullptr}, - }; - luaL_setfuncs(L, replacementFuncs, 0); - - lua_pushnil(L); - lua_setfield(L, gtable, "loadfile"); - - lua_pushnil(L); - lua_setfield(L, gtable, "dofile"); + g["loadfile"] = sol::nil; + g["dofile"] = sol::nil; // set up package lib - lua_getfield(L, gtable, "package"); - - auto package = lua_gettop(L); - lua_pushstring(L, ""); - lua_setfield(L, package, "cpath"); - - // we don't use path - lua_pushstring(L, ""); - lua_setfield(L, package, "path"); - { - lua_getfield(L, gtable, "table"); - auto table = lua_gettop(L); - lua_getfield(L, -1, "remove"); - lua_remove(L, table); - } - auto remove = lua_gettop(L); + auto package = g["package"]; + package["cpath"] = ""; + package["path"] = ""; - // remove searcher_Croot, searcher_C and searcher_Lua leaving only searcher_preload - for (int i = 0; i < 3; i++) + sol::protected_function tbremove = g["table"]["remove"]; + + // remove searcher_Croot, searcher_C and searcher_Lua leaving only searcher_preload + sol::table searchers = package["searchers"]; + for (int i = 0; i < 3; i++) + { + tbremove(searchers); + } + searchers.add(&lua::api::searcherRelative); + searchers.add(&lua::api::searcherAbsolute); + } + // set up io lib { - lua_pushvalue(L, remove); - lua_getfield(L, package, "searchers"); - lua_pcall(L, 1, 0, 0); + auto c2io = lua.create_table(); + auto realio = r[lua::api::REG_REAL_IO_NAME]; + c2io["type"] = realio["type"]; + g["io"] = c2io; + // prevent plugins getting direct access to realio + r[LUA_LOADED_TABLE]["io"] = c2io; + + // Don't give plugins the option to shit into our stdio + r["_IO_input"] = sol::nil; + r["_IO_output"] = sol::nil; } - lua_pop(L, 1); // get rid of remove + PluginController::initSol(lua, plugin); +} - lua_getfield(L, package, "searchers"); - lua_pushcclosure(L, lua::api::searcherRelative, 0); - lua_seti(L, -2, 2); +// TODO: investigate if `plugin` can ever point to an invalid plugin, +// especially in cases when the plugin is errored. +void PluginController::initSol(sol::state_view &lua, Plugin *plugin) +{ + auto g = lua.globals(); + // Do not capture plugin->state_ in lambdas, this makes the functions unusable in callbacks + g.set_function("print", &lua::api::g_print); + g.set_function("load", &lua::api::g_load); - lua::push(L, QString(pluginDir.absolutePath())); - lua_pushcclosure(L, lua::api::searcherAbsolute, 1); - lua_seti(L, -2, 3); - lua_pop(L, 2); // remove package, package.searchers + sol::table c2 = g["c2"]; + c2.set_function("register_command", + [plugin](const QString &name, sol::protected_function cb) { + return plugin->registerCommand(name, std::move(cb)); + }); + c2.set_function("register_callback", &lua::api::c2_register_callback); + c2.set_function("log", &lua::api::c2_log); + c2.set_function("later", &lua::api::c2_later); - // NOLINTNEXTLINE(*-avoid-c-arrays) - static const luaL_Reg ioLib[] = { - {"close", lua::api::io_close}, - {"flush", lua::api::io_flush}, - {"input", lua::api::io_input}, - {"lines", lua::api::io_lines}, - {"open", lua::api::io_open}, - {"output", lua::api::io_output}, - {"popen", lua::api::io_popen}, // stub - {"read", lua::api::io_read}, - {"tmpfile", lua::api::io_tmpfile}, // stub - {"write", lua::api::io_write}, - // type = realio.type - {nullptr, nullptr}, - }; - // TODO: io.popen stub - auto iolibIdx = lua::pushEmptyTable(L, 1); - luaL_setfuncs(L, ioLib, 0); + lua::api::ChannelRef::createUserType(c2); + lua::api::HTTPResponse::createUserType(c2); + lua::api::HTTPRequest::createUserType(c2); + c2["ChannelType"] = lua::createEnumTable(lua); + c2["HTTPMethod"] = lua::createEnumTable(lua); + c2["EventType"] = lua::createEnumTable(lua); + c2["LogLevel"] = lua::createEnumTable(lua); - // set ourio.type = realio.type - lua_pushvalue(L, iolibIdx); - lua_getfield(L, LUA_REGISTRYINDEX, lua::api::REG_REAL_IO_NAME); - lua_getfield(L, -1, "type"); - lua_remove(L, -2); // remove realio - lua_setfield(L, iolibIdx, "type"); - lua_pop(L, 1); // still have iolib on top of stack - - lua_pushvalue(L, iolibIdx); - lua_setfield(L, gtable, "io"); - - lua_pushvalue(L, iolibIdx); - lua_setfield(L, LUA_REGISTRYINDEX, lua::api::REG_C2_IO_NAME); - - luaL_getsubtable(L, LUA_REGISTRYINDEX, LUA_LOADED_TABLE); - lua_pushvalue(L, iolibIdx); - lua_setfield(L, -2, "io"); - - lua_pop(L, 3); // remove gtable, iolib, LOADED - - // Don't give plugins the option to shit into our stdio - lua_pushnil(L); - lua_setfield(L, LUA_REGISTRYINDEX, "_IO_input"); - - lua_pushnil(L); - lua_setfield(L, LUA_REGISTRYINDEX, "_IO_output"); + sol::table io = g["io"]; + io.set_function( + "open", sol::overload(&lua::api::io_open, &lua::api::io_open_modeless)); + io.set_function("lines", sol::overload(&lua::api::io_lines, + &lua::api::io_lines_noargs)); + io.set_function("input", sol::overload(&lua::api::io_input_argless, + &lua::api::io_input_name, + &lua::api::io_input_file)); + io.set_function("output", sol::overload(&lua::api::io_output_argless, + &lua::api::io_output_name, + &lua::api::io_output_file)); + io.set_function("close", sol::overload(&lua::api::io_close_argless, + &lua::api::io_close_file)); + io.set_function("flush", sol::overload(&lua::api::io_flush_argless, + &lua::api::io_flush_file)); + io.set_function("read", &lua::api::io_read); + io.set_function("write", &lua::api::io_write); + io.set_function("popen", &lua::api::io_popen); + io.set_function("tmpfile", &lua::api::io_tmpfile); } void PluginController::load(const QFileInfo &index, const QDir &pluginDir, @@ -314,7 +262,7 @@ void PluginController::load(const QFileInfo &index, const QDir &pluginDir, << " because safe mode is enabled."; return; } - PluginController::openLibrariesFor(l, meta, pluginDir); + PluginController::openLibrariesFor(temp); if (!PluginController::isPluginEnabled(pluginName) || !getSettings()->pluginsEnabled) @@ -345,17 +293,13 @@ bool PluginController::reload(const QString &id) { return false; } - if (it->second->state_ != nullptr) - { - lua_close(it->second->state_); - it->second->state_ = nullptr; - } + for (const auto &[cmd, _] : it->second->ownedCommands) { getApp()->getCommands()->unregisterPluginCommand(cmd); } - it->second->ownedCommands.clear(); QDir loadDir = it->second->loadDirectory_; + // Since Plugin owns the state, it will clean up everything related to it this->plugins_.erase(id); this->tryLoadFromDir(loadDir); return true; @@ -369,27 +313,36 @@ QString PluginController::tryExecPluginCommand(const QString &commandName, if (auto it = plugin->ownedCommands.find(commandName); it != plugin->ownedCommands.end()) { - const auto &funcName = it->second; + sol::state_view lua(plugin->state_); + sol::table args = lua.create_table_with( + "words", ctx.words, // + "channel", lua::api::ChannelRef(ctx.channel) // + ); - 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) + auto result = + lua::tryCall>(it->second, args); + if (!result) { - ctx.channel->addSystemMessage("Lua error: " + - lua::humanErrorText(L, res)); - return ""; + ctx.channel->addSystemMessage( + QStringView( + u"Failed to evaluate command from plugin %1: %2") + .arg(plugin->meta.name, result.error())); + return {}; } - return ""; + + auto opt = result.value(); + if (!opt) + { + return {}; + } + return *opt; } } 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 ""; + return {}; } bool PluginController::isPluginEnabled(const QString &id) @@ -435,32 +388,31 @@ std::pair PluginController::updateCustomCompletions( continue; } - lua::StackGuard guard(pl->state_); - auto opt = pl->getCompletionCallback(); if (opt) { qCDebug(chatterinoLua) << "Processing custom completions from plugin" << name; auto &cb = *opt; - auto errOrList = cb(lua::api::CompletionEvent{ - .query = query, - .full_text_content = fullTextContent, - .cursor_position = cursorPosition, - .is_first_word = isFirstWord, - }); - if (std::holds_alternative(errOrList)) + sol::state_view view(pl->state_); + auto errOrList = lua::tryCall( + cb, + toTable(pl->state_, lua::api::CompletionEvent{ + .query = query, + .full_text_content = fullTextContent, + .cursor_position = cursorPosition, + .is_first_word = isFirstWord, + })); + if (!errOrList.has_value()) { - guard.handled(); - int err = std::get(errOrList); qCDebug(chatterinoLua) << "Got error from plugin " << pl->meta.name << " while refreshing tab completion: " - << lua::humanErrorText(pl->state_, err); + << errOrList.get_unexpected().error(); continue; } - auto list = std::get(errOrList); + auto list = lua::api::CompletionList(*errOrList); if (list.hideOthers) { results = QStringList(list.values.begin(), list.values.end()); diff --git a/src/controllers/plugins/PluginController.hpp b/src/controllers/plugins/PluginController.hpp index 50bc88c7e..ce4fe6170 100644 --- a/src/controllers/plugins/PluginController.hpp +++ b/src/controllers/plugins/PluginController.hpp @@ -10,6 +10,7 @@ # include # include # include +# include # include # include @@ -66,11 +67,16 @@ private: const PluginMeta &meta); // This function adds lua standard libraries into the state - static void openLibrariesFor(lua_State *L, const PluginMeta & /*meta*/, - const QDir &pluginDir); + static void openLibrariesFor(Plugin *plugin); + + static void initSol(sol::state_view &lua, Plugin *plugin); + static void loadChatterinoLib(lua_State *l); bool tryLoadFromDir(const QDir &pluginDir); std::map> plugins_; + + // This is for tests, pay no attention + friend class PluginControllerAccess; }; } // namespace chatterino diff --git a/src/controllers/plugins/PluginPermission.hpp b/src/controllers/plugins/PluginPermission.hpp index ffa728e7b..8beb918a1 100644 --- a/src/controllers/plugins/PluginPermission.hpp +++ b/src/controllers/plugins/PluginPermission.hpp @@ -10,6 +10,8 @@ namespace chatterino { struct PluginPermission { explicit PluginPermission(const QJsonObject &obj); + // This is for tests + PluginPermission() = default; enum class Type { FilesystemRead, diff --git a/src/controllers/plugins/SolTypes.cpp b/src/controllers/plugins/SolTypes.cpp new file mode 100644 index 000000000..43de08295 --- /dev/null +++ b/src/controllers/plugins/SolTypes.cpp @@ -0,0 +1,131 @@ +#ifdef CHATTERINO_HAVE_PLUGINS +# include "controllers/plugins/SolTypes.hpp" + +# include "controllers/plugins/PluginController.hpp" + +# include +# include +namespace chatterino::lua { + +Plugin *ThisPluginState::plugin() +{ + if (this->plugptr_ != nullptr) + { + return this->plugptr_; + } + auto *pl = getApp()->getPlugins()->getPluginByStatePtr(this->state_); + if (pl == nullptr) + { + throw std::runtime_error("internal error: missing plugin"); + } + this->plugptr_ = pl; + return pl; +} + +} // namespace chatterino::lua + +// NOLINTBEGIN(readability-named-parameter) +// QString +bool sol_lua_check(sol::types, lua_State *L, int index, + std::function handler, + sol::stack::record &tracking) +{ + return sol::stack::check(L, index, handler, tracking); +} + +QString sol_lua_get(sol::types, lua_State *L, int index, + sol::stack::record &tracking) +{ + auto str = sol::stack::get(L, index, tracking); + return QString::fromUtf8(str.data(), static_cast(str.length())); +} + +int sol_lua_push(sol::types, lua_State *L, const QString &value) +{ + return sol::stack::push(L, value.toUtf8().data()); +} + +// QStringList +bool sol_lua_check(sol::types, lua_State *L, int index, + std::function handler, + sol::stack::record &tracking) +{ + return sol::stack::check(L, index, handler, tracking); +} + +QStringList sol_lua_get(sol::types, lua_State *L, int index, + sol::stack::record &tracking) +{ + sol::table table = sol::stack::get(L, index, tracking); + QStringList result; + result.reserve(static_cast(table.size())); + for (size_t i = 1; i < table.size() + 1; i++) + { + result.append(table.get(i)); + } + return result; +} + +int sol_lua_push(sol::types, lua_State *L, + const QStringList &value) +{ + sol::table table = sol::table::create(L, static_cast(value.size())); + for (const QString &str : value) + { + table.add(str); + } + return sol::stack::push(L, table); +} + +// QByteArray +bool sol_lua_check(sol::types, lua_State *L, int index, + std::function handler, + sol::stack::record &tracking) +{ + return sol::stack::check(L, index, handler, tracking); +} + +QByteArray sol_lua_get(sol::types, lua_State *L, int index, + sol::stack::record &tracking) +{ + auto str = sol::stack::get(L, index, tracking); + return QByteArray::fromRawData(str.data(), str.length()); +} + +int sol_lua_push(sol::types, lua_State *L, const QByteArray &value) +{ + return sol::stack::push(L, + std::string_view(value.constData(), value.size())); +} + +namespace chatterino::lua { + +// ThisPluginState + +bool sol_lua_check(sol::types, + lua_State * /*L*/, int /* index*/, + std::function /* handler*/, + sol::stack::record & /*tracking*/) +{ + return true; +} + +chatterino::lua::ThisPluginState sol_lua_get( + sol::types, lua_State *L, int /*index*/, + sol::stack::record &tracking) +{ + tracking.use(0); + return {L}; +} + +int sol_lua_push(sol::types, lua_State *L, + const chatterino::lua::ThisPluginState &value) +{ + return sol::stack::push(L, sol::thread(L, value)); +} + +} // namespace chatterino::lua + +// NOLINTEND(readability-named-parameter) + +#endif diff --git a/src/controllers/plugins/SolTypes.hpp b/src/controllers/plugins/SolTypes.hpp new file mode 100644 index 000000000..3ee41c3a7 --- /dev/null +++ b/src/controllers/plugins/SolTypes.hpp @@ -0,0 +1,170 @@ +#pragma once +#ifdef CHATTERINO_HAVE_PLUGINS +# include "util/QMagicEnum.hpp" +# include "util/TypeName.hpp" + +# include +# include +# include +# include +# include +# include + +namespace chatterino::detail { + +// NOLINTBEGIN(readability-identifier-naming) +template +constexpr bool IsOptional = false; +template +constexpr bool IsOptional> = true; +// NOLINTEND(readability-identifier-naming) + +} // namespace chatterino::detail + +namespace chatterino { + +class Plugin; + +} // namespace chatterino + +namespace chatterino::lua { + +class ThisPluginState +{ +public: + ThisPluginState(lua_State *Ls) + : plugptr_(nullptr) + , state_(Ls) + { + } + + operator lua_State *() const noexcept + { + return this->state_; + } + + lua_State *operator->() const noexcept + { + return this->state_; + } + lua_State *state() const noexcept + { + return this->state_; + } + + Plugin *plugin(); + +private: + Plugin *plugptr_; + lua_State *state_; +}; + +/// @brief Attempts to call @a function with @a args +/// +/// @a T is expected to be returned. +/// If `void` is specified, the returned values +/// are ignored. +/// `std::optional` means nil|LuaEquiv (or zero returns) +/// A return type that doesn't match returns an error +template +inline nonstd::expected_lite::expected tryCall( + const sol::protected_function &function, Args &&...args) +{ + sol::protected_function_result result = + function(std::forward(args)...); + if (!result.valid()) + { + sol::error err = result; + return nonstd::expected_lite::make_unexpected( + QString::fromUtf8(err.what())); + } + + if constexpr (std::is_same_v) + { + return {}; + } + else + { + if constexpr (detail::IsOptional) + { + if (result.return_count() == 0) + { + return {}; + } + } + if (result.return_count() > 1) + { + return nonstd::expected_lite::make_unexpected( + u"Expected one value to be returned but " % + QString::number(result.return_count()) % + u" values were returned"); + } + + try + { + if constexpr (detail::IsOptional) + { + // we want to error on anything that is not nil|T, + // std::optional in sol means "give me a T or if it does not match nullopt" + if (result.get_type() == sol::type::nil) + { + return {}; + } + auto ret = result.get(); + + if (!ret) + { + auto t = type_name(); + return nonstd::expected_lite::make_unexpected( + u"Expected " % QLatin1String(t.data(), t.size()) % + u" to be returned but " % + qmagicenum::enumName(result.get_type()) % + u" was returned"); + } + return *ret; + } + else + { + auto ret = result.get>(); + + if (!ret) + { + auto t = type_name(); + return nonstd::expected_lite::make_unexpected( + u"Expected " % QLatin1String(t.data(), t.size()) % + u" to be returned but " % + qmagicenum::enumName(result.get_type()) % + u" was returned"); + } + return *ret; + } + } + catch (std::runtime_error &e) + { + return nonstd::expected_lite::make_unexpected( + QString::fromUtf8(e.what())); + } + // non other exceptions we let it explode + } +} + +// NOLINTNEXTLINE(cppcoreguidelines-macro-usage) +# define SOL_STACK_FUNCTIONS(TYPE) \ + bool sol_lua_check(sol::types, lua_State *L, int index, \ + std::function handler, \ + sol::stack::record &tracking); \ + TYPE sol_lua_get(sol::types, lua_State *L, int index, \ + sol::stack::record &tracking); \ + int sol_lua_push(sol::types, lua_State *L, const TYPE &value); + +SOL_STACK_FUNCTIONS(chatterino::lua::ThisPluginState) + +} // namespace chatterino::lua + +SOL_STACK_FUNCTIONS(QString) +SOL_STACK_FUNCTIONS(QStringList) +SOL_STACK_FUNCTIONS(QByteArray) + +# undef SOL_STACK_FUNCTIONS + +#endif diff --git a/src/controllers/plugins/api/ChannelRef.cpp b/src/controllers/plugins/api/ChannelRef.cpp index a57e60119..b9bced3a0 100644 --- a/src/controllers/plugins/api/ChannelRef.cpp +++ b/src/controllers/plugins/api/ChannelRef.cpp @@ -1,397 +1,224 @@ #ifdef CHATTERINO_HAVE_PLUGINS # include "controllers/plugins/api/ChannelRef.hpp" +# include "Application.hpp" # include "common/Channel.hpp" # include "controllers/commands/CommandController.hpp" -# include "controllers/plugins/LuaAPI.hpp" -# include "controllers/plugins/LuaUtilities.hpp" -# include "messages/MessageBuilder.hpp" +# include "controllers/plugins/SolTypes.hpp" # include "providers/twitch/TwitchChannel.hpp" # include "providers/twitch/TwitchIrcServer.hpp" -extern "C" { -# include -# include -} +# include -# include # include # include namespace chatterino::lua::api { -// NOLINTBEGIN(*vararg) -// NOLINTNEXTLINE(*-avoid-c-arrays) -static const luaL_Reg CHANNEL_REF_METHODS[] = { - {"is_valid", &ChannelRef::is_valid}, - {"get_name", &ChannelRef::get_name}, - {"get_type", &ChannelRef::get_type}, - {"get_display_name", &ChannelRef::get_display_name}, - {"send_message", &ChannelRef::send_message}, - {"add_system_message", &ChannelRef::add_system_message}, - {"is_twitch_channel", &ChannelRef::is_twitch_channel}, - - // Twitch - {"get_room_modes", &ChannelRef::get_room_modes}, - {"get_stream_status", &ChannelRef::get_stream_status}, - {"get_twitch_id", &ChannelRef::get_twitch_id}, - {"is_broadcaster", &ChannelRef::is_broadcaster}, - {"is_mod", &ChannelRef::is_mod}, - {"is_vip", &ChannelRef::is_vip}, - - // misc - {"__tostring", &ChannelRef::to_string}, - - // static - {"by_name", &ChannelRef::get_by_name}, - {"by_twitch_id", &ChannelRef::get_by_twitch_id}, - {nullptr, nullptr}, -}; - -void ChannelRef::createMetatable(lua_State *L) +ChannelRef::ChannelRef(const std::shared_ptr &chan) + : weak(chan) { - lua::StackGuard guard(L, 1); - - luaL_newmetatable(L, "c2.Channel"); - lua_pushstring(L, "__index"); - lua_pushvalue(L, -2); // clone metatable - lua_settable(L, -3); // metatable.__index = metatable - - // Generic IWeakResource stuff - lua_pushstring(L, "__gc"); - lua_pushcfunction( - L, (&WeakPtrUserData::destroy)); - lua_settable(L, -3); // metatable.__gc = WeakPtrUserData<...>::destroy - - luaL_setfuncs(L, CHANNEL_REF_METHODS, 0); } -ChannelPtr ChannelRef::getOrError(lua_State *L, bool expiredOk) +std::shared_ptr ChannelRef::strong() { - if (lua_gettop(L) < 1) + auto c = this->weak.lock(); + if (!c) { - luaL_error(L, "Called c2.Channel method without a channel object"); - return nullptr; + throw std::runtime_error( + "Expired c2.Channel used - use c2.Channel:is_valid() to " + "check validity"); } - if (lua_isuserdata(L, lua_gettop(L)) == 0) + return c; +} + +std::shared_ptr ChannelRef::twitch() +{ + auto c = std::dynamic_pointer_cast(this->weak.lock()); + if (!c) { - luaL_error( - L, "Called c2.Channel method with a non-userdata 'self' argument"); - return nullptr; + throw std::runtime_error( + "Expired or non-twitch c2.Channel used - use " + "c2.Channel:is_valid() and c2.Channe:is_twitch_channel()"); } - // luaL_checkudata is no-return if check fails - auto *checked = luaL_checkudata(L, lua_gettop(L), "c2.Channel"); - auto *data = - WeakPtrUserData::from(checked); - if (data == nullptr) - { - luaL_error(L, - "Called c2.Channel method with an invalid channel pointer"); - return nullptr; - } - lua_pop(L, 1); - if (data->target.expired()) - { - if (!expiredOk) + return c; +} + +bool ChannelRef::is_valid() +{ + return !this->weak.expired(); +} + +QString ChannelRef::get_name() +{ + return this->strong()->getName(); +} + +Channel::Type ChannelRef::get_type() +{ + return this->strong()->getType(); +} + +QString ChannelRef::get_display_name() +{ + return this->strong()->getDisplayName(); +} + +void ChannelRef::send_message(QString text, sol::variadic_args va) +{ + bool execCommands = [&] { + if (va.size() >= 1) { - luaL_error(L, - "Usage of expired c2.Channel object. Underlying " - "resource was freed. Use Channel:is_valid() to check"); + return va.get(); } - return nullptr; - } - return data->target.lock(); -} - -std::shared_ptr ChannelRef::getTwitchOrError(lua_State *L) -{ - auto ref = ChannelRef::getOrError(L); - auto ptr = dynamic_pointer_cast(ref); - if (ptr == nullptr) + return false; + }(); + text = text.replace('\n', ' '); + auto chan = this->strong(); + if (execCommands) { - luaL_error(L, - "c2.Channel Twitch-only operation on non-Twitch channel."); + text = getApp()->getCommands()->execCommand(text, chan, false); } - return ptr; + chan->sendMessage(text); } -int ChannelRef::is_valid(lua_State *L) +void ChannelRef::add_system_message(QString text) { - ChannelPtr that = ChannelRef::getOrError(L, true); - lua::push(L, that != nullptr); - return 1; + text = text.replace('\n', ' '); + this->strong()->addSystemMessage(text); } -int ChannelRef::get_name(lua_State *L) +bool ChannelRef::is_twitch_channel() { - ChannelPtr that = ChannelRef::getOrError(L); - lua::push(L, that->getName()); - return 1; + return this->strong()->isTwitchChannel(); } -int ChannelRef::get_type(lua_State *L) +sol::table ChannelRef::get_room_modes(sol::this_state state) { - ChannelPtr that = ChannelRef::getOrError(L); - lua::push(L, that->getType()); - return 1; + return toTable(state.L, *this->twitch()->accessRoomModes()); } -int ChannelRef::get_display_name(lua_State *L) +sol::table ChannelRef::get_stream_status(sol::this_state state) { - ChannelPtr that = ChannelRef::getOrError(L); - lua::push(L, that->getDisplayName()); - return 1; + return toTable(state.L, *this->twitch()->accessStreamStatus()); } -int ChannelRef::send_message(lua_State *L) +QString ChannelRef::get_twitch_id() { - if (lua_gettop(L) != 2 && lua_gettop(L) != 3) + return this->twitch()->roomId(); +} + +bool ChannelRef::is_broadcaster() +{ + return this->twitch()->isBroadcaster(); +} + +bool ChannelRef::is_mod() +{ + return this->twitch()->isMod(); +} + +bool ChannelRef::is_vip() +{ + return this->twitch()->isVip(); +} + +QString ChannelRef::to_string() +{ + auto chan = this->weak.lock(); + if (!chan) { - luaL_error(L, "Channel:send_message needs 1 or 2 arguments (message " - "text and optionally execute_commands flag)"); - return 0; + return ""; } - bool execcmds = false; - if (lua_gettop(L) == 3) + return QStringView(u"").arg(chan->getName()); +} + +std::optional ChannelRef::get_by_name(const QString &name) +{ + auto chan = getApp()->getTwitch()->getChannelOrEmpty(name); + if (chan->isEmpty()) { - if (!lua::pop(L, &execcmds)) + return std::nullopt; + } + return chan; +} + +std::optional ChannelRef::get_by_twitch_id(const QString &id) +{ + auto chan = getApp()->getTwitch()->getChannelOrEmptyByID(id); + if (chan->isEmpty()) + { + return std::nullopt; + } + return chan; +} + +void ChannelRef::createUserType(sol::table &c2) +{ + // clang-format off + c2.new_usertype( + "Channel", sol::no_constructor, + // meta methods + sol::meta_method::to_string, &ChannelRef::to_string, + + // Channel + "is_valid", &ChannelRef::is_valid, + "get_name",&ChannelRef::get_name, + "get_type", &ChannelRef::get_type, + "get_display_name", &ChannelRef::get_display_name, + "send_message", &ChannelRef::send_message, + "add_system_message", &ChannelRef::add_system_message, + "is_twitch_channel", &ChannelRef::is_twitch_channel, + + // TwitchChannel + "get_room_modes", &ChannelRef::get_room_modes, + "get_stream_status", &ChannelRef::get_stream_status, + "get_twitch_id", &ChannelRef::get_twitch_id, + "is_broadcaster", &ChannelRef::is_broadcaster, + "is_mod", &ChannelRef::is_mod, + "is_vip", &ChannelRef::is_vip, + + // static + "by_name", &ChannelRef::get_by_name, + "by_twitch_id", &ChannelRef::get_by_twitch_id + ); + // clang-format on +} + +sol::table toTable(lua_State *L, const TwitchChannel::RoomModes &modes) +{ + auto maybe = [](int value) { + if (value >= 0) { - luaL_error(L, "cannot get execute_commands (2nd argument of " - "Channel:send_message)"); - return 0; + return std::optional{value}; } - } - - QString text; - if (!lua::pop(L, &text)) - { - luaL_error(L, "cannot get text (1st argument of Channel:send_message)"); - return 0; - } - - ChannelPtr that = ChannelRef::getOrError(L); - - text = text.replace('\n', ' '); - if (execcmds) - { - text = getApp()->getCommands()->execCommand(text, that, false); - } - that->sendMessage(text); - return 0; -} - -int ChannelRef::add_system_message(lua_State *L) -{ - // needs to account for the hidden self argument - if (lua_gettop(L) != 2) - { - luaL_error( - L, "Channel:add_system_message needs exactly 1 argument (message " - "text)"); - return 0; - } - - QString text; - if (!lua::pop(L, &text)) - { - luaL_error( - L, "cannot get text (1st argument of Channel:add_system_message)"); - return 0; - } - ChannelPtr that = ChannelRef::getOrError(L); - text = text.replace('\n', ' '); - that->addSystemMessage(text); - return 0; -} - -int ChannelRef::is_twitch_channel(lua_State *L) -{ - ChannelPtr that = ChannelRef::getOrError(L); - lua::push(L, that->isTwitchChannel()); - return 1; -} - -int ChannelRef::get_room_modes(lua_State *L) -{ - auto tc = ChannelRef::getTwitchOrError(L); - const auto m = tc->accessRoomModes(); - const auto modes = LuaRoomModes{ - .unique_chat = m->r9k, - .subscriber_only = m->submode, - .emotes_only = m->emoteOnly, - .follower_only = (m->followerOnly == -1) - ? std::nullopt - : std::optional(m->followerOnly), - .slow_mode = - (m->slowMode == 0) ? std::nullopt : std::optional(m->slowMode), - + return std::optional{}; }; - lua::push(L, modes); - return 1; + // clang-format off + return sol::table::create_with(L, + "subscriber_only", modes.submode, + "unique_chat", modes.r9k, + "emotes_only", modes.emoteOnly, + "follower_only", maybe(modes.followerOnly), + "slow_mode", maybe(modes.slowMode) + ); + // clang-format on } -int ChannelRef::get_stream_status(lua_State *L) +sol::table toTable(lua_State *L, const TwitchChannel::StreamStatus &status) { - auto tc = ChannelRef::getTwitchOrError(L); - const auto s = tc->accessStreamStatus(); - const auto status = LuaStreamStatus{ - .live = s->live, - .viewer_count = static_cast(s->viewerCount), - .uptime = s->uptimeSeconds, - .title = s->title, - .game_name = s->game, - .game_id = s->gameId, - }; - lua::push(L, status); - return 1; + // clang-format off + return sol::table::create_with(L, + "live", status.live, + "viewer_count", status.viewerCount, + "title", status.title, + "game_name", status.game, + "game_id", status.gameId, + "uptime", status.uptimeSeconds + ); + // clang-format on } -int ChannelRef::get_twitch_id(lua_State *L) -{ - auto tc = ChannelRef::getTwitchOrError(L); - lua::push(L, tc->roomId()); - return 1; -} - -int ChannelRef::is_broadcaster(lua_State *L) -{ - auto tc = ChannelRef::getTwitchOrError(L); - lua::push(L, tc->isBroadcaster()); - return 1; -} - -int ChannelRef::is_mod(lua_State *L) -{ - auto tc = ChannelRef::getTwitchOrError(L); - lua::push(L, tc->isMod()); - return 1; -} - -int ChannelRef::is_vip(lua_State *L) -{ - auto tc = ChannelRef::getTwitchOrError(L); - lua::push(L, tc->isVip()); - return 1; -} - -int ChannelRef::get_by_name(lua_State *L) -{ - if (lua_gettop(L) != 2) - { - luaL_error(L, "Channel.by_name needs exactly 2 arguments (channel " - "name and platform)"); - lua_pushnil(L); - return 1; - } - LPlatform platform{}; - if (!lua::pop(L, &platform)) - { - luaL_error(L, "cannot get platform (2nd argument of Channel.by_name, " - "expected a string)"); - lua_pushnil(L); - return 1; - } - QString name; - if (!lua::pop(L, &name)) - { - luaL_error(L, - "cannot get channel name (1st argument of Channel.by_name, " - "expected a string)"); - lua_pushnil(L); - return 1; - } - auto chn = getApp()->getTwitch()->getChannelOrEmpty(name); - lua::push(L, chn); - return 1; -} - -int ChannelRef::get_by_twitch_id(lua_State *L) -{ - if (lua_gettop(L) != 1) - { - luaL_error( - L, "Channel.by_twitch_id needs exactly 1 arguments (channel owner " - "id)"); - lua_pushnil(L); - return 1; - } - QString id; - if (!lua::pop(L, &id)) - { - luaL_error(L, - "cannot get channel name (1st argument of Channel.by_name, " - "expected a string)"); - lua_pushnil(L); - return 1; - } - auto chn = getApp()->getTwitch()->getChannelOrEmptyByID(id); - - lua::push(L, chn); - return 1; -} - -int ChannelRef::to_string(lua_State *L) -{ - ChannelPtr that = ChannelRef::getOrError(L, true); - if (that == nullptr) - { - lua_pushstring(L, ""); - return 1; - } - QString formated = QString("").arg(that->getName()); - lua::push(L, formated); - return 1; -} } // namespace chatterino::lua::api -// NOLINTEND(*vararg) -// -namespace chatterino::lua { -StackIdx push(lua_State *L, const api::LuaRoomModes &modes) -{ - auto out = lua::pushEmptyTable(L, 6); -# define PUSH(field) \ - lua::push(L, modes.field); \ - lua_setfield(L, out, #field) - PUSH(unique_chat); - PUSH(subscriber_only); - PUSH(emotes_only); - PUSH(follower_only); - PUSH(slow_mode); -# undef PUSH - return out; -} -StackIdx push(lua_State *L, const api::LuaStreamStatus &status) -{ - auto out = lua::pushEmptyTable(L, 6); -# define PUSH(field) \ - lua::push(L, status.field); \ - lua_setfield(L, out, #field) - PUSH(live); - PUSH(viewer_count); - PUSH(uptime); - PUSH(title); - PUSH(game_name); - PUSH(game_id); -# undef PUSH - return out; -} - -StackIdx push(lua_State *L, ChannelPtr chn) -{ - using namespace chatterino::lua::api; - - if (chn->isEmpty()) - { - lua_pushnil(L); - return lua_gettop(L); - } - WeakPtrUserData::create( - L, chn->weak_from_this()); - luaL_getmetatable(L, "c2.Channel"); - lua_setmetatable(L, -2); - return lua_gettop(L); -} - -} // namespace chatterino::lua #endif diff --git a/src/controllers/plugins/api/ChannelRef.hpp b/src/controllers/plugins/api/ChannelRef.hpp index 32e1946ab..9d4455739 100644 --- a/src/controllers/plugins/api/ChannelRef.hpp +++ b/src/controllers/plugins/api/ChannelRef.hpp @@ -1,48 +1,24 @@ #pragma once #ifdef CHATTERINO_HAVE_PLUGINS # include "common/Channel.hpp" -# include "controllers/plugins/LuaUtilities.hpp" -# include "controllers/plugins/PluginController.hpp" # include "providers/twitch/TwitchChannel.hpp" -# include +# include namespace chatterino::lua::api { // NOLINTBEGIN(readability-identifier-naming) /** - * This enum describes a platform for the purpose of searching for a channel. - * Currently only Twitch is supported because identifying IRC channels is tricky. - * @exposeenum c2.Platform + * @includefile providers/twitch/TwitchChannel.hpp */ -enum class LPlatform { - Twitch, - //IRC, -}; /** * @lua@class c2.Channel */ struct ChannelRef { - static void createMetatable(lua_State *L); - friend class chatterino::PluginController; - - /** - * @brief Get the content of the top object on Lua stack, usually first argument to function as a ChannelPtr. - * If the object given is not a userdatum or the pointer inside that - * userdatum doesn't point to a Channel, a lua error is thrown. - * - * @param expiredOk Should an expired return nullptr instead of erroring - */ - static ChannelPtr getOrError(lua_State *L, bool expiredOk = false); - - /** - * @brief Casts the result of getOrError to std::shared_ptr - * if that fails thows a lua error. - */ - static std::shared_ptr getTwitchOrError(lua_State *L); - public: + ChannelRef(const std::shared_ptr &chan); + /** * Returns true if the channel this object points to is valid. * If the object expired, returns false @@ -51,7 +27,7 @@ public: * @lua@return boolean success * @exposed c2.Channel:is_valid */ - static int is_valid(lua_State *L); + bool is_valid(); /** * Gets the channel's name. This is the lowercase login name. @@ -59,7 +35,7 @@ public: * @lua@return string name * @exposed c2.Channel:get_name */ - static int get_name(lua_State *L); + QString get_name(); /** * Gets the channel's type @@ -67,7 +43,7 @@ public: * @lua@return c2.ChannelType * @exposed c2.Channel:get_type */ - static int get_type(lua_State *L); + Channel::Type get_type(); /** * Get the channel owner's display name. This may contain non-lowercase ascii characters. @@ -75,17 +51,17 @@ public: * @lua@return string name * @exposed c2.Channel:get_display_name */ - static int get_display_name(lua_State *L); + QString get_display_name(); /** * Sends a message to the target channel. * Note that this does not execute client-commands. * * @lua@param message string - * @lua@param execute_commands boolean Should commands be run on the text? + * @lua@param execute_commands? boolean Should commands be run on the text? * @exposed c2.Channel:send_message */ - static int send_message(lua_State *L); + void send_message(QString text, sol::variadic_args va); /** * Adds a system message client-side @@ -93,7 +69,7 @@ public: * @lua@param message string * @exposed c2.Channel:add_system_message */ - static int add_system_message(lua_State *L); + void add_system_message(QString text); /** * Returns true for twitch channels. @@ -103,7 +79,7 @@ public: * @lua@return boolean * @exposed c2.Channel:is_twitch_channel */ - static int is_twitch_channel(lua_State *L); + bool is_twitch_channel(); /** * Twitch Channel specific functions @@ -115,7 +91,7 @@ public: * @lua@return RoomModes * @exposed c2.Channel:get_room_modes */ - static int get_room_modes(lua_State *L); + sol::table get_room_modes(sol::this_state state); /** * Returns a copy of the stream status. @@ -123,7 +99,7 @@ public: * @lua@return StreamStatus * @exposed c2.Channel:get_stream_status */ - static int get_stream_status(lua_State *L); + sol::table get_stream_status(sol::this_state state); /** * Returns the Twitch user ID of the owner of the channel. @@ -131,7 +107,7 @@ public: * @lua@return string * @exposed c2.Channel:get_twitch_id */ - static int get_twitch_id(lua_State *L); + QString get_twitch_id(); /** * Returns true if the channel is a Twitch channel and the user owns it @@ -139,7 +115,7 @@ public: * @lua@return boolean * @exposed c2.Channel:is_broadcaster */ - static int is_broadcaster(lua_State *L); + bool is_broadcaster(); /** * Returns true if the channel is a Twitch channel and the user is a moderator in the channel @@ -148,7 +124,7 @@ public: * @lua@return boolean * @exposed c2.Channel:is_mod */ - static int is_mod(lua_State *L); + bool is_mod(); /** * Returns true if the channel is a Twitch channel and the user is a VIP in the channel @@ -157,7 +133,7 @@ public: * @lua@return boolean * @exposed c2.Channel:is_vip */ - static int is_vip(lua_State *L); + bool is_vip(); /** * Misc @@ -167,7 +143,7 @@ public: * @lua@return string * @exposed c2.Channel:__tostring */ - static int to_string(lua_State *L); + QString to_string(); /** * Static functions @@ -184,11 +160,10 @@ public: * - /automod * * @lua@param name string Which channel are you looking for? - * @lua@param platform c2.Platform Where to search for the channel? * @lua@return c2.Channel? * @exposed c2.Channel.by_name */ - static int get_by_name(lua_State *L); + static std::optional get_by_name(const QString &name); /** * Finds a channel by the Twitch user ID of its owner. @@ -197,79 +172,24 @@ public: * @lua@return c2.Channel? * @exposed c2.Channel.by_twitch_id */ - static int get_by_twitch_id(lua_State *L); -}; + static std::optional get_by_twitch_id(const QString &id); -// This is a copy of the TwitchChannel::RoomModes structure, except it uses nicer optionals -/** - * @lua@class RoomModes - */ -struct LuaRoomModes { - /** - * @lua@field unique_chat boolean You might know this as r9kbeta or robot9000. - */ - bool unique_chat = false; + static void createUserType(sol::table &c2); - /** - * @lua@field subscriber_only boolean - */ - bool subscriber_only = false; +private: + std::weak_ptr weak; - /** - * @lua@field emotes_only boolean Whether or not text is allowed in messages. Note that "emotes" here only means Twitch emotes, not Unicode emoji, nor 3rd party text-based emotes - */ - bool emotes_only = false; + /// Locks the weak pointer and throws if the pointer expired + std::shared_ptr strong(); - /** - * @lua@field follower_only number? Time in minutes you need to follow to chat or nil. - */ - std::optional follower_only; - /** - * @lua@field slow_mode number? Time in seconds you need to wait before sending messages or nil. - */ - std::optional slow_mode; -}; - -/** - * @lua@class StreamStatus - */ -struct LuaStreamStatus { - /** - * @lua@field live boolean - */ - bool live = false; - - /** - * @lua@field viewer_count number - */ - int viewer_count = 0; - - /** - * @lua@field uptime number Seconds since the stream started. - */ - int uptime = 0; - - /** - * @lua@field title string Stream title or last stream title - */ - QString title; - - /** - * @lua@field game_name string - */ - QString game_name; - - /** - * @lua@field game_id string - */ - QString game_id; + /// Locks the weak pointer and throws if the pointer is invalid + std::shared_ptr twitch(); }; // NOLINTEND(readability-identifier-naming) + +sol::table toTable(lua_State *L, const TwitchChannel::RoomModes &modes); +sol::table toTable(lua_State *L, const TwitchChannel::StreamStatus &status); + } // namespace chatterino::lua::api -namespace chatterino::lua { -StackIdx push(lua_State *L, const api::LuaRoomModes &modes); -StackIdx push(lua_State *L, const api::LuaStreamStatus &status); -StackIdx push(lua_State *L, ChannelPtr chn); -} // namespace chatterino::lua #endif diff --git a/src/controllers/plugins/api/EventType.hpp b/src/controllers/plugins/api/EventType.hpp new file mode 100644 index 000000000..73b5df135 --- /dev/null +++ b/src/controllers/plugins/api/EventType.hpp @@ -0,0 +1,14 @@ +#pragma once +#ifdef CHATTERINO_HAVE_PLUGINS + +namespace chatterino::lua::api { + +/** + * @exposeenum c2.EventType + */ +enum class EventType { + CompletionRequested, +}; + +} // namespace chatterino::lua::api +#endif diff --git a/src/controllers/plugins/api/HTTPRequest.cpp b/src/controllers/plugins/api/HTTPRequest.cpp index eba2773ad..a1a97f616 100644 --- a/src/controllers/plugins/api/HTTPRequest.cpp +++ b/src/controllers/plugins/api/HTTPRequest.cpp @@ -6,402 +6,173 @@ # include "common/network/NetworkRequest.hpp" # include "common/network/NetworkResult.hpp" # include "controllers/plugins/api/HTTPResponse.hpp" -# include "controllers/plugins/LuaAPI.hpp" # include "controllers/plugins/LuaUtilities.hpp" +# include "controllers/plugins/PluginController.hpp" +# include "controllers/plugins/SolTypes.hpp" # include "util/DebugCount.hpp" -extern "C" { # include # include -} +# include +# include # include # include +# include +# include +# include +# include +# include -# include +# include +# include # include +# include namespace chatterino::lua::api { -// NOLINTBEGIN(*vararg) -// NOLINTNEXTLINE(*-avoid-c-arrays) -static const luaL_Reg HTTP_REQUEST_METHODS[] = { - {"on_success", &HTTPRequest::on_success_wrap}, - {"on_error", &HTTPRequest::on_error_wrap}, - {"finally", &HTTPRequest::finally_wrap}, - {"execute", &HTTPRequest::execute_wrap}, - {"set_timeout", &HTTPRequest::set_timeout_wrap}, - {"set_payload", &HTTPRequest::set_payload_wrap}, - {"set_header", &HTTPRequest::set_header_wrap}, - // static - {"create", &HTTPRequest::create}, - {nullptr, nullptr}, -}; - -std::shared_ptr HTTPRequest::getOrError(lua_State *L, - StackIdx where) +void HTTPRequest::createUserType(sol::table &c2) { - if (lua_gettop(L) < 1) - { - // The nullptr is there just to appease the compiler, luaL_error is no return - luaL_error(L, "Called c2.HTTPRequest method without a request object"); - return nullptr; - } - if (lua_isuserdata(L, where) == 0) - { - luaL_error( - L, - "Called c2.HTTPRequest method with a non-userdata 'self' argument"); - return nullptr; - } - // luaL_checkudata is no-return if check fails - auto *checked = luaL_checkudata(L, where, "c2.HTTPRequest"); - auto *data = - SharedPtrUserData::from( - checked); - if (data == nullptr) - { - luaL_error(L, "Called c2.HTTPRequest method with an invalid pointer"); - return nullptr; - } - lua_remove(L, where); - if (data->target == nullptr) - { - luaL_error( - L, "Internal error: SharedPtrUserData::target was null. This is a Chatterino bug!"); - return nullptr; - } - if (data->target->done) - { - luaL_error(L, "This c2.HTTPRequest has already been executed!"); - return nullptr; - } - return data->target; + c2.new_usertype( // + "HTTPRequest", sol::no_constructor, // + sol::meta_method::to_string, &HTTPRequest::to_string, // + + "on_success", &HTTPRequest::on_success, // + "on_error", &HTTPRequest::on_error, // + "finally", &HTTPRequest::finally, // + + "set_timeout", &HTTPRequest::set_timeout, // + "set_payload", &HTTPRequest::set_payload, // + "set_header", &HTTPRequest::set_header, // + "execute", &HTTPRequest::execute, // + + "create", &HTTPRequest::create // + ); } -void HTTPRequest::createMetatable(lua_State *L) +void HTTPRequest::on_success(sol::protected_function func) { - lua::StackGuard guard(L, 1); - - luaL_newmetatable(L, "c2.HTTPRequest"); - lua_pushstring(L, "__index"); - lua_pushvalue(L, -2); // clone metatable - lua_settable(L, -3); // metatable.__index = metatable - - // Generic ISharedResource stuff - lua_pushstring(L, "__gc"); - lua_pushcfunction(L, (&SharedPtrUserData::destroy)); - lua_settable(L, -3); // metatable.__gc = SharedPtrUserData<...>::destroy - - luaL_setfuncs(L, HTTP_REQUEST_METHODS, 0); + this->cbSuccess = std::make_optional(func); } -int HTTPRequest::on_success_wrap(lua_State *L) +void HTTPRequest::on_error(sol::protected_function func) { - lua::StackGuard guard(L, -2); - auto ptr = HTTPRequest::getOrError(L, 1); - return ptr->on_success(L); + this->cbError = std::make_optional(func); } -int HTTPRequest::on_success(lua_State *L) +void HTTPRequest::set_timeout(int timeout) { - auto top = lua_gettop(L); - if (top != 1) - { - return luaL_error( - L, "HTTPRequest:on_success needs 1 argument (a callback " - "that takes an HTTPResult and doesn't return anything)"); - } - if (!lua_isfunction(L, top)) - { - return luaL_error( - L, "HTTPRequest:on_success needs 1 argument (a callback " - "that takes an HTTPResult and doesn't return anything)"); - } - auto shared = this->pushPrivate(L); - lua_pushvalue(L, -2); - lua_setfield(L, shared, "success"); // this deletes the function copy - lua_pop(L, 2); // delete the table and function original - return 0; + this->timeout_ = timeout; } -int HTTPRequest::on_error_wrap(lua_State *L) +void HTTPRequest::finally(sol::protected_function func) { - lua::StackGuard guard(L, -2); - auto ptr = HTTPRequest::getOrError(L, 1); - return ptr->on_error(L); + this->cbFinally = std::make_optional(func); } -int HTTPRequest::on_error(lua_State *L) +void HTTPRequest::set_payload(QByteArray payload) { - auto top = lua_gettop(L); - if (top != 1) - { - return luaL_error( - L, "HTTPRequest:on_error needs 1 argument (a callback " - "that takes an HTTPResult and doesn't return anything)"); - } - if (!lua_isfunction(L, top)) - { - return luaL_error( - L, "HTTPRequest:on_error needs 1 argument (a callback " - "that takes an HTTPResult and doesn't return anything)"); - } - auto shared = this->pushPrivate(L); - lua_pushvalue(L, -2); - lua_setfield(L, shared, "error"); // this deletes the function copy - lua_pop(L, 2); // delete the table and function original - return 0; + this->req_ = std::move(this->req_).payload(payload); } -int HTTPRequest::set_timeout_wrap(lua_State *L) +// name and value may be random bytes +void HTTPRequest::set_header(QByteArray name, QByteArray value) { - lua::StackGuard guard(L, -2); - auto ptr = HTTPRequest::getOrError(L, 1); - return ptr->set_timeout(L); + this->req_ = std::move(this->req_).header(name, value); } -int HTTPRequest::set_timeout(lua_State *L) +std::shared_ptr HTTPRequest::create(sol::this_state L, + NetworkRequestType method, + QString url) { - auto top = lua_gettop(L); - if (top != 1) - { - return luaL_error( - L, "HTTPRequest:set_timeout needs 1 argument (a number of " - "milliseconds after which the request will time out)"); - } - - int temporary = -1; - if (!lua::pop(L, &temporary)) - { - return luaL_error( - L, "HTTPRequest:set_timeout failed to get timeout, expected a " - "positive integer"); - } - if (temporary <= 0) - { - return luaL_error( - L, "HTTPRequest:set_timeout failed to get timeout, expected a " - "positive integer"); - } - this->timeout_ = temporary; - return 0; -} - -int HTTPRequest::finally_wrap(lua_State *L) -{ - lua::StackGuard guard(L, -2); - auto ptr = HTTPRequest::getOrError(L, 1); - return ptr->finally(L); -} - -int HTTPRequest::finally(lua_State *L) -{ - auto top = lua_gettop(L); - if (top != 1) - { - return luaL_error(L, "HTTPRequest:finally needs 1 argument (a callback " - "that takes nothing and doesn't return anything)"); - } - if (!lua_isfunction(L, top)) - { - return luaL_error(L, "HTTPRequest:finally needs 1 argument (a callback " - "that takes nothing and doesn't return anything)"); - } - auto shared = this->pushPrivate(L); - lua_pushvalue(L, -2); - lua_setfield(L, shared, "finally"); // this deletes the function copy - lua_pop(L, 2); // delete the table and function original - return 0; -} - -int HTTPRequest::set_payload_wrap(lua_State *L) -{ - lua::StackGuard guard(L, -2); - auto ptr = HTTPRequest::getOrError(L, 1); - return ptr->set_payload(L); -} - -int HTTPRequest::set_payload(lua_State *L) -{ - auto top = lua_gettop(L); - if (top != 1) - { - return luaL_error( - L, "HTTPRequest:set_payload needs 1 argument (a string payload)"); - } - - std::string temporary; - if (!lua::pop(L, &temporary)) - { - return luaL_error( - L, "HTTPRequest:set_payload failed to get payload, expected a " - "string"); - } - this->req_ = - std::move(this->req_).payload(QByteArray::fromStdString(temporary)); - return 0; -} - -int HTTPRequest::set_header_wrap(lua_State *L) -{ - lua::StackGuard guard(L, -3); - auto ptr = HTTPRequest::getOrError(L, 1); - return ptr->set_header(L); -} - -int HTTPRequest::set_header(lua_State *L) -{ - auto top = lua_gettop(L); - if (top != 2) - { - return luaL_error( - L, "HTTPRequest:set_header needs 2 arguments (a header name " - "and a value)"); - } - - std::string value; - if (!lua::pop(L, &value)) - { - return luaL_error( - L, "cannot get value (2nd argument of HTTPRequest:set_header)"); - } - std::string name; - if (!lua::pop(L, &name)) - { - return luaL_error( - L, "cannot get name (1st argument of HTTPRequest:set_header)"); - } - this->req_ = std::move(this->req_) - .header(QByteArray::fromStdString(name), - QByteArray::fromStdString(value)); - return 0; -} - -int HTTPRequest::create(lua_State *L) -{ - lua::StackGuard guard(L, -1); - if (lua_gettop(L) != 2) - { - return luaL_error( - L, "HTTPRequest.create needs exactly 2 arguments (method " - "and url)"); - } - QString url; - if (!lua::pop(L, &url)) - { - return luaL_error(L, - "cannot get url (2nd argument of HTTPRequest.create, " - "expected a string)"); - } auto parsedurl = QUrl(url); if (!parsedurl.isValid()) { - return luaL_error( - L, "cannot parse url (2nd argument of HTTPRequest.create, " - "got invalid url in argument)"); - } - NetworkRequestType method{}; - if (!lua::pop(L, &method)) - { - return luaL_error( - L, "cannot get method (1st argument of HTTPRequest.create, " - "expected a string)"); + throw std::runtime_error( + "cannot parse url (2nd argument of HTTPRequest.create, " + "got invalid url in argument)"); } auto *pl = getApp()->getPlugins()->getPluginByStatePtr(L); if (!pl->hasHTTPPermissionFor(parsedurl)) { - return luaL_error( - L, "Plugin does not have permission to send HTTP requests " - "to this URL"); + throw std::runtime_error( + "Plugin does not have permission to send HTTP requests " + "to this URL"); } NetworkRequest r(parsedurl, method); - lua::push( - L, std::make_shared(ConstructorAccessTag{}, std::move(r))); - return 1; + return std::make_shared(ConstructorAccessTag{}, std::move(r)); } -int HTTPRequest::execute_wrap(lua_State *L) +void HTTPRequest::execute(sol::this_state L) { - auto ptr = HTTPRequest::getOrError(L, 1); - return ptr->execute(L); -} - -int HTTPRequest::execute(lua_State *L) -{ - auto shared = this->shared_from_this(); + if (this->done) + { + throw std::runtime_error( + "Cannot execute this c2.HTTPRequest, it was executed already!"); + } this->done = true; + + // this keeps the object alive even if Lua were to forget about it, + auto hack = this->weak_from_this(); + auto *pl = getApp()->getPlugins()->getPluginByStatePtr(L); + pl->httpRequests.push_back(this->shared_from_this()); + std::move(this->req_) - .onSuccess([shared, L](const NetworkResult &res) { + .onSuccess([L, hack](const NetworkResult &res) { + auto self = hack.lock(); + if (!self) + { + return; + } + if (!self->cbSuccess.has_value()) + { + return; + } lua::StackGuard guard(L); - auto *thread = lua_newthread(L); - - auto priv = shared->pushPrivate(thread); - lua_getfield(thread, priv, "success"); - auto cb = lua_gettop(thread); - if (lua_isfunction(thread, cb)) - { - lua::push(thread, std::make_shared(res)); - // one arg, no return, no msgh - lua_pcall(thread, 1, 0, 0); - } - else - { - lua_pop(thread, 1); // remove callback - } - lua_closethread(thread, nullptr); - lua_pop(L, 1); // remove thread from L + (*self->cbSuccess)(HTTPResponse(res)); + self->cbSuccess = std::nullopt; }) - .onError([shared, L](const NetworkResult &res) { + .onError([L, hack](const NetworkResult &res) { + auto self = hack.lock(); + if (!self) + { + return; + } + if (!self->cbError.has_value()) + { + return; + } lua::StackGuard guard(L); - auto *thread = lua_newthread(L); - - auto priv = shared->pushPrivate(thread); - lua_getfield(thread, priv, "error"); - auto cb = lua_gettop(thread); - if (lua_isfunction(thread, cb)) - { - lua::push(thread, std::make_shared(res)); - // one arg, no return, no msgh - lua_pcall(thread, 1, 0, 0); - } - else - { - lua_pop(thread, 1); // remove callback - } - lua_closethread(thread, nullptr); - lua_pop(L, 1); // remove thread from L + (*self->cbError)(HTTPResponse(res)); + self->cbError = std::nullopt; }) - .finally([shared, L]() { + .finally([L, hack]() { + auto self = hack.lock(); + if (!self) + { + // this could happen if the plugin was deleted + return; + } + auto *pl = getApp()->getPlugins()->getPluginByStatePtr(L); + for (auto it = pl->httpRequests.begin(); + it < pl->httpRequests.end(); it++) + { + if (*it == self) + { + pl->httpRequests.erase(it); + break; + } + } + + if (!self->cbFinally.has_value()) + { + return; + } lua::StackGuard guard(L); - auto *thread = lua_newthread(L); - - auto priv = shared->pushPrivate(thread); - lua_getfield(thread, priv, "finally"); - auto cb = lua_gettop(thread); - if (lua_isfunction(thread, cb)) - { - // no args, no return, no msgh - lua_pcall(thread, 0, 0, 0); - } - else - { - lua_pop(thread, 1); // remove callback - } - // remove our private data - lua_pushnil(thread); - lua_setfield(thread, LUA_REGISTRYINDEX, - shared->privateKey.toStdString().c_str()); - lua_closethread(thread, nullptr); - lua_pop(L, 1); // remove thread from L - - // we removed our private table, forget the key for it - shared->privateKey = QString(); + (*self->cbFinally)(); + self->cbFinally = std::nullopt; }) .timeout(this->timeout_) .execute(); - return 0; } HTTPRequest::HTTPRequest(HTTPRequest::ConstructorAccessTag /*ignored*/, @@ -418,34 +189,10 @@ HTTPRequest::~HTTPRequest() // but that's better than accessing a possibly invalid lua_State pointer. } -StackIdx HTTPRequest::pushPrivate(lua_State *L) +QString HTTPRequest::to_string() { - if (this->privateKey.isEmpty()) - { - this->privateKey = QString("HTTPRequestPrivate%1") - .arg(QRandomGenerator::system()->generate()); - pushEmptyTable(L, 4); - lua_setfield(L, LUA_REGISTRYINDEX, - this->privateKey.toStdString().c_str()); - } - lua_getfield(L, LUA_REGISTRYINDEX, this->privateKey.toStdString().c_str()); - return lua_gettop(L); + return ""; } -// NOLINTEND(*vararg) } // namespace chatterino::lua::api - -namespace chatterino::lua { - -StackIdx push(lua_State *L, std::shared_ptr request) -{ - using namespace chatterino::lua::api; - - SharedPtrUserData::create( - L, std::move(request)); - luaL_getmetatable(L, "c2.HTTPRequest"); - lua_setmetatable(L, -2); - return lua_gettop(L); -} -} // namespace chatterino::lua #endif diff --git a/src/controllers/plugins/api/HTTPRequest.hpp b/src/controllers/plugins/api/HTTPRequest.hpp index 955a3cd2d..6fe3b97be 100644 --- a/src/controllers/plugins/api/HTTPRequest.hpp +++ b/src/controllers/plugins/api/HTTPRequest.hpp @@ -2,10 +2,16 @@ #ifdef CHATTERINO_HAVE_PLUGINS # include "common/network/NetworkRequest.hpp" # include "controllers/plugins/LuaUtilities.hpp" -# include "controllers/plugins/PluginController.hpp" + +# include +# include # include +namespace chatterino { +class PluginController; +} // namespace chatterino + namespace chatterino::lua::api { // NOLINTBEGIN(readability-identifier-naming) @@ -33,33 +39,19 @@ public: private: NetworkRequest req_; - static void createMetatable(lua_State *L); + static void createUserType(sol::table &c2); friend class chatterino::PluginController; - /** - * @brief Get the content of the top object on Lua stack, usually the first argument as an HTTPRequest - * - * If the object given is not a userdatum or the pointer inside that - * userdatum doesn't point to a HTTPRequest, a lua error is thrown. - * - * This function always returns a non-null pointer. - */ - static std::shared_ptr getOrError(lua_State *L, - StackIdx where = -1); - /** - * Pushes the private table onto the lua stack. - * - * This might create it if it doesn't exist. - */ - StackIdx pushPrivate(lua_State *L); - // This is the key in the registry the private table it held at (if it exists) // This might be a null QString if the request has already been executed or // the table wasn't created yet. - QString privateKey; int timeout_ = 10'000; bool done = false; + std::optional cbSuccess; + std::optional cbError; + std::optional cbFinally; + public: // These functions are wrapped so data can be accessed more easily. When a call from Lua comes in: // - the static wrapper function is called @@ -72,8 +64,7 @@ public: * @lua@param callback HTTPCallback Function to call when the HTTP request succeeds * @exposed HTTPRequest:on_success */ - static int on_success_wrap(lua_State *L); - int on_success(lua_State *L); + void on_success(sol::protected_function func); /** * Sets the failure callback @@ -81,8 +72,7 @@ public: * @lua@param callback HTTPCallback Function to call when the HTTP request fails or returns a non-ok status * @exposed HTTPRequest:on_error */ - static int on_error_wrap(lua_State *L); - int on_error(lua_State *L); + void on_error(sol::protected_function func); /** * Sets the finally callback @@ -90,8 +80,7 @@ public: * @lua@param callback fun(): nil Function to call when the HTTP request finishes * @exposed HTTPRequest:finally */ - static int finally_wrap(lua_State *L); - int finally(lua_State *L); + void finally(sol::protected_function func); /** * Sets the timeout @@ -99,8 +88,7 @@ public: * @lua@param timeout integer How long in milliseconds until the times out * @exposed HTTPRequest:set_timeout */ - static int set_timeout_wrap(lua_State *L); - int set_timeout(lua_State *L); + void set_timeout(int timeout); /** * Sets the request payload @@ -108,8 +96,7 @@ public: * @lua@param data string * @exposed HTTPRequest:set_payload */ - static int set_payload_wrap(lua_State *L); - int set_payload(lua_State *L); + void set_payload(QByteArray payload); /** * Sets a header in the request @@ -118,16 +105,19 @@ public: * @lua@param value string * @exposed HTTPRequest:set_header */ - static int set_header_wrap(lua_State *L); - int set_header(lua_State *L); + void set_header(QByteArray name, QByteArray value); /** * Executes the HTTP request * * @exposed HTTPRequest:execute */ - static int execute_wrap(lua_State *L); - int execute(lua_State *L); + void execute(sol::this_state L); + /** + * @lua@return string + * @exposed HTTPRequest:__tostring + */ + QString to_string(); /** * Static functions @@ -142,7 +132,9 @@ public: * @lua@return HTTPRequest * @exposed HTTPRequest.create */ - static int create(lua_State *L); + static std::shared_ptr create(sol::this_state L, + NetworkRequestType method, + QString url); }; // NOLINTEND(readability-identifier-naming) diff --git a/src/controllers/plugins/api/HTTPResponse.cpp b/src/controllers/plugins/api/HTTPResponse.cpp index f6d6ea1df..e18d7bf1f 100644 --- a/src/controllers/plugins/api/HTTPResponse.cpp +++ b/src/controllers/plugins/api/HTTPResponse.cpp @@ -2,77 +2,28 @@ # include "controllers/plugins/api/HTTPResponse.hpp" # include "common/network/NetworkResult.hpp" -# include "controllers/plugins/LuaAPI.hpp" +# include "controllers/plugins/SolTypes.hpp" # include "util/DebugCount.hpp" -extern "C" { # include -} +# include +# include + # include namespace chatterino::lua::api { -// NOLINTBEGIN(*vararg) -// NOLINTNEXTLINE(*-avoid-c-arrays) -static const luaL_Reg HTTP_RESPONSE_METHODS[] = { - {"data", &HTTPResponse::data_wrap}, - {"status", &HTTPResponse::status_wrap}, - {"error", &HTTPResponse::error_wrap}, - {nullptr, nullptr}, -}; -void HTTPResponse::createMetatable(lua_State *L) +void HTTPResponse::createUserType(sol::table &c2) { - lua::StackGuard guard(L, 1); + c2.new_usertype( // + "HTTPResponse", sol::no_constructor, + // metamethods + sol::meta_method::to_string, &HTTPResponse::to_string, // - luaL_newmetatable(L, "c2.HTTPResponse"); - lua_pushstring(L, "__index"); - lua_pushvalue(L, -2); // clone metatable - lua_settable(L, -3); // metatable.__index = metatable - - // Generic ISharedResource stuff - lua_pushstring(L, "__gc"); - lua_pushcfunction(L, (&SharedPtrUserData::destroy)); - lua_settable(L, -3); // metatable.__gc = SharedPtrUserData<...>::destroy - - luaL_setfuncs(L, HTTP_RESPONSE_METHODS, 0); -} - -std::shared_ptr HTTPResponse::getOrError(lua_State *L, - StackIdx where) -{ - if (lua_gettop(L) < 1) - { - // The nullptr is there just to appease the compiler, luaL_error is no return - luaL_error(L, "Called c2.HTTPResponse method without a request object"); - return nullptr; - } - if (lua_isuserdata(L, where) == 0) - { - luaL_error(L, "Called c2.HTTPResponse method with a non-userdata " - "'self' argument"); - return nullptr; - } - // luaL_checkudata is no-return if check fails - auto *checked = luaL_checkudata(L, where, "c2.HTTPResponse"); - auto *data = - SharedPtrUserData::from( - checked); - if (data == nullptr) - { - luaL_error(L, "Called c2.HTTPResponse method with an invalid pointer"); - return nullptr; - } - lua_remove(L, where); - if (data->target == nullptr) - { - luaL_error( - L, - "Internal error: SharedPtrUserData::target was null. This is a Chatterino bug!"); - return nullptr; - } - return data->target; + "data", &HTTPResponse::data, // + "status", &HTTPResponse::status, // + "error", &HTTPResponse::error // + ); } HTTPResponse::HTTPResponse(NetworkResult res) @@ -85,60 +36,30 @@ HTTPResponse::~HTTPResponse() DebugCount::decrease("lua::api::HTTPResponse"); } -int HTTPResponse::data_wrap(lua_State *L) +QByteArray HTTPResponse::data() { - lua::StackGuard guard(L, 0); // 1 in, 1 out - auto ptr = HTTPResponse::getOrError(L, 1); - return ptr->data(L); + return this->result_.getData(); } -int HTTPResponse::data(lua_State *L) +std::optional HTTPResponse::status() { - lua::push(L, this->result_.getData().toStdString()); - return 1; + return this->result_.status(); } -int HTTPResponse::status_wrap(lua_State *L) +QString HTTPResponse::error() { - lua::StackGuard guard(L, 0); // 1 in, 1 out - auto ptr = HTTPResponse::getOrError(L, 1); - return ptr->status(L); + return this->result_.formatError(); } -int HTTPResponse::status(lua_State *L) +QString HTTPResponse::to_string() { - lua::push(L, this->result_.status()); - return 1; + if (this->status().has_value()) + { + return QStringView(u"") + .arg(QString::number(*this->status())); + } + return ""; } -int HTTPResponse::error_wrap(lua_State *L) -{ - lua::StackGuard guard(L, 0); // 1 in, 1 out - auto ptr = HTTPResponse::getOrError(L, 1); - return ptr->error(L); -} - -int HTTPResponse::error(lua_State *L) -{ - lua::push(L, this->result_.formatError()); - return 1; -} - -// NOLINTEND(*vararg) } // namespace chatterino::lua::api - -namespace chatterino::lua { -StackIdx push(lua_State *L, std::shared_ptr request) -{ - using namespace chatterino::lua::api; - - // Prepare table - SharedPtrUserData::create( - L, std::move(request)); - luaL_getmetatable(L, "c2.HTTPResponse"); - lua_setmetatable(L, -2); - - return lua_gettop(L); -} -} // namespace chatterino::lua #endif diff --git a/src/controllers/plugins/api/HTTPResponse.hpp b/src/controllers/plugins/api/HTTPResponse.hpp index 205aae01e..80eb49bd3 100644 --- a/src/controllers/plugins/api/HTTPResponse.hpp +++ b/src/controllers/plugins/api/HTTPResponse.hpp @@ -1,12 +1,11 @@ #pragma once #ifdef CHATTERINO_HAVE_PLUGINS # include "common/network/NetworkResult.hpp" -# include "controllers/plugins/LuaUtilities.hpp" + +# include +# include # include -extern "C" { -# include -} namespace chatterino { class PluginController; @@ -18,7 +17,7 @@ namespace chatterino::lua::api { /** * @lua@class HTTPResponse */ -class HTTPResponse : public std::enable_shared_from_this +class HTTPResponse { NetworkResult result_; @@ -31,20 +30,9 @@ public: ~HTTPResponse(); private: - static void createMetatable(lua_State *L); + static void createUserType(sol::table &c2); friend class chatterino::PluginController; - /** - * @brief Get the content of the top object on Lua stack, usually the first argument as an HTTPResponse - * - * If the object given is not a userdatum or the pointer inside that - * userdatum doesn't point to a HTTPResponse, a lua error is thrown. - * - * This function always returns a non-null pointer. - */ - static std::shared_ptr getOrError(lua_State *L, - StackIdx where = -1); - public: /** * Returns the data. This is not guaranteed to be encoded using any @@ -52,29 +40,28 @@ public: * * @exposed HTTPResponse:data */ - static int data_wrap(lua_State *L); - int data(lua_State *L); + QByteArray data(); /** * Returns the status code. * * @exposed HTTPResponse:status */ - static int status_wrap(lua_State *L); - int status(lua_State *L); + std::optional status(); /** * A somewhat human readable description of an error if such happened * @exposed HTTPResponse:error */ + QString error(); - static int error_wrap(lua_State *L); - int error(lua_State *L); + /** + * @lua@return string + * @exposed HTTPResponse:__tostring + */ + QString to_string(); }; // NOLINTEND(readability-identifier-naming) } // namespace chatterino::lua::api -namespace chatterino::lua { -StackIdx push(lua_State *L, std::shared_ptr request); -} // namespace chatterino::lua #endif diff --git a/src/controllers/plugins/api/IOWrapper.cpp b/src/controllers/plugins/api/IOWrapper.cpp index f6a58a0bb..b3e4103b9 100644 --- a/src/controllers/plugins/api/IOWrapper.cpp +++ b/src/controllers/plugins/api/IOWrapper.cpp @@ -2,15 +2,17 @@ # include "controllers/plugins/api/IOWrapper.hpp" # include "Application.hpp" -# include "controllers/plugins/LuaUtilities.hpp" +# include "common/QLogging.hpp" # include "controllers/plugins/PluginController.hpp" -extern "C" { # include # include -} +# include +# include # include +# include +# include namespace chatterino::lua::api { @@ -91,45 +93,28 @@ struct LuaFileMode { } }; -int ioError(lua_State *L, const QString &value, int errnoequiv) +sol::variadic_results ioError(lua_State *L, const QString &value, + int errnoequiv) { - lua_pushnil(L); - lua::push(L, value); - lua::push(L, errnoequiv); - return 3; + sol::variadic_results out; + out.push_back(sol::nil); + out.push_back(sol::make_object(L, value.toStdString())); + out.push_back({L, sol::in_place_type, errnoequiv}); + return out; } -// NOLINTBEGIN(*vararg) -int io_open(lua_State *L) +sol::variadic_results io_open(sol::this_state L, QString filename, + QString strmode) { auto *pl = getApp()->getPlugins()->getPluginByStatePtr(L); if (pl == nullptr) { - luaL_error(L, "internal error: no plugin"); - return 0; + throw std::runtime_error("internal error: no plugin"); } - LuaFileMode mode; - if (lua_gettop(L) == 2) + LuaFileMode mode(strmode); + if (!mode.error.isEmpty()) { - // we have a mode - QString smode; - if (!lua::pop(L, &smode)) - { - return luaL_error( - L, - "io.open mode (2nd argument) must be a string or not present"); - } - mode = LuaFileMode(smode); - if (!mode.error.isEmpty()) - { - return luaL_error(L, mode.error.toStdString().c_str()); - } - } - QString filename; - if (!lua::pop(L, &filename)) - { - return luaL_error(L, - "io.open filename (1st argument) must be a string"); + throw std::runtime_error(mode.error.toStdString()); } QFileInfo file(pl->dataDirectory().filePath(filename)); auto abs = file.absoluteFilePath(); @@ -144,39 +129,35 @@ int io_open(lua_State *L) "Plugin does not have permissions to access given file.", EACCES); } - lua_getfield(L, LUA_REGISTRYINDEX, REG_REAL_IO_NAME); - lua_getfield(L, -1, "open"); - lua_remove(L, -2); // remove LUA_REGISTRYINDEX[REAL_IO_NAME] - lua::push(L, abs); - lua::push(L, mode.toString()); - lua_call(L, 2, 3); - return 3; + + sol::state_view lua(L); + auto open = lua.registry()[REG_REAL_IO_NAME]["open"]; + sol::protected_function_result res = + open(abs.toStdString(), mode.toString().toStdString()); + return res; +} +sol::variadic_results io_open_modeless(sol::this_state L, QString filename) +{ + return io_open(L, std::move(filename), "r"); } -int io_lines(lua_State *L) +sol::variadic_results io_lines_noargs(sol::this_state L) +{ + sol::state_view lua(L); + auto lines = lua.registry()[REG_REAL_IO_NAME]["lines"]; + sol::protected_function_result res = lines(); + return res; +} + +sol::variadic_results io_lines(sol::this_state L, QString filename, + sol::variadic_args args) { auto *pl = getApp()->getPlugins()->getPluginByStatePtr(L); if (pl == nullptr) { - luaL_error(L, "internal error: no plugin"); - return 0; - } - if (lua_gettop(L) == 0) - { - // io.lines() case, just call realio.lines - lua_getfield(L, LUA_REGISTRYINDEX, REG_REAL_IO_NAME); - lua_getfield(L, -1, "lines"); - lua_remove(L, -2); // remove LUA_REGISTRYINDEX[REAL_IO_NAME] - lua_call(L, 0, 1); - return 1; - } - QString filename; - if (!lua::pop(L, &filename)) - { - return luaL_error( - L, - "io.lines filename (1st argument) must be a string or not present"); + throw std::runtime_error("internal error: no plugin"); } + sol::state_view lua(L); QFileInfo file(pl->dataDirectory().filePath(filename)); auto abs = file.absoluteFilePath(); qCDebug(chatterinoLua) << "[" << pl->id << ":" << pl->meta.name @@ -185,191 +166,168 @@ int io_lines(lua_State *L) bool ok = pl->hasFSPermissionFor(false, abs); if (!ok) { - return ioError(L, - "Plugin does not have permissions to access given file.", - EACCES); + throw std::runtime_error( + "Plugin does not have permissions to access given file."); } - // Our stack looks like this: - // - {...}[1] - // - {...}[2] - // ... - // We want: - // - REG[REG_REAL_IO_NAME].lines - // - absolute file path - // - {...}[1] - // - {...}[2] - // ... - lua_getfield(L, LUA_REGISTRYINDEX, REG_REAL_IO_NAME); - lua_getfield(L, -1, "lines"); - lua_remove(L, -2); // remove LUA_REGISTRYINDEX[REAL_IO_NAME] - lua_insert(L, 1); // move function to start of stack - lua::push(L, abs); - lua_insert(L, 2); // move file name just after the function - lua_call(L, lua_gettop(L) - 1, LUA_MULTRET); - return lua_gettop(L); + auto lines = lua.registry()[REG_REAL_IO_NAME]["lines"]; + sol::protected_function_result res = lines(abs.toStdString(), args); + return res; } -namespace { - - // This is the code for both io.input and io.output - int globalFileCommon(lua_State *L, bool output) +sol::variadic_results io_input_argless(sol::this_state L) +{ + auto *pl = getApp()->getPlugins()->getPluginByStatePtr(L); + if (pl == nullptr) { - auto *pl = getApp()->getPlugins()->getPluginByStatePtr(L); - if (pl == nullptr) - { - luaL_error(L, "internal error: no plugin"); - return 0; - } - // Three signature cases: - // io.input() - // io.input(file) - // io.input(name) - if (lua_gettop(L) == 0) - { - // We have no arguments, call realio.input() - lua_getfield(L, LUA_REGISTRYINDEX, REG_REAL_IO_NAME); - if (output) - { - lua_getfield(L, -1, "output"); - } - else - { - lua_getfield(L, -1, "input"); - } - lua_remove(L, -2); // remove LUA_REGISTRYINDEX[REAL_IO_NAME] - lua_call(L, 0, 1); - return 1; - } - if (lua_gettop(L) != 1) - { - return luaL_error(L, "Too many arguments given to io.input()."); - } - // Now check if we have a file or name - auto *p = luaL_testudata(L, 1, LUA_FILEHANDLE); - if (p == nullptr) - { - // this is not a file handle, send it to open - luaL_getsubtable(L, LUA_REGISTRYINDEX, REG_C2_IO_NAME); - lua_getfield(L, -1, "open"); - lua_remove(L, -2); // remove io - - lua_pushvalue(L, 1); // dupe arg - if (output) - { - lua_pushstring(L, "w"); - } - else - { - lua_pushstring(L, "r"); - } - lua_call(L, 2, 1); // call ourio.open(arg1, 'r'|'w') - // if this isn't a string ourio.open errors - - // this leaves us with: - // 1. arg - // 2. new_file - lua_remove(L, 1); // remove arg, replacing it with new_file - } - - // file handle, pass it off to realio.input - lua_getfield(L, LUA_REGISTRYINDEX, REG_REAL_IO_NAME); - if (output) - { - lua_getfield(L, -1, "output"); - } - else - { - lua_getfield(L, -1, "input"); - } - lua_remove(L, -2); // remove LUA_REGISTRYINDEX[REAL_IO_NAME] - lua_pushvalue(L, 1); // duplicate arg - lua_call(L, 1, 1); - return 1; + throw std::runtime_error("internal error: no plugin"); } + sol::state_view lua(L); -} // namespace - -int io_input(lua_State *L) -{ - return globalFileCommon(L, false); + auto func = lua.registry()[REG_REAL_IO_NAME]["input"]; + sol::protected_function_result res = func(); + return res; } - -int io_output(lua_State *L) +sol::variadic_results io_input_file(sol::this_state L, sol::userdata file) { - return globalFileCommon(L, true); -} - -int io_close(lua_State *L) -{ - if (lua_gettop(L) > 1) + auto *pl = getApp()->getPlugins()->getPluginByStatePtr(L); + if (pl == nullptr) { - return luaL_error( - L, "Too many arguments for io.close. Expected one or zero."); + throw std::runtime_error("internal error: no plugin"); } - if (lua_gettop(L) == 0) + sol::state_view lua(L); + + auto func = lua.registry()[REG_REAL_IO_NAME]["input"]; + sol::protected_function_result res = func(file); + return res; +} +sol::variadic_results io_input_name(sol::this_state L, QString filename) +{ + auto *pl = getApp()->getPlugins()->getPluginByStatePtr(L); + if (pl == nullptr) { - lua_getfield(L, LUA_REGISTRYINDEX, "_IO_output"); + throw std::runtime_error("internal error: no plugin"); } - lua_getfield(L, -1, "close"); - lua_pushvalue(L, -2); - lua_call(L, 1, 0); - return 0; -} - -int io_flush(lua_State *L) -{ - if (lua_gettop(L) > 1) + sol::state_view lua(L); + auto res = io_open(L, std::move(filename), "r"); + if (res.size() != 1) { - return luaL_error( - L, "Too many arguments for io.flush. Expected one or zero."); + throw std::runtime_error(res.at(1).as()); } - lua_getfield(L, LUA_REGISTRYINDEX, "_IO_output"); - lua_getfield(L, -1, "flush"); - lua_pushvalue(L, -2); - lua_call(L, 1, 0); - return 0; -} - -int io_read(lua_State *L) -{ - if (lua_gettop(L) > 1) + auto obj = res.at(0); + if (obj.get_type() != sol::type::userdata) { - return luaL_error( - L, "Too many arguments for io.read. Expected one or zero."); + throw std::runtime_error("a file must be a userdata."); } - lua_getfield(L, LUA_REGISTRYINDEX, "_IO_input"); - lua_getfield(L, -1, "read"); - lua_insert(L, 1); - lua_insert(L, 2); - lua_call(L, lua_gettop(L) - 1, 1); - return 1; + return io_input_file(L, obj); } -int io_write(lua_State *L) +sol::variadic_results io_output_argless(sol::this_state L) { - lua_getfield(L, LUA_REGISTRYINDEX, "_IO_output"); - lua_getfield(L, -1, "write"); - lua_insert(L, 1); - lua_insert(L, 2); - // (input) - // (input).read - // args - lua_call(L, lua_gettop(L) - 1, 1); - return 1; -} + auto *pl = getApp()->getPlugins()->getPluginByStatePtr(L); + if (pl == nullptr) + { + throw std::runtime_error("internal error: no plugin"); + } + sol::state_view lua(L); -int io_popen(lua_State *L) + auto func = lua.registry()[REG_REAL_IO_NAME]["output"]; + sol::protected_function_result res = func(); + return res; +} +sol::variadic_results io_output_file(sol::this_state L, sol::userdata file) { - return luaL_error(L, "io.popen: This function is a stub!"); -} + auto *pl = getApp()->getPlugins()->getPluginByStatePtr(L); + if (pl == nullptr) + { + throw std::runtime_error("internal error: no plugin"); + } + sol::state_view lua(L); -int io_tmpfile(lua_State *L) + auto func = lua.registry()[REG_REAL_IO_NAME]["output"]; + sol::protected_function_result res = func(file); + return res; +} +sol::variadic_results io_output_name(sol::this_state L, QString filename) { - return luaL_error(L, "io.tmpfile: This function is a stub!"); + auto *pl = getApp()->getPlugins()->getPluginByStatePtr(L); + if (pl == nullptr) + { + throw std::runtime_error("internal error: no plugin"); + } + sol::state_view lua(L); + auto res = io_open(L, std::move(filename), "w"); + if (res.size() != 1) + { + throw std::runtime_error(res.at(1).as()); + } + auto obj = res.at(0); + if (obj.get_type() != sol::type::userdata) + { + throw std::runtime_error("internal error: a file must be a userdata."); + } + return io_output_file(L, obj); } -// NOLINTEND(*vararg) +bool io_close_argless(sol::this_state L) +{ + sol::state_view lua(L); + auto out = lua.registry()["_IO_output"]; + return io_close_file(L, out); +} + +bool io_close_file(sol::this_state L, sol::userdata file) +{ + sol::state_view lua(L); + return file["close"](file); +} + +void io_flush_argless(sol::this_state L) +{ + sol::state_view lua(L); + auto out = lua.registry()["_IO_output"]; + io_flush_file(L, out); +} + +void io_flush_file(sol::this_state L, sol::userdata file) +{ + sol::state_view lua(L); + file["flush"](file); +} + +sol::variadic_results io_read(sol::this_state L, sol::variadic_args args) +{ + sol::state_view lua(L); + auto inp = lua.registry()["_IO_input"]; + if (!inp.is()) + { + throw std::runtime_error("Input not set to a file"); + } + sol::protected_function read = inp["read"]; + return read(inp, args); +} + +sol::variadic_results io_write(sol::this_state L, sol::variadic_args args) +{ + sol::state_view lua(L); + auto out = lua.registry()["_IO_output"]; + if (!out.is()) + { + throw std::runtime_error("Output not set to a file"); + } + sol::protected_function write = out["write"]; + return write(out, args); +} + +void io_popen() +{ + throw std::runtime_error("io.popen: This function is a stub!"); +} + +void io_tmpfile() +{ + throw std::runtime_error("io.tmpfile: This function is a stub!"); +} } // namespace chatterino::lua::api #endif diff --git a/src/controllers/plugins/api/IOWrapper.hpp b/src/controllers/plugins/api/IOWrapper.hpp index 24ee2801e..16b1ae178 100644 --- a/src/controllers/plugins/api/IOWrapper.hpp +++ b/src/controllers/plugins/api/IOWrapper.hpp @@ -1,5 +1,9 @@ #pragma once #ifdef CHATTERINO_HAVE_PLUGINS +# include +# include +# include +# include struct lua_State; @@ -8,7 +12,6 @@ namespace chatterino::lua::api { // These functions are exposed as `_G.io`, they are wrappers for native Lua functionality. const char *const REG_REAL_IO_NAME = "real_lua_io_lib"; -const char *const REG_C2_IO_NAME = "c2io"; /** * Opens a file. @@ -20,7 +23,9 @@ const char *const REG_C2_IO_NAME = "c2io"; * @lua@param mode nil|"r"|"w"|"a"|"r+"|"w+"|"a+" * @exposed io.open */ -int io_open(lua_State *L); +sol::variadic_results io_open(sol::this_state L, QString filename, + QString strmode); +sol::variadic_results io_open_modeless(sol::this_state L, QString filename); /** * Equivalent to io.input():lines("l") or a specific iterator over given file @@ -32,7 +37,9 @@ int io_open(lua_State *L); * @lua@param ... * @exposed io.lines */ -int io_lines(lua_State *L); +sol::variadic_results io_lines(sol::this_state L, QString filename, + sol::variadic_args args); +sol::variadic_results io_lines_noargs(sol::this_state L); /** * Opens a file and sets it as default input or if given no arguments returns the default input. @@ -42,7 +49,9 @@ int io_lines(lua_State *L); * @lua@return nil|FILE* * @exposed io.input */ -int io_input(lua_State *L); +sol::variadic_results io_input_argless(sol::this_state L); +sol::variadic_results io_input_file(sol::this_state L, sol::userdata file); +sol::variadic_results io_input_name(sol::this_state L, QString filename); /** * Opens a file and sets it as default output or if given no arguments returns the default output @@ -52,7 +61,9 @@ int io_input(lua_State *L); * @lua@return nil|FILE* * @exposed io.output */ -int io_output(lua_State *L); +sol::variadic_results io_output_argless(sol::this_state L); +sol::variadic_results io_output_file(sol::this_state L, sol::userdata file); +sol::variadic_results io_output_name(sol::this_state L, QString filename); /** * Closes given file or io.output() if not given. @@ -61,7 +72,8 @@ int io_output(lua_State *L); * @lua@param nil|FILE* * @exposed io.close */ -int io_close(lua_State *L); +bool io_close_argless(sol::this_state L); +bool io_close_file(sol::this_state L, sol::userdata file); /** * Flushes the buffer for given file or io.output() if not given. @@ -70,7 +82,8 @@ int io_close(lua_State *L); * @lua@param nil|FILE* * @exposed io.flush */ -int io_flush(lua_State *L); +void io_flush_argless(sol::this_state L); +void io_flush_file(sol::this_state L, sol::userdata file); /** * Reads some data from the default input file @@ -79,7 +92,7 @@ int io_flush(lua_State *L); * @lua@param nil|string * @exposed io.read */ -int io_read(lua_State *L); +sol::variadic_results io_read(sol::this_state L, sol::variadic_args args); /** * Writes some data to the default output file @@ -88,10 +101,10 @@ int io_read(lua_State *L); * @lua@param nil|string * @exposed io.write */ -int io_write(lua_State *L); +sol::variadic_results io_write(sol::this_state L, sol::variadic_args args); -int io_popen(lua_State *L); -int io_tmpfile(lua_State *L); +void io_popen(); +void io_tmpfile(); // NOLINTEND(readability-identifier-naming) } // namespace chatterino::lua::api diff --git a/src/providers/twitch/TwitchChannel.hpp b/src/providers/twitch/TwitchChannel.hpp index 41eb53de8..484b52bbb 100644 --- a/src/providers/twitch/TwitchChannel.hpp +++ b/src/providers/twitch/TwitchChannel.hpp @@ -64,22 +64,55 @@ const int MAX_QUEUED_REDEMPTIONS = 16; class TwitchChannel final : public Channel, public ChannelChatters { public: + /** + * @lua@class StreamStatus + */ struct StreamStatus { + /** + * @lua@field live boolean + */ bool live = false; bool rerun = false; + /** + * @lua@field viewer_count number + */ unsigned viewerCount = 0; + /** + * @lua@field title string Stream title or last stream title + */ QString title; + /** + * @lua@field game_name string + */ QString game; + /** + * @lua@field game_id string + */ QString gameId; QString uptime; + /** + * @lua@field uptime number Seconds since the stream started. + */ int uptimeSeconds = 0; QString streamType; QString streamId; }; + /** + * @lua@class RoomModes + */ struct RoomModes { + /** + * @lua@field subscriber_only boolean + */ bool submode = false; + /** + * @lua@field unique_chat boolean You might know this as r9kbeta or robot9000. + */ bool r9k = false; + /** + * @lua@field emotes_only boolean Whether or not text is allowed in messages. Note that "emotes" here only means Twitch emotes, not Unicode emoji, nor 3rd party text-based emotes + */ bool emoteOnly = false; /** @@ -88,6 +121,8 @@ public: * Special cases: * -1 = follower mode off * 0 = follower mode on, no time requirement + * + * @lua@field follower_only number? Time in minutes you need to follow to chat or nil. **/ int followerOnly = -1; @@ -95,6 +130,8 @@ public: * @brief Number of seconds required to wait before typing emotes * * 0 = slow mode off + * + * @lua@field slow_mode number? Time in seconds you need to wait before sending messages or nil. **/ int slowMode = 0; }; diff --git a/src/util/TypeName.hpp b/src/util/TypeName.hpp index 3e5c674e8..1f6066381 100644 --- a/src/util/TypeName.hpp +++ b/src/util/TypeName.hpp @@ -28,6 +28,15 @@ constexpr auto type_name() name.remove_prefix(prefix.size()); name.remove_suffix(suffix.size()); + if (name.starts_with("class ")) + { + name.remove_prefix(6); + } + if (name.starts_with("struct ")) + { + name.remove_prefix(7); + } + return name; } diff --git a/src/widgets/splits/SplitContainer.cpp b/src/widgets/splits/SplitContainer.cpp index c2ddc1160..1cca478f0 100644 --- a/src/widgets/splits/SplitContainer.cpp +++ b/src/widgets/splits/SplitContainer.cpp @@ -805,6 +805,32 @@ void SplitContainer::popup() window.show(); } +QString channelTypeToString(Channel::Type value) noexcept +{ + using Type = chatterino::Channel::Type; + switch (value) + { + default: + assert(false && "value cannot be serialized"); + return "never"; + + case Type::Twitch: + return "twitch"; + case Type::TwitchWhispers: + return "whispers"; + case Type::TwitchWatching: + return "watching"; + case Type::TwitchMentions: + return "mentions"; + case Type::TwitchLive: + return "live"; + case Type::TwitchAutomod: + return "automod"; + case Type::Misc: + return "misc"; + } +} + NodeDescriptor SplitContainer::buildDescriptorRecursively( const Node *currentNode) const { @@ -814,7 +840,7 @@ NodeDescriptor SplitContainer::buildDescriptorRecursively( currentNode->split_->getIndirectChannel().getType(); SplitNodeDescriptor result; - result.type_ = qmagicenum::enumNameString(channelType); + result.type_ = channelTypeToString(channelType); result.channelName_ = currentNode->split_->getChannel()->getName(); result.filters_ = currentNode->split_->getFilters(); return result; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5a2cb5f1b..547f0e7c0 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -48,6 +48,7 @@ set(test_SOURCES ${CMAKE_CURRENT_LIST_DIR}/src/FlagsEnum.cpp ${CMAKE_CURRENT_LIST_DIR}/src/MessageLayoutContainer.cpp ${CMAKE_CURRENT_LIST_DIR}/src/CancellationToken.cpp + ${CMAKE_CURRENT_LIST_DIR}/src/Plugins.cpp ${CMAKE_CURRENT_LIST_DIR}/src/TwitchIrc.cpp ${CMAKE_CURRENT_LIST_DIR}/src/IgnoreController.cpp ${CMAKE_CURRENT_LIST_DIR}/src/lib/Snapshot.cpp diff --git a/tests/src/NetworkHelpers.hpp b/tests/src/NetworkHelpers.hpp new file mode 100644 index 000000000..f55355e4a --- /dev/null +++ b/tests/src/NetworkHelpers.hpp @@ -0,0 +1,55 @@ +#pragma once +#include "Test.hpp" + +#include +namespace chatterino { + +#ifdef CHATTERINO_TEST_USE_PUBLIC_HTTPBIN +// Using our self-hosted version of httpbox https://github.com/kevinastone/httpbox +const char *const HTTPBIN_BASE_URL = "https://braize.pajlada.com/httpbox"; +#else +const char *const HTTPBIN_BASE_URL = "http://127.0.0.1:9051"; +#endif + +class RequestWaiter +{ +public: + void requestDone() + { + { + std::unique_lock lck(this->mutex_); + ASSERT_FALSE(this->requestDone_); + this->requestDone_ = true; + } + this->condition_.notify_one(); + } + + void waitForRequest() + { + using namespace std::chrono_literals; + + while (true) + { + { + std::unique_lock lck(this->mutex_); + bool done = this->condition_.wait_for(lck, 10ms, [this] { + return this->requestDone_; + }); + if (done) + { + break; + } + } + QCoreApplication::processEvents(QEventLoop::AllEvents); + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + } + + ASSERT_TRUE(this->requestDone_); + } + +private: + std::mutex mutex_; + std::condition_variable condition_; + bool requestDone_ = false; +}; +} // namespace chatterino diff --git a/tests/src/NetworkRequest.cpp b/tests/src/NetworkRequest.cpp index ca723481e..44b7504c7 100644 --- a/tests/src/NetworkRequest.cpp +++ b/tests/src/NetworkRequest.cpp @@ -2,6 +2,7 @@ #include "common/network/NetworkManager.hpp" #include "common/network/NetworkResult.hpp" +#include "NetworkHelpers.hpp" #include "Test.hpp" #include @@ -10,14 +11,6 @@ using namespace chatterino; namespace { -#ifdef CHATTERINO_TEST_USE_PUBLIC_HTTPBIN -// Not using httpbin.org, since it can be really slow and cause timeouts. -// postman-echo has the same API. -const char *const HTTPBIN_BASE_URL = "https://postman-echo.com"; -#else -const char *const HTTPBIN_BASE_URL = "http://127.0.0.1:9051"; -#endif - QString getStatusURL(int code) { return QString("%1/status/%2").arg(HTTPBIN_BASE_URL).arg(code); @@ -28,46 +21,6 @@ QString getDelayURL(int delay) return QString("%1/delay/%2").arg(HTTPBIN_BASE_URL).arg(delay); } -class RequestWaiter -{ -public: - void requestDone() - { - { - std::unique_lock lck(this->mutex_); - ASSERT_FALSE(this->requestDone_); - this->requestDone_ = true; - } - this->condition_.notify_one(); - } - - void waitForRequest() - { - using namespace std::chrono_literals; - - while (true) - { - { - std::unique_lock lck(this->mutex_); - bool done = this->condition_.wait_for(lck, 10ms, [this] { - return this->requestDone_; - }); - if (done) - { - break; - } - } - QCoreApplication::processEvents(QEventLoop::AllEvents); - QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); - } - } - -private: - std::mutex mutex_; - std::condition_variable condition_; - bool requestDone_ = false; -}; - } // namespace TEST(NetworkRequest, Success) diff --git a/tests/src/Plugins.cpp b/tests/src/Plugins.cpp new file mode 100644 index 000000000..588d3c2ff --- /dev/null +++ b/tests/src/Plugins.cpp @@ -0,0 +1,641 @@ +#ifdef CHATTERINO_HAVE_PLUGINS +# include "Application.hpp" +# include "common/Channel.hpp" +# include "common/network/NetworkCommon.hpp" +# include "controllers/commands/Command.hpp" // IWYU pragma: keep +# include "controllers/commands/CommandController.hpp" +# include "controllers/plugins/api/ChannelRef.hpp" +# include "controllers/plugins/Plugin.hpp" +# include "controllers/plugins/PluginController.hpp" +# include "controllers/plugins/PluginPermission.hpp" +# include "controllers/plugins/SolTypes.hpp" // IWYU pragma: keep +# include "mocks/BaseApplication.hpp" +# include "mocks/Channel.hpp" +# include "mocks/Emotes.hpp" +# include "mocks/Logging.hpp" +# include "mocks/TwitchIrcServer.hpp" +# include "NetworkHelpers.hpp" +# include "singletons/Logging.hpp" +# include "Test.hpp" + +# include +# include +# include + +# include +# include +# include + +using namespace chatterino; +using chatterino::mock::MockChannel; + +namespace { + +const QString TEST_SETTINGS = R"( +{ + "plugins": { + "supportEnabled": true, + "enabledPlugins": [ + "test" + ] + } +} +)"; + +class MockTwitch : public mock::MockTwitchIrcServer +{ +public: + ChannelPtr mm2pl = std::make_shared("mm2pl"); + + ChannelPtr getChannelOrEmpty(const QString &dirtyChannelName) override + { + if (dirtyChannelName == "mm2pl") + { + return this->mm2pl; + } + return Channel::getEmpty(); + } + + std::shared_ptr getChannelOrEmptyByID( + const QString &channelID) override + { + if (channelID == "117691339") + { + return this->mm2pl; + } + return Channel::getEmpty(); + } +}; + +class MockApplication : public mock::BaseApplication +{ +public: + MockApplication() + : mock::BaseApplication(TEST_SETTINGS) + , plugins(this->paths_) + , commands(this->paths_) + { + } + + PluginController *getPlugins() override + { + return &this->plugins; + } + + CommandController *getCommands() override + { + return &this->commands; + } + + IEmotes *getEmotes() override + { + return &this->emotes; + } + + mock::MockTwitchIrcServer *getTwitch() override + { + return &this->twitch; + } + + ILogging *getChatLogger() override + { + return &this->logging; + } + + PluginController plugins; + mock::EmptyLogging logging; + CommandController commands; + mock::Emotes emotes; + MockTwitch twitch; +}; + +} // namespace + +namespace chatterino { + +class PluginControllerAccess +{ +public: + static bool tryLoadFromDir(const QDir &pluginDir) + { + return getApp()->getPlugins()->tryLoadFromDir(pluginDir); + } + + static void openLibrariesFor(Plugin *plugin) + { + return PluginController::openLibrariesFor(plugin); + } + + static std::map> &plugins() + { + return getApp()->getPlugins()->plugins_; + } + + static lua_State *state(Plugin *pl) + { + return pl->state_; + } +}; + +} // namespace chatterino + +class PluginTest : public ::testing::Test +{ +protected: + void configure(std::vector permissions = {}) + { + this->app = std::make_unique(); + + auto &plugins = PluginControllerAccess::plugins(); + { + PluginMeta meta; + meta.name = "Test"; + meta.license = "MIT"; + meta.homepage = "https://github.com/Chatterino/chatterino2"; + meta.description = "Plugin for tests"; + meta.permissions = std::move(permissions); + + QDir plugindir = + QDir(app->paths_.pluginsDirectory).absoluteFilePath("test"); + + plugindir.mkpath("."); + auto temp = std::make_unique("test", luaL_newstate(), meta, + plugindir); + this->rawpl = temp.get(); + plugins.insert({"test", std::move(temp)}); + } + + // XXX: this skips PluginController::load() + PluginControllerAccess::openLibrariesFor(rawpl); + this->lua = new sol::state_view(PluginControllerAccess::state(rawpl)); + + this->channel = app->twitch.mm2pl; + this->rawpl->dataDirectory().mkpath("."); + } + + void TearDown() override + { + // perform safe destruction of the plugin + delete this->lua; + this->lua = nullptr; + PluginControllerAccess::plugins().clear(); + this->rawpl = nullptr; + this->app.reset(); + } + + Plugin *rawpl = nullptr; + std::unique_ptr app; + sol::state_view *lua; + ChannelPtr channel; +}; + +TEST_F(PluginTest, testCommands) +{ + configure(); + + lua->script(R"lua( + _G.called = false + _G.words = nil + _G.channel = nil + c2.register_command("/test", function(ctx) + _G.called = true + _G.words = ctx.words + _G.channel = ctx.channel + end) + )lua"); + + EXPECT_EQ(app->commands.pluginCommands(), QStringList{"/test"}); + app->commands.execCommand("/test with arguments", channel, false); + bool called = (*lua)["called"]; + EXPECT_EQ(called, true); + + EXPECT_NE((*lua)["words"], sol::nil); + { + sol::table tbl = (*lua)["words"]; + std::vector words; + for (auto &o : tbl) + { + words.push_back(o.second.as()); + } + EXPECT_EQ(words, + std::vector({"/test", "with", "arguments"})); + } + + sol::object chnobj = (*lua)["channel"]; + EXPECT_EQ(chnobj.get_type(), sol::type::userdata); + lua::api::ChannelRef ref = chnobj.as(); + EXPECT_EQ(ref.get_name(), channel->getName()); +} + +TEST_F(PluginTest, testCompletion) +{ + configure(); + + lua->script(R"lua( + _G.called = false + _G.query = nil + _G.full_text_content = nil + _G.cursor_position = nil + _G.is_first_word = nil + + c2.register_callback( + c2.EventType.CompletionRequested, + function(ev) + _G.called = true + _G.query = ev.query + _G.full_text_content = ev.full_text_content + _G.cursor_position = ev.cursor_position + _G.is_first_word = ev.is_first_word + if ev.query == "exclusive" then + return { + hide_others = true, + values = {"Completion1", "Completion2"} + } + end + return { + hide_others = false, + values = {"Completion"}, + } + end + ) + )lua"); + + bool done{}; + QStringList results; + std::tie(done, results) = + app->plugins.updateCustomCompletions("foo", "foo", 3, true); + ASSERT_EQ(done, false); + ASSERT_EQ(results, QStringList{"Completion"}); + + ASSERT_EQ((*lua).get("query"), "foo"); + ASSERT_EQ((*lua).get("full_text_content"), "foo"); + ASSERT_EQ((*lua).get("cursor_position"), 3); + ASSERT_EQ((*lua).get("is_first_word"), true); + + std::tie(done, results) = app->plugins.updateCustomCompletions( + "exclusive", "foo exclusive", 13, false); + ASSERT_EQ(done, true); + ASSERT_EQ(results, QStringList({"Completion1", "Completion2"})); + + ASSERT_EQ((*lua).get("query"), "exclusive"); + ASSERT_EQ((*lua).get("full_text_content"), "foo exclusive"); + ASSERT_EQ((*lua).get("cursor_position"), 13); + ASSERT_EQ((*lua).get("is_first_word"), false); +} + +TEST_F(PluginTest, testChannel) +{ + configure(); + lua->script(R"lua( + chn = c2.Channel.by_name("mm2pl") + )lua"); + + ASSERT_EQ(lua->script(R"lua( return chn:get_name() )lua").get(0), + "mm2pl"); + ASSERT_EQ( + lua->script(R"lua( return chn:get_type() )lua").get(0), + Channel::Type::Twitch); + ASSERT_EQ( + lua->script(R"lua( return chn:get_display_name() )lua").get(0), + "mm2pl"); + // TODO: send_message, add_system_message + + ASSERT_EQ( + lua->script(R"lua( return chn:is_twitch_channel() )lua").get(0), + true); + + // this is not a TwitchChannel + const auto *shouldThrow1 = R"lua( + return chn:is_broadcaster() + )lua"; + EXPECT_ANY_THROW(lua->script(shouldThrow1)); + const auto *shouldThrow2 = R"lua( + return chn:is_mod() + )lua"; + EXPECT_ANY_THROW(lua->script(shouldThrow2)); + const auto *shouldThrow3 = R"lua( + return chn:is_vip() + )lua"; + EXPECT_ANY_THROW(lua->script(shouldThrow3)); + const auto *shouldThrow4 = R"lua( + return chn:get_twitch_id() + )lua"; + EXPECT_ANY_THROW(lua->script(shouldThrow4)); +} + +TEST_F(PluginTest, testHttp) +{ + { + PluginPermission net; + net.type = PluginPermission::Type::Network; + configure({net}); + } + + lua->script(R"lua( + function DoReq(url, postdata) + r = c2.HTTPRequest.create(method, url) + r:on_success(function(res) + status = res:status() + data = res:data() + error = res:error() + success = true + end) + r:on_error(function(res) + status = res:status() + data = res:data() + error = res:error() + failure = true + end) + r:finally(function() + finally = true + done() + end) + if postdata ~= "" then + r:set_payload(postdata) + r:set_header("Content-Type", "text/plain") + end + r:set_timeout(1000) + r:execute() + end + )lua"); + + struct RequestCase { + QString url; + bool success; + bool failure; + + int status; + QString error; + + NetworkRequestType meth = NetworkRequestType::Get; + QByteArray data; // null means do not check + }; + + std::vector cases{ + {"/status/200", true, false, 200, "200"}, + {"/delay/2", false, true, 0, "TimeoutError"}, + {"/post", true, false, 200, "200", NetworkRequestType::Post, + "Example data"}, + }; + + for (const auto &c : cases) + { + lua->script(R"lua( + success = false + failure = false + finally = false + + status = nil + data = nil + error = nil + )lua"); + RequestWaiter waiter; + (*lua)["method"] = c.meth; + (*lua)["done"] = [&waiter]() { + waiter.requestDone(); + }; + + (*lua)["DoReq"](HTTPBIN_BASE_URL + c.url, c.data); + waiter.waitForRequest(); + + EXPECT_EQ(lua->get("success"), c.success); + EXPECT_EQ(lua->get("failure"), c.failure); + EXPECT_EQ(lua->get("finally"), true); + + if (c.status != 0) + { + EXPECT_EQ(lua->get("status"), c.status); + } + else + { + EXPECT_EQ((*lua)["status"], sol::nil); + } + EXPECT_EQ(lua->get("error"), c.error); + if (!c.data.isNull()) + { + EXPECT_EQ(lua->get("data"), c.data); + } + } +} + +const QByteArray TEST_FILE_DATA = "Test file data\nWith a new line.\n"; + +TEST_F(PluginTest, ioTest) +{ + { + PluginPermission ioread; + PluginPermission iowrite; + ioread.type = PluginPermission::Type::FilesystemRead; + iowrite.type = PluginPermission::Type::FilesystemWrite; + configure({ioread, iowrite}); + } + + lua->set("TEST_DATA", TEST_FILE_DATA); + + lua->script(R"lua( + f, err = io.open("testfile", "w") + print(f, err) + f:write(TEST_DATA) + f:close() + + f, err = io.open("testfile", "r") + out = f:read("a") + f:close() + )lua"); + EXPECT_EQ(lua->get("out"), TEST_FILE_DATA); + + lua->script(R"lua( + io.input("testfile") + out = io.read("a") + )lua"); + EXPECT_EQ(lua->get("out"), TEST_FILE_DATA); + + const auto *shouldThrow1 = R"lua( + io.popen("/bin/sh", "-c", "notify-send \"This should not execute.\"") + )lua"; + EXPECT_ANY_THROW(lua->script(shouldThrow1)); + const auto *shouldThrow2 = R"lua( + io.tmpfile() + )lua"; + EXPECT_ANY_THROW(lua->script(shouldThrow2)); +} + +TEST_F(PluginTest, ioNoPerms) +{ + configure(); + auto file = rawpl->dataDirectory().filePath("testfile"); + QFile f(file); + f.open(QFile::WriteOnly); + f.write(TEST_FILE_DATA); + f.close(); + + EXPECT_EQ( + // clang-format off + lua->script(R"lua( + f, err = io.open("testfile", "r") + return err + )lua").get(0), + "Plugin does not have permissions to access given file." + // clang-format on + ); + + const auto *shouldThrow1 = R"lua( + io.input("testfile") + )lua"; + EXPECT_ANY_THROW(lua->script(shouldThrow1)); + + EXPECT_EQ( + // clang-format off + lua->script(R"lua( + f, err = io.open("testfile", "w") + return err + )lua").get(0), + "Plugin does not have permissions to access given file." + // clang-format on + ); + + const auto *shouldThrow2 = R"lua( + io.output("testfile") + )lua"; + EXPECT_ANY_THROW(lua->script(shouldThrow2)); + + const auto *shouldThrow3 = R"lua( + io.lines("testfile") + )lua"; + EXPECT_ANY_THROW(lua->script(shouldThrow3)); +} + +TEST_F(PluginTest, requireNoData) +{ + { + PluginPermission ioread; + PluginPermission iowrite; + ioread.type = PluginPermission::Type::FilesystemRead; + iowrite.type = PluginPermission::Type::FilesystemWrite; + configure({ioread, iowrite}); + } + + auto file = rawpl->dataDirectory().filePath("thisiscode.lua"); + QFile f(file); + f.open(QFile::WriteOnly); + f.write(R"lua(print("Data was executed"))lua"); + f.close(); + + const auto *shouldThrow1 = R"lua( + require("data.thisiscode") + )lua"; + EXPECT_ANY_THROW(lua->script(shouldThrow1)); +} + +TEST_F(PluginTest, testTimerRec) +{ + configure(); + + RequestWaiter waiter; + lua->set("done", [&] { + waiter.requestDone(); + }); + + sol::protected_function fn = lua->script(R"lua( + local i = 0 + f = function() + i = i + 1 + c2.log(c2.LogLevel.Info, "cb", i) + if i < 1024 then + c2.later(f, 1) + else + done() + end + end + c2.later(f, 1) + )lua"); + waiter.waitForRequest(); +} + +TEST_F(PluginTest, tryCallTest) +{ + configure(); + lua->script(R"lua( + function return_table() + return { + a="b" + } + end + function return_nothing() + end + function return_nil() + return nil + end + function return_nothing_and_error() + error("I failed :)") + end + )lua"); + + using func = sol::protected_function; + + func returnTable = lua->get("return_table"); + func returnNil = lua->get("return_nil"); + func returnNothing = lua->get("return_nothing"); + func returnNothingAndError = lua->get("return_nothing_and_error"); + + // happy paths + { + auto res = lua::tryCall(returnTable); + EXPECT_TRUE(res.has_value()); + auto t = res.value(); + EXPECT_EQ(t.get("a"), "b"); + } + { + // valid void return + auto res = lua::tryCall(returnNil); + EXPECT_TRUE(res.has_value()); + } + { + // valid void return + auto res = lua::tryCall(returnNothing); + EXPECT_TRUE(res.has_value()); + } + { + auto res = lua::tryCall(returnNothingAndError); + EXPECT_FALSE(res.has_value()); + EXPECT_EQ(res.error(), "[string \"...\"]:13: I failed :)"); + } + { + auto res = lua::tryCall>(returnNil); + EXPECT_TRUE(res.has_value()); // no error + auto opt = *res; + EXPECT_FALSE(opt.has_value()); // but also no false + } + + // unhappy paths + { + // wrong return type + auto res = lua::tryCall(returnTable); + EXPECT_FALSE(res.has_value()); + EXPECT_EQ(res.error(), + "Expected int to be returned but table was returned"); + } + { + // optional but bad return type + auto res = lua::tryCall>(returnTable); + EXPECT_FALSE(res.has_value()); + EXPECT_EQ(res.error(), "Expected std::optional to be returned but " + "table was returned"); + } + { + // no return + auto res = lua::tryCall(returnNothing); + EXPECT_FALSE(res.has_value()); + EXPECT_EQ(res.error(), + "Expected int to be returned but none was returned"); + } + { + // nil return + auto res = lua::tryCall(returnNil); + EXPECT_FALSE(res.has_value()); + EXPECT_EQ(res.error(), + "Expected int to be returned but lua_nil was returned"); + } +} + +#endif