diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 903bfaa..dfbaee5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,7 +17,7 @@ jobs: run: pacman -Syu --noconfirm - name: Install dependencies - run: pacman -S --needed --noconfirm base-devel git extra-cmake-modules qt6-tools yaml-cpp gtest + run: pacman -S --needed --noconfirm base-devel git extra-cmake-modules qt6-declarative qt6-tools yaml-cpp gtest - name: Check out repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/CMakeLists.txt b/CMakeLists.txt index dbf7ac9..598780d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -23,6 +23,7 @@ include(KDECMakeSettings) find_package(Qt6 ${QT_MIN_VERSION} CONFIG REQUIRED COMPONENTS Core DBus + Qml ) find_package(PkgConfig REQUIRED) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 6228500..17355b3 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -18,6 +18,7 @@ set(libinputactions_SRCS libinputactions/config/parsers/flags.cpp libinputactions/config/parsers/NodeParser.h libinputactions/config/parsers/qt.cpp + libinputactions/config/parsers/scripting.cpp libinputactions/config/parsers/separated-string.h libinputactions/config/parsers/std.cpp libinputactions/config/parsers/triggers.cpp @@ -80,6 +81,11 @@ set(libinputactions_SRCS libinputactions/interfaces/TextInput.cpp libinputactions/interfaces/Window.h libinputactions/interfaces/WindowProvider.cpp + libinputactions/scripting/modules/core/CoreModule.cpp + libinputactions/scripting/FunctionWrapper.cpp + libinputactions/scripting/ScriptAction.cpp + libinputactions/scripting/ScriptCondition.cpp + libinputactions/scripting/ScriptingEngine.cpp libinputactions/triggers/core/DirectionalMotionTriggerCore.cpp libinputactions/triggers/core/MotionTriggerCore.cpp libinputactions/triggers/core/StrokeTriggerCore.cpp @@ -132,6 +138,7 @@ target_link_libraries(libinputactions PUBLIC libevdev-cpp Qt6::Core Qt6::DBus + Qt6::Qml ${LIBEVDEV_LIBRARIES} ) target_compile_definitions(libinputactions PUBLIC TEST_VIRTUAL=$,virtual,>) diff --git a/src/libinputactions/InputActionsMain.cpp b/src/libinputactions/InputActionsMain.cpp index e598ad7..2fd7b90 100644 --- a/src/libinputactions/InputActionsMain.cpp +++ b/src/libinputactions/InputActionsMain.cpp @@ -22,6 +22,7 @@ #include "interfaces/implementations/DBusPlasmaGlobalShortcutInvoker.h" #include "interfaces/implementations/FileConfigProvider.h" #include "interfaces/implementations/ProcessRunnerImpl.h" +#include "scripting/ScriptingEngine.h" #include "variables/VariableRegistry.h" #include #include @@ -53,6 +54,7 @@ InputActionsMain::~InputActionsMain() g_globalConfig.reset(); g_configProvider.reset(); g_inputBackend.reset(); + g_scriptingEngine.reset(); g_strokeRecorder.reset(); g_variableRegistry.reset(); } @@ -98,6 +100,7 @@ void InputActionsMain::setMissingImplementations() setMissingImplementation(g_configLoader); setMissingImplementation(g_globalConfig); setMissingImplementation(g_inputBackend); + setMissingImplementation(g_scriptingEngine); setMissingImplementation(g_strokeRecorder); setMissingImplementation(g_variableRegistry); } diff --git a/src/libinputactions/config/ConfigIssue.cpp b/src/libinputactions/config/ConfigIssue.cpp index 6c51c67..6fdb901 100644 --- a/src/libinputactions/config/ConfigIssue.cpp +++ b/src/libinputactions/config/ConfigIssue.cpp @@ -18,9 +18,12 @@ #include "ConfigIssue.h" #include "ConfigIssueManager.h" +#include "ConfigLoader.h" #include "Node.h" #include #include +#include +#include namespace InputActions { @@ -183,6 +186,17 @@ QString UnusedPropertyConfigIssue::message() const return QString("Property '%1' does not exist or has no effect in this context.").arg(m_property); } +UncaughtScriptErrorConfigIssue::UncaughtScriptErrorConfigIssue(const Node *node, QJSValue error) + : ConfigIssue(node) + , m_message(g_configLoader->futureScriptingEngine().errorToString(error)) +{ +} + +QString UncaughtScriptErrorConfigIssue::message() const +{ + return QString("Uncaught script error\n\n%1").arg(m_message); +} + MissingRequiredPropertyConfigException::MissingRequiredPropertyConfigException(const Node *node, QString property) : ConfigException(node) , m_property(std::move(property)) @@ -194,6 +208,17 @@ QString MissingRequiredPropertyConfigException::message() const return QString("Required property '%1' was not specified.").arg(m_property); } +UncaughtScriptErrorConfigException::UncaughtScriptErrorConfigException(const Node *node, QJSValue error) + : ConfigException(node) + , m_message(g_configLoader->futureScriptingEngine().errorToString(error)) +{ +} + +QString UncaughtScriptErrorConfigException::message() const +{ + return QString("Uncaught script error\n\n%1").arg(m_message); +} + YamlCppConfigException::YamlCppConfigException(TextPosition position, QString message) : ConfigException(position) , m_message(std::move(message)) diff --git a/src/libinputactions/config/ConfigIssue.h b/src/libinputactions/config/ConfigIssue.h index a4e591f..95482a5 100644 --- a/src/libinputactions/config/ConfigIssue.h +++ b/src/libinputactions/config/ConfigIssue.h @@ -19,6 +19,7 @@ #pragma once #include "TextPosition.h" +#include #include #include @@ -121,6 +122,19 @@ class UnusedPropertyConfigIssue QString m_property; }; +class UncaughtScriptErrorConfigIssue + : public ConfigIssue + , public virtual Copyable +{ +public: + UncaughtScriptErrorConfigIssue(const Node *node, QJSValue error); + + QString message() const override; + +private: + QString m_message; +}; + class DuplicateSetItemConfigException : public ConfigException , public virtual Copyable @@ -212,6 +226,19 @@ class MissingRequiredPropertyConfigException QString m_property; }; +class UncaughtScriptErrorConfigException + : public ConfigException + , public virtual Copyable +{ +public: + UncaughtScriptErrorConfigException(const Node *node, QJSValue error); + + QString message() const override; + +private: + QString m_message; +}; + class YamlCppConfigException : public ConfigException , public virtual Copyable diff --git a/src/libinputactions/config/ConfigLoader.cpp b/src/libinputactions/config/ConfigLoader.cpp index c984130..19f0995 100644 --- a/src/libinputactions/config/ConfigLoader.cpp +++ b/src/libinputactions/config/ConfigLoader.cpp @@ -20,6 +20,7 @@ #include "ConfigIssueManager.h" #include "GlobalConfig.h" #include "Node.h" +#include "config/ConfigIssue.h" #include "interfaces/ConfigProvider.h" #include "parsers/containers.h" #include "parsers/core.h" @@ -33,6 +34,7 @@ #include #include #include +#include namespace InputActions { @@ -52,6 +54,8 @@ struct Config std::vector deviceRules; std::set emergencyCombination = {KEY_BACKSPACE, KEY_SPACE, KEY_ENTER}; + + std::unique_ptr scriptingEngine = std::make_unique(); }; void ConfigLoader::loadEmpty() @@ -97,6 +101,21 @@ Config ConfigLoader::createConfig(const QString &raw) } Config config; + m_scriptingEngine = config.scriptingEngine.get(); + + if (const auto *scriptingNode = root->mapAt("scripting")) { + if (const auto *scriptsNode = scriptingNode->at("scripts")) { + for (const auto *scriptNode : scriptsNode->sequenceItems()) { + const auto *sourceNode = scriptNode->at("source", true); + const auto source = sourceNode->as(); + const auto result = m_scriptingEngine->evaluate(sourceNode->as()); + if (result.isError()) { + throw UncaughtScriptErrorConfigException(sourceNode, result); + } + } + } + } + loadMember(config.autoReload, root->at("autoreload")); loadMember(config.allowExternalVariableAccess, root->at("external_variable_access")); if (const auto *notificationsNode = root->mapAt("notifications")) { @@ -133,6 +152,7 @@ void ConfigLoader::activateConfig(Config config, bool initialize) g_inputBackend->reset(); // Okay because required keys are not cleared g_actionExecutor->clearQueue(); g_actionExecutor->waitForDone(); + g_scriptingEngine.reset(); g_globalConfig->setAllowExternalVariableAccess(config.allowExternalVariableAccess); g_globalConfig->setAutoReload(config.autoReload); @@ -150,6 +170,7 @@ void ConfigLoader::activateConfig(Config config, bool initialize) g_inputBackend->setDeviceRules(config.deviceRules); g_inputBackend->setEmergencyCombination(config.emergencyCombination); + g_scriptingEngine = std::move(config.scriptingEngine); if (initialize) { g_inputBackend->initialize(); } diff --git a/src/libinputactions/config/ConfigLoader.h b/src/libinputactions/config/ConfigLoader.h index b802531..fd123ae 100644 --- a/src/libinputactions/config/ConfigLoader.h +++ b/src/libinputactions/config/ConfigLoader.h @@ -18,6 +18,7 @@ #pragma once +#include #include #include #include @@ -26,6 +27,7 @@ namespace InputActions { struct Config; +class ScriptingEngine; struct ConfigLoadSettings { @@ -42,6 +44,11 @@ struct ConfigLoadSettings class ConfigLoader { public: + /** + * Scripting engine for the configuration that is currently being loaded. + */ + ScriptingEngine &futureScriptingEngine() const { return *m_scriptingEngine; } + /** * @return Whether the operation was successful. Errors may be obtained from ConfigIssueManager. */ @@ -55,6 +62,8 @@ class ConfigLoader private: Config createConfig(const QString &raw); void activateConfig(Config config, bool initialize); + + ScriptingEngine *m_scriptingEngine; }; inline std::shared_ptr g_configLoader; diff --git a/src/libinputactions/config/parsers/core.cpp b/src/libinputactions/config/parsers/core.cpp index 04d7ddf..598d00f 100644 --- a/src/libinputactions/config/parsers/core.cpp +++ b/src/libinputactions/config/parsers/core.cpp @@ -20,6 +20,7 @@ #include "containers.h" #include "flags.h" #include "globals.h" +#include "scripting.h" #include "separated-string.h" #include "triggers.h" #include "utils.h" @@ -39,6 +40,7 @@ #include #include #include +#include #include #include #include @@ -51,6 +53,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -158,6 +163,8 @@ void NodeParser>::parse(const Node *node, std::unique_pt result = std::make_unique(shortcut.first, shortcut.second); } else if (const auto *replaceTextNode = node->at("replace_text")) { result = std::make_unique(replaceTextNode->as>(true)); + } else if (const auto *scriptActionNode = node->at("script")) { + result = std::make_unique(parseFunction(scriptActionNode), scriptActionNode->shared_from_this()); } else if (const auto *sleepActionNode = node->at("sleep")) { result = std::make_unique(sleepActionNode->as()); } else if (const auto *oneNode = node->at("one")) { @@ -225,6 +232,9 @@ std::shared_ptr parseCondition(const Node *node, const VariableRegist if (const auto *canReplaceTextNode = node->at("can_replace_text")) { return std::make_shared(canReplaceTextNode->as>(true)); } + if (const auto *scriptNode = node->at("script")) { + return std::make_shared(parseFunction(scriptNode), scriptNode->shared_from_this()); + } if (isLegacy(node)) { g_configIssueManager->addIssue(DeprecatedFeatureConfigIssue(node, DeprecatedFeature::LegacyConditions)); diff --git a/src/libinputactions/config/parsers/scripting.cpp b/src/libinputactions/config/parsers/scripting.cpp new file mode 100644 index 0000000..97e1994 --- /dev/null +++ b/src/libinputactions/config/parsers/scripting.cpp @@ -0,0 +1,40 @@ +/* + Input Actions - Input handler that executes user-defined actions + Copyright (C) 2024-2026 Marcin Woźniak + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +#include "scripting.h" +#include +#include +#include + +namespace InputActions +{ + +QJSValue parseFunction(const Node *node) +{ + auto result = g_configLoader->futureScriptingEngine().evaluate(node->as()); + if (result.isError()) { + throw UncaughtScriptErrorConfigException(node, result); + } + if (!result.isCallable()) { + throw InvalidValueConfigException(node, "Expression is not a function."); + } + + return result; +} + +} \ No newline at end of file diff --git a/src/libinputactions/config/parsers/scripting.h b/src/libinputactions/config/parsers/scripting.h new file mode 100644 index 0000000..87f31f0 --- /dev/null +++ b/src/libinputactions/config/parsers/scripting.h @@ -0,0 +1,30 @@ +/* + Input Actions - Input handler that executes user-defined actions + Copyright (C) 2024-2026 Marcin Woźniak + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +#pragma once + +#include + +namespace InputActions +{ + +class Node; + +QJSValue parseFunction(const Node *node); + +} \ No newline at end of file diff --git a/src/libinputactions/scripting/FunctionWrapper.cpp b/src/libinputactions/scripting/FunctionWrapper.cpp new file mode 100644 index 0000000..3d2a407 --- /dev/null +++ b/src/libinputactions/scripting/FunctionWrapper.cpp @@ -0,0 +1,35 @@ +/* + Input Actions - Input handler that executes user-defined actions + Copyright (C) 2024-2026 Marcin Woźniak + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +#include "FunctionWrapper.h" + +namespace InputActions +{ + +FunctionWrapper::FunctionWrapper(QJSEngine *engine, std::function function) + : m_engine(engine) + , m_function(std::move(function)) +{ +} + +QJSValue FunctionWrapper::call(QJSValueList args) +{ + return m_function(args); +} + +} \ No newline at end of file diff --git a/src/libinputactions/scripting/FunctionWrapper.h b/src/libinputactions/scripting/FunctionWrapper.h new file mode 100644 index 0000000..2c9f10b --- /dev/null +++ b/src/libinputactions/scripting/FunctionWrapper.h @@ -0,0 +1,76 @@ +/* + Input Actions - Input handler that executes user-defined actions + Copyright (C) 2024-2026 Marcin Woźniak + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +#pragma once + +#include +#include + +namespace InputActions +{ + +/** + * Wraps a function in a QObject to make it invokable from JavaScript. + */ +class FunctionWrapper : public QObject +{ + Q_OBJECT + +public: + template + static FunctionWrapper *create(QJSEngine *engine, TFunction &&func) + { + std::function function = std::forward(func); + const auto wrapper = [engine, function = std::move(function)](QJSValueList args) -> QJSValue { + if (args.size() != sizeof...(TArgs)) { + engine->throwError(QString("Invalid argument count.")); + return {}; + } + + return call(engine, function, args, std::index_sequence_for()); + }; + + return new FunctionWrapper(engine, wrapper); + } + + Q_INVOKABLE QJSValue call(QJSValueList args); + +private: + FunctionWrapper(QJSEngine *engine, std::function function); + + template + static QJSValue call(QJSEngine *engine, const std::function &function, const QJSValueList &args, std::index_sequence) + { + if constexpr (std::is_void_v) { + function(engine->fromScriptValue(args[I])...); + return {}; + } + + const auto result = function(engine->fromScriptValue(args[I])...); + if constexpr (std::is_same_v) { + return result; + } + + return engine->toScriptValue(std::move(result)); + } + + QJSEngine *m_engine; + std::function m_function; +}; + +} \ No newline at end of file diff --git a/src/libinputactions/scripting/ScriptAction.cpp b/src/libinputactions/scripting/ScriptAction.cpp new file mode 100644 index 0000000..91f5e4b --- /dev/null +++ b/src/libinputactions/scripting/ScriptAction.cpp @@ -0,0 +1,41 @@ +/* + Input Actions - Input handler that executes user-defined actions + Copyright (C) 2024-2026 Marcin Woźniak + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +#include "ScriptAction.h" +#include +#include +#include + +namespace InputActions +{ + +ScriptAction::ScriptAction(QJSValue function, std::shared_ptr sourceNode) + : m_function(std::move(function)) + , m_sourceNode(std::move(sourceNode)) +{ +} + +void ScriptAction::doExecute(const ActionExecutionArguments &args) +{ + const auto result = g_scriptingEngine->call(m_function); + if (result.isError() && m_sourceNode) { + g_configIssueManager->addIssue(UncaughtScriptErrorConfigIssue(m_sourceNode.get(), result)); + } +} + +} \ No newline at end of file diff --git a/src/libinputactions/scripting/ScriptAction.h b/src/libinputactions/scripting/ScriptAction.h new file mode 100644 index 0000000..7f30f93 --- /dev/null +++ b/src/libinputactions/scripting/ScriptAction.h @@ -0,0 +1,46 @@ +/* + Input Actions - Input handler that executes user-defined actions + Copyright (C) 2024-2026 Marcin Woźniak + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +#pragma once + +#include +#include + +namespace InputActions +{ + +class Node; + +class ScriptAction : public Action +{ +public: + /** + * @param function Called with no arguments when the action is executed. The return value is ignored. + * @param sourceNode The configuration node this action was defined in. May be nullptr. + */ + ScriptAction(QJSValue function, std::shared_ptr sourceNode); + +protected: + void doExecute(const ActionExecutionArguments &args) override; + +private: + QJSValue m_function; + std::shared_ptr m_sourceNode; +}; + +} \ No newline at end of file diff --git a/src/libinputactions/scripting/ScriptCondition.cpp b/src/libinputactions/scripting/ScriptCondition.cpp new file mode 100644 index 0000000..187aeff --- /dev/null +++ b/src/libinputactions/scripting/ScriptCondition.cpp @@ -0,0 +1,44 @@ +/* + Input Actions - Input handler that executes user-defined actions + Copyright (C) 2024-2026 Marcin Woźniak + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +#include "ScriptCondition.h" +#include +#include +#include + +namespace InputActions +{ + +ScriptCondition::ScriptCondition(QJSValue function, std::shared_ptr sourceNode) + : m_function(std::move(function)) + , m_sourceNode(std::move(sourceNode)) +{ +} + +bool ScriptCondition::doEvaluate(const ConditionEvaluationArguments &arguments) +{ + const auto result = g_scriptingEngine->call(m_function); + if (result.isError() && m_sourceNode) { + g_configIssueManager->addIssue(UncaughtScriptErrorConfigIssue(m_sourceNode.get(), result)); + return false; + } + + return result.toBool(); +} + +} \ No newline at end of file diff --git a/src/libinputactions/scripting/ScriptCondition.h b/src/libinputactions/scripting/ScriptCondition.h new file mode 100644 index 0000000..15f6dc2 --- /dev/null +++ b/src/libinputactions/scripting/ScriptCondition.h @@ -0,0 +1,46 @@ +/* + Input Actions - Input handler that executes user-defined actions + Copyright (C) 2024-2026 Marcin Woźniak + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +#pragma once + +#include +#include + +namespace InputActions +{ + +class Node; + +class ScriptCondition : public Condition +{ +public: + /** + * @param function Called with no arguments when the condition is evaluated. The condition is satisfied when the return value is true. + * @param sourceNode The configuration node this action was defined in. May be nullptr. + */ + ScriptCondition(QJSValue function, std::shared_ptr sourceNode); + +protected: + bool doEvaluate(const ConditionEvaluationArguments &arguments) override; + +private: + QJSValue m_function; + std::shared_ptr m_sourceNode; +}; + +} \ No newline at end of file diff --git a/src/libinputactions/scripting/ScriptingEngine.cpp b/src/libinputactions/scripting/ScriptingEngine.cpp new file mode 100644 index 0000000..b0a2de0 --- /dev/null +++ b/src/libinputactions/scripting/ScriptingEngine.cpp @@ -0,0 +1,148 @@ +/* + Input Actions - Input handler that executes user-defined actions + Copyright (C) 2024-2026 Marcin Woźniak + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +#include "ScriptingEngine.h" +#include "modules/core/CoreModule.h" +#include +#include +#include +#include + +Q_LOGGING_CATEGORY(INPUTACTIONS_SCRIPTING, "inputactions.scripting", QtWarningMsg) + +namespace InputActions +{ + +static const std::chrono::milliseconds WATCHDOG_TIMER_TIMEOUT{2000}; +static const std::chrono::milliseconds WATCHDOG_TIMER_RESET_INTERVAL{1000}; + +ScriptingEngine::~ScriptingEngine() +{ + if (!m_engine) { + return; + } + + QMetaObject::invokeMethod(m_watchdogTimer, "stop", Qt::BlockingQueuedConnection); + m_watchdogTimerThread->quit(); + m_watchdogTimerThread->wait(); + + m_watchdogTimer->deleteLater(); + m_watchdogTimerThread->deleteLater(); +} + +void ScriptingEngine::initialize() +{ + m_engine.emplace(); + m_engine->installExtensions(QJSEngine::ConsoleExtension); + + initializeWatchdog(); + + registerBuiltinModule("inputactions/core", m_engine->newQObject(new CoreModule)); + + auto globalObject = m_engine->globalObject(); + globalObject.setProperty("require", newFunction([this](QString module) { + if (m_builtinModules.contains(module)) { + return m_builtinModules[module]; + } + + return m_engine->importModule(module); + })); +} + +void ScriptingEngine::initializeWatchdog() +{ + m_watchdogTimerThread = new QThread; + m_watchdogTimer = new QTimer; + + m_watchdogTimer->setInterval(WATCHDOG_TIMER_TIMEOUT); + m_watchdogTimer->moveToThread(m_watchdogTimerThread); + connect(m_watchdogTimer, &QTimer::timeout, [this]() { + m_engine->setInterrupted(true); + QThreadHelpers::runOnThread(QThreadHelpers::mainThread(), []() { + g_notificationManager + ->sendNotification("Infinite loop detected", + "A script has likely entered an infinite loop and frozen the main thread. InputActions has been suspended."); + g_inputActions->suspend(); + }); + }); + m_watchdogTimerThread->start(); + + connect(&m_watchdogRestartTimer, &QTimer::timeout, this, &ScriptingEngine::onWatchdogValueRestartTimerTick); + m_watchdogRestartTimer.setInterval(WATCHDOG_TIMER_RESET_INTERVAL); + m_watchdogRestartTimer.start(); +} + +void ScriptingEngine::registerBuiltinModule(const QString &name, QJSValue value) +{ + ensureEngine().registerModule(name, value); + m_builtinModules[name] = std::move(value); +} + +QJSValue ScriptingEngine::evaluate(const QString &script) +{ + const auto result = ensureEngine().evaluate(script); + if (result.isError()) { + logError(result); + } + + return result; +} + +QJSValue ScriptingEngine::call(const QJSValue &function, const QJSValueList &args) const +{ + const auto result = function.call(args); + if (result.isError()) { + logError(result); + } + + return result; +} + +QString ScriptingEngine::errorToString(const QJSValue &error) const +{ + const auto name = error.property("name").toString(); + const auto message = error.property("message").toString(); + auto file = error.property("fileName").toString(); + if (file.isEmpty()) { + file = "None (defined in a YAML file)"; + } + const auto lineNumber = error.property("lineNumber").toUInt(); + const auto stack = error.property("stack").toString(); + + return QString("%1: %2\nFile: %3\nLine: %4\nStack:\n%5\n").arg(name, message, file, QString::number(lineNumber), QStringHelpers::indented(stack, 4)); +} + +void ScriptingEngine::logError(const QJSValue &error) const +{ + qCCritical(INPUTACTIONS_SCRIPTING).nospace().noquote() << "Uncaught script error\n" << errorToString(error); +} + +QJSEngine &ScriptingEngine::ensureEngine() +{ + if (!m_engine) { + initialize(); + } + return m_engine.value(); +} + +void ScriptingEngine::onWatchdogValueRestartTimerTick() +{ + QMetaObject::invokeMethod(m_watchdogTimer, "start", Qt::QueuedConnection); +} + +} \ No newline at end of file diff --git a/src/libinputactions/scripting/ScriptingEngine.h b/src/libinputactions/scripting/ScriptingEngine.h new file mode 100644 index 0000000..31dbf95 --- /dev/null +++ b/src/libinputactions/scripting/ScriptingEngine.h @@ -0,0 +1,89 @@ +/* + Input Actions - Input handler that executes user-defined actions + Copyright (C) 2024-2026 Marcin Woźniak + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +#pragma once + +#include "FunctionWrapper.h" +#include +#include +#include + +namespace InputActions +{ + +/** + * Lazily initializated. + */ +class ScriptingEngine : public QObject +{ + Q_OBJECT + +public: + ScriptingEngine() = default; + ~ScriptingEngine() override; + + /** + * Same as QJSEngine::evaluate but with error logging. + */ + QJSValue evaluate(const QString &script); + + /** + * Same as QJSValue::call but with error logging. + */ + QJSValue call(const QJSValue &function, const QJSValueList &args = {}) const; + + QString errorToString(const QJSValue &error) const; + void logError(const QJSValue &error) const; + + template + QJSValue newFunction(TFunction &&function) + { + auto &engine = ensureEngine(); + auto *wrapper = FunctionWrapper::create(&engine, std::forward(function)); + return evaluate(QString("obj => (...args) => obj.call(args);")).call({engine.newQObject(wrapper)}); + } + +private slots: + void onWatchdogValueRestartTimerTick(); + +private: + /** + * Returns an instance of the engine. Initializes the engine if it has not been initialized yet. + */ + QJSEngine &ensureEngine(); + void initialize(); + void initializeWatchdog(); + + void registerBuiltinModule(const QString &name, QJSValue value); + + std::optional m_engine; + std::map m_builtinModules; + + QThread *m_watchdogTimerThread{}; + QTimer *m_watchdogTimer{}; + QTimer m_watchdogRestartTimer; +}; + +/** + * Do not use this instance during the creation of a configuration, use ConfigLoader::futureScriptingEngine instead. + * + * A new instance is created on each config activation. + */ +inline std::unique_ptr g_scriptingEngine; + +} \ No newline at end of file diff --git a/src/libinputactions/scripting/modules/core/CoreModule.cpp b/src/libinputactions/scripting/modules/core/CoreModule.cpp new file mode 100644 index 0000000..35ac5aa --- /dev/null +++ b/src/libinputactions/scripting/modules/core/CoreModule.cpp @@ -0,0 +1,30 @@ +/* + Input Actions - Input handler that executes user-defined actions + Copyright (C) 2024-2026 Marcin Woźniak + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +#include "CoreModule.h" +#include + +namespace InputActions +{ + +VariableRegistry *CoreModule::variableRegistry() const +{ + return g_variableRegistry.get(); +} + +} \ No newline at end of file diff --git a/src/libinputactions/scripting/modules/core/CoreModule.h b/src/libinputactions/scripting/modules/core/CoreModule.h new file mode 100644 index 0000000..14e3b17 --- /dev/null +++ b/src/libinputactions/scripting/modules/core/CoreModule.h @@ -0,0 +1,37 @@ +/* + Input Actions - Input handler that executes user-defined actions + Copyright (C) 2024-2026 Marcin Woźniak + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +#pragma once + +#include + +namespace InputActions +{ + +class VariableRegistry; + +class CoreModule : public QObject +{ + Q_OBJECT + Q_PROPERTY(VariableRegistry *variableRegistry READ variableRegistry) + +public: + VariableRegistry *variableRegistry() const; +}; + +} \ No newline at end of file diff --git a/src/libinputactions/variables/Variable.cpp b/src/libinputactions/variables/Variable.cpp index 3524b3d..07d260e 100644 --- a/src/libinputactions/variables/Variable.cpp +++ b/src/libinputactions/variables/Variable.cpp @@ -17,6 +17,7 @@ */ #include "Variable.h" +#include namespace InputActions { @@ -25,6 +26,7 @@ Variable::Variable(QMetaType type) : m_type(std::move(type)) , m_operations(VariableOperationsBase::create(this)) { + QJSEngine::setObjectOwnership(this, QJSEngine::CppOwnership); } const VariableOperationsBase *Variable::operations() const diff --git a/src/libinputactions/variables/Variable.h b/src/libinputactions/variables/Variable.h index e72a6c1..21e308f 100644 --- a/src/libinputactions/variables/Variable.h +++ b/src/libinputactions/variables/Variable.h @@ -19,14 +19,17 @@ #pragma once #include "VariableOperations.h" +#include #include -#include namespace InputActions { -class Variable +class Variable : public QObject { + Q_OBJECT + Q_PROPERTY(QVariant value READ value) + public: Variable(QMetaType type); virtual ~Variable() = default; diff --git a/src/libinputactions/variables/VariableRegistry.h b/src/libinputactions/variables/VariableRegistry.h index 90f4641..9aa6cf9 100644 --- a/src/libinputactions/variables/VariableRegistry.h +++ b/src/libinputactions/variables/VariableRegistry.h @@ -21,6 +21,7 @@ #include "ComputedVariable.h" #include "StoredVariable.h" #include "VariableWrapper.h" +#include #include #include #include @@ -56,11 +57,18 @@ struct BuiltinVariables inline static const VariableInfo LastTriggerTimestamp{QStringLiteral("last_trigger_timestamp")}; }; -class VariableRegistry +class VariableRegistry : public QObject { + Q_OBJECT + public: VariableRegistry(); - ~VariableRegistry(); + ~VariableRegistry() override; + + /** + * @return The variable with the specified name or nullptr if not found. + */ + Q_INVOKABLE Variable *variable(const QString &name) const; template std::optional> variable(const VariableInfo &info) const @@ -87,10 +95,6 @@ class VariableRegistry } bool contains(const QString &name) const; - /** - * @return The variable with the specified name or nullptr if not found. - */ - Variable *variable(const QString &name) const; Variable *registerVariable(const QString &name, std::unique_ptr variable, bool hidden = false); template diff --git a/tests/libinputactions/Test.cpp b/tests/libinputactions/Test.cpp index 8757498..1f85cf9 100644 --- a/tests/libinputactions/Test.cpp +++ b/tests/libinputactions/Test.cpp @@ -8,6 +8,9 @@ namespace InputActions void Test::initMain() { + int argc = 0; + QCoreApplication app(argc, nullptr); + auto *inputActions = new InputActionsMain; g_configProvider = std::make_shared(); // don't watch config inputActions->setMissingImplementations();