diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 64928065..59cb4bfb 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -98,11 +98,11 @@ variables:
# See the documentation here: #
# https://wayland.freedesktop.org/libinput/doc/latest/building.html #
###############################################################################
- FEDORA_PACKAGES: 'git-core gcc gcc-c++ pkgconf-pkg-config meson check-devel libudev-devel libevdev-devel doxygen graphviz python3-sphinx python3-recommonmark python3-sphinx_rtd_theme python3-pytest-xdist libwacom-devel cairo-devel gtk4-devel glib2-devel mtdev-devel diffutils wayland-protocols-devel black clang clang-tools-extra jq rpmdevtools valgrind systemd-udev qemu-img qemu-system-x86-core qemu-system-aarch64-core jq python3-click python3-rich virtme-ng'
- DEBIAN_PACKAGES: 'git gcc g++ pkg-config meson check libudev-dev libevdev-dev doxygen graphviz python3-sphinx python3-recommonmark python3-sphinx-rtd-theme python3-pytest-xdist libwacom-dev libcairo2-dev libgtk-3-dev libglib2.0-dev libmtdev-dev curl'
- UBUNTU_PACKAGES: 'git gcc g++ pkg-config meson check libudev-dev libevdev-dev doxygen graphviz python3-sphinx python3-recommonmark python3-sphinx-rtd-theme python3-pytest-xdist libwacom-dev libcairo2-dev libgtk-3-dev libglib2.0-dev libmtdev-dev'
- ARCH_PACKAGES: 'git gcc pkgconfig meson check libsystemd libevdev python-pytest-xdist libwacom gtk4 mtdev diffutils'
- ALPINE_PACKAGES: 'git gcc build-base pkgconfig meson check-dev eudev-dev libevdev-dev libwacom-dev cairo-dev gtk4.0-dev mtdev-dev bash'
+ FEDORA_PACKAGES: 'git-core gcc gcc-c++ pkgconf-pkg-config meson check-devel libudev-devel libevdev-devel doxygen graphviz python3-sphinx python3-recommonmark python3-sphinx_rtd_theme python3-pytest-xdist libwacom-devel cairo-devel gtk4-devel glib2-devel mtdev-devel diffutils wayland-protocols-devel black clang clang-tools-extra jq rpmdevtools valgrind systemd-udev qemu-img qemu-system-x86-core qemu-system-aarch64-core jq python3-click python3-rich virtme-ng lua-devel'
+ DEBIAN_PACKAGES: 'git gcc g++ pkg-config meson check libudev-dev libevdev-dev doxygen graphviz python3-sphinx python3-recommonmark python3-sphinx-rtd-theme python3-pytest-xdist libwacom-dev libcairo2-dev libgtk-3-dev libglib2.0-dev libmtdev-dev curl lua5.4-dev'
+ UBUNTU_PACKAGES: 'git gcc g++ pkg-config meson check libudev-dev libevdev-dev doxygen graphviz python3-sphinx python3-recommonmark python3-sphinx-rtd-theme python3-pytest-xdist libwacom-dev libcairo2-dev libgtk-3-dev libglib2.0-dev libmtdev-dev lua5.4-dev'
+ ARCH_PACKAGES: 'git gcc pkgconfig meson check libsystemd libevdev python-pytest-xdist libwacom gtk4 mtdev diffutils lua'
+ ALPINE_PACKAGES: 'git gcc build-base pkgconfig meson check-dev eudev-dev libevdev-dev libwacom-dev cairo-dev gtk4.0-dev mtdev-dev bash lua5.4-dev'
FREEBSD_PACKAGES: 'git pkgconf meson libepoll-shim libudev-devd libevdev libwacom gtk3 libmtdev bash wayland'
############################ end of package lists #############################
@@ -110,12 +110,12 @@ variables:
# changing these will force rebuilding the associated image
# Note: these tags have no meaning and are not tied to a particular
# libinput version
- FEDORA_TAG: '2025-05-19.0'
- DEBIAN_TAG: '2025-05-19.0'
- UBUNTU_TAG: '2025-05-19.0'
- ARCH_TAG: '2025-05-19.0'
- ALPINE_TAG: '2025-05-19.0'
- FREEBSD_TAG: '2025-05-19.0'
+ FEDORA_TAG: '2025-05-20.0'
+ DEBIAN_TAG: '2025-05-20.0'
+ UBUNTU_TAG: '2025-05-20.0'
+ ARCH_TAG: '2025-05-20.0'
+ ALPINE_TAG: '2025-05-20.0'
+ FREEBSD_TAG: '2025-05-20.0'
FDO_UPSTREAM_REPO: libinput/libinput
@@ -796,6 +796,19 @@ vm-pointer-no-libwacom:
variables:
MESON_ARGS: '-Dlibwacom=false'
+vm-lua:
+ extends:
+ - .fedora:42@test-suite-vm
+ variables:
+ SUITE_NAMES: 'lua'
+
+vm-lua-no-libwacom:
+ extends:
+ - vm-lua
+ stage: test-suite-no-libwacom
+ variables:
+ MESON_ARGS: '-Dlibwacom=false'
+
vm-valgrind-touchpad:
stage: valgrind
@@ -1005,6 +1018,20 @@ vm-valgrind-pointer:
rules:
- if: $GITLAB_USER_LOGIN != "marge-bot"
+vm-valgrind-lua:
+ stage: valgrind
+ extends:
+ - vm-lua
+ - .policy-retry-on-failure
+ variables:
+ MESON_TEST_ARGS: '--setup=valgrind'
+ LITEST_JOBS: 0
+ retry:
+ max: 2
+ rules:
+ - if: $GITLAB_USER_LOGIN != "marge-bot"
+
+
.fedora-build@template:
extends:
@@ -1067,6 +1094,20 @@ build-no-mtdev-nodeps@fedora:42:
before_script:
- dnf remove -y mtdev mtdev-devel
+build-no-lua@fedora:42:
+ extends:
+ - .fedora-build@template
+ variables:
+ MESON_ARGS: "-Dlua-plugins=disabled"
+
+build-no-lua-nodeps@fedora:42:
+ extends:
+ - .fedora-build@template
+ variables:
+ MESON_ARGS: "-Dlua-plugins=disabled"
+ before_script:
+ - dnf remove -y lua lua-devel
+
build-docs@fedora:42:
extends:
- .fedora-build@template
@@ -1163,6 +1204,7 @@ check-test-suites:
libinput-test-suite-totem
libinput-test-suite-touch
libinput-test-suite-pointer
+ libinput-test-suite-lua
EOF
- sort -o ci-testsuites ci-testsuites
- diff -u8 -w ci-testsuites meson-testsuites || (echo "Some test suites are not run in the CI" && false)
diff --git a/.gitlab-ci/ci.template b/.gitlab-ci/ci.template
index 7190f264..007bc1c6 100644
--- a/.gitlab-ci/ci.template
+++ b/.gitlab-ci/ci.template
@@ -492,6 +492,7 @@ vm-valgrind-{{suite.name}}:
- if: $GITLAB_USER_LOGIN != "marge-bot"
{% endfor %}
+
{% endfor %}{# for if distro.use_for_qemu_tests #}
{% for distro in distributions if distro.use_for_custom_build_tests %}
@@ -557,6 +558,20 @@ build-no-mtdev-nodeps@{{distro.name}}:{{version}}:
before_script:
- dnf remove -y mtdev mtdev-devel
+build-no-lua@{{distro.name}}:{{version}}:
+ extends:
+ - .{{distro.name}}-build@template
+ variables:
+ MESON_ARGS: "-Dlua-plugins=disabled"
+
+build-no-lua-nodeps@{{distro.name}}:{{version}}:
+ extends:
+ - .{{distro.name}}-build@template
+ variables:
+ MESON_ARGS: "-Dlua-plugins=disabled"
+ before_script:
+ - dnf remove -y lua lua-devel
+
build-docs@{{distro.name}}:{{version}}:
extends:
- .{{distro.name}}-build@template
diff --git a/.gitlab-ci/config.yml b/.gitlab-ci/config.yml
index 1ff1df6c..bcdf0d6f 100644
--- a/.gitlab-ci/config.yml
+++ b/.gitlab-ci/config.yml
@@ -3,7 +3,7 @@
#
# We're happy to rebuild all containers when one changes.
-.default_tag: &default_tag '2025-05-19.0'
+.default_tag: &default_tag '2025-05-20.0'
distributions:
- name: fedora
@@ -50,6 +50,7 @@ distributions:
- python3-click
- python3-rich
- virtme-ng
+ - lua-devel
- name: debian
tag: *default_tag
versions:
@@ -75,6 +76,7 @@ distributions:
- libglib2.0-dev
- libmtdev-dev
- curl # for the coverity job
+ - lua5.4-dev
- name: ubuntu
tag: *default_tag
versions:
@@ -99,6 +101,7 @@ distributions:
- libgtk-3-dev
- libglib2.0-dev
- libmtdev-dev
+ - lua5.4-dev
- name: arch
tag: *default_tag
versions:
@@ -116,6 +119,7 @@ distributions:
- gtk4
- mtdev
- diffutils
+ - lua
build:
extra_variables:
- "MESON_ARGS: '-Ddocumentation=false'" # python-recommonmark is no longer in the repos
@@ -136,6 +140,7 @@ distributions:
- gtk4.0-dev
- mtdev-dev
- bash
+ - lua5.4-dev
build:
extra_variables:
- "MESON_ARGS: '-Ddocumentation=false' # alpine does not have python-recommonmark"
@@ -227,6 +232,9 @@ test_suites:
- name: pointer
suites:
- pointer
+ - name: lua
+ suites:
+ - lua
vng:
kernel: https://gitlab.freedesktop.org/api/v4/projects/libevdev%2Fhid-tools/packages/generic/kernel-x86_64/v6.14/bzImage
diff --git a/doc/user/index.rst b/doc/user/index.rst
index 4bf8c32b..40b40c9c 100644
--- a/doc/user/index.rst
+++ b/doc/user/index.rst
@@ -12,6 +12,7 @@
troubleshooting
contributing
development
+ lua-plugins
API documentation <@HTTP_DOC_LINK@/api/>
diff --git a/doc/user/lua-plugins.rst b/doc/user/lua-plugins.rst
new file mode 100644
index 00000000..6e581c49
--- /dev/null
+++ b/doc/user/lua-plugins.rst
@@ -0,0 +1,633 @@
+.. _lua_plugins:
+
+==============================================================================
+Lua Plugins
+==============================================================================
+
+libinput provides a plugin system that allows users to modify the behavior
+of devices. For example, a plugin may add or remove axes and/or buttons on a
+device and/or modify the event stream seen by this device before it is passed
+to libinput.
+
+Plugins are implemented in `Lua `_ (version 5.4 or later)
+and are typically loaded from ``/usr/lib{64}/libinput/plugins`` and
+``/etc/libinput/plugins``. Plugins are loaded in alphabetical order and where
+multiple plugins share the same file name, the one in the highest precedence
+directory is used. Plugins in ``/etc`` take precedence over
+plugins in ``/usr``.
+
+Plugins are run sequentially in ascending sort-order (i.e. ``00-foo.lua`` runs
+before ``10-bar.lua``) and each plugin sees the state left by any previous
+plugins.
+
+See the `Lua Reference manual `_ for
+details on the Lua language.
+
+.. note:: Plugins are **not** loaded by default, it is up to the compositor
+ whether to allow plugins. An explicit call to
+ ``libinput_plugin_system_load_plugins()`` is required.
+
+------------------------------------------------------------------------------
+Limitations
+------------------------------------------------------------------------------
+
+Each script runs in its own sandbox and cannot communicate or share state with
+other scripts.
+
+Tables that hold API methods are not writable, i.e. it is not possible
+to overwrite the default functionality of those APIs.
+
+The Lua API available to plugins is limited to the following calls::
+
+ assert error ipairs next pairs tonumber
+ pcall select print tostring type xpcall
+ table string math
+
+It is not possible to e.g. use the ``io`` module from a script.
+
+To use methods on instantiated objects, the method call syntax must be used.
+For example:
+
+.. code-block:: lua
+
+ libinput:register()
+ libinput.register() -- this will fail
+
+------------------------------------------------------------------------------
+When to use plugins
+------------------------------------------------------------------------------
+
+libinput plugins are a relatively niche use-case that typically need to
+address either once-off issues (e.g. those caused by worn-out hardware) or
+user preferences that libinput does not and will not cater for.
+
+Plugins should not be used for issues that can be fixed generically, for
+example via :ref:`device-quirks`.
+
+As a rule of thumb: a plugin should be a once-off that only works for one
+user's hardware. If a plugin can be shared with many users then the plugin
+implements functionality that should be integrated into libinput proper.
+
+------------------------------------------------------------------------------
+Testing plugins
+------------------------------------------------------------------------------
+
+Our :ref:`tools` support plugins if passed the ``--enable-plugins`` commandline
+option. For implementing and testing plugins the easiest commands to test are
+
+- ``libinput debug-events --enable-plugins`` (see :ref:`libinput-debug-events` docs)
+- ``libinput debug-gui --enable-plugins`` (see :ref:`libinput-debug-gui` docs)
+
+Where libinput is built and run from git, the tools will also look for plugins
+in the meson build directory. See the ``plugins/meson.build`` file for details.
+
+.. _plugins_api_lua:
+
+--------------------------------------------------------------------------------
+Lua Plugin API
+--------------------------------------------------------------------------------
+
+Lua plugins sit effectively below libinput and the API is not a
+representation of the libinput API. The API revolves around two types:
+``libinput`` and ``EvdevDevice``. The former is used to register a
+plugin from a script, the latter represents one device that is present
+in the system (but may not have yet been added by libinput).
+
+Typically a script does the following steps:
+
+- register with libinput via ``libinput:register(version)``
+- connect to the ``"new-evdev-device"`` event
+- receive an ``EvdevDevice`` object in the ``"new-evdev-device"`` callback
+
+ - check and/or modify the evdev event codes on the device
+ - connect to the device's ``"evdev-frame"`` event
+
+- receive an :ref:`evdev frame ` in the device's
+ ``"evdev-frame"`` callback
+
+ - check and/or modify the events in that frame
+
+Where multiple plugins are active, the evdev frame passed to the callback is
+the combined frame as processed by all previous plugins in ascending sort order.
+For example, if one plugin discards all button events subsequent plugins will
+never see those button events in the frame.
+
+.. _plugins_api_version_stability:
+
+..............................................................................
+Plugin version stability
+..............................................................................
+
+Plugin API version stability is provided on a best effort basis. We aim to provide
+stable plugin versions for as long as feasible but may need to retire some older
+versions over time. For this reason a plugin can select multiple versions it
+implements, libinput will pick one supported version and adjust the plugin
+behavior to match that version. See the ``libinput:register()`` call for details.
+
+--------------------------------------------------------------------------------
+Lua Plugin API Reference
+--------------------------------------------------------------------------------
+
+
+libinput provides the following globals and types:
+
+.. _plugins_api_evdev_usage:
+
+................................................................................
+Evdev Usages
+................................................................................
+
+Evdev usages are a libinput-specific wrapper around the ``linux/input-event-codes.h``
+evdev types and codes. They are used by libinput internally and are a 32-bit
+combination of ``type << 16 | code``. Each usage carries the type and code and
+is thus simpler to pass around and less prone to type confusion.
+
+For the case where the :ref:`evdev global ` does not
+provide a named constant the value can be crafted manually:
+
+.. code-block:: lua
+
+ type = 0x3 -- EV_REL
+ code = 0x1 -- REL_Y
+ usage = (type << 16) | code
+
+.. _plugins_api_evdev_global:
+
+................................................................................
+The ``evdev`` global
+................................................................................
+
+The ``evdev`` global represents all known :ref:`plugins_api_evdev_usage`,
+effectively in the form:
+
+.. code-block:: lua
+
+ evdev = {
+ ABS_X = (3 << 16) | 0,
+ ABS_Y = (3 << 16) | 1,
+ ...
+ REL_X = (2 << 16) | 0,
+ REL_Y = (2 << 16) | 1,
+ ...
+ }
+
+
+This global is provided for convenience to improve readability in the code.
+Note that the name uses the event code name only but the value is an
+:ref:`Evdev Usage ` (type and code).
+
+See the ``linux/input-event-codes.h`` header file provided by your kernel
+for a list of all evdev types and codes.
+
+The evdev global also provides the bus type constants, e.g. ``evdev.BUS_USB``.
+See the ``linux/input.h`` header file provided by your kernel
+for a list of bus types.
+
+
+.. _plugins_api_evdev_frame:
+
+................................................................................
+Evdev frames
+................................................................................
+
+Evdev frames represent a single frame of evdev events for a device. A frame
+is a group of events that occured at the same time. The frame usually only
+contains state that has changed compared to the previous frame.
+
+In our API a frame is exposed as a nested table with the following structure:
+
+.. code-block:: lua
+
+ frame1 = {
+ { usage = evdev.ABS_X, value = 123 },
+ { usage = evdev.ABS_Y, value = 456 },
+ { usage = evdev.BTN_LEFT, value = 1 },
+ }
+ frame2 = {
+ { sage = evdev.ABS_Y, value = 457 },
+ }
+ frame3 = {
+ { sage = evdev.ABS_X, value = 124 },
+ { usage = evdev.BTN_LEFT, value = 0 },
+ }
+
+.. note:: This API does not use ``SYN_REPORT`` events, it is implied at the
+ end of the table. Where a plugin writes a ``SYN_REPORT`` into the
+ list of events, that ``SYN_REPORT`` terminates the event frame
+ (similar to writing a ``\0`` into the middle of a C string).
+ A frame containing only a ``SYN_REPORT`` is functionally equivalent
+ to an empty frame.
+
+Events or frames do not have a timestamp. Where a timestamp is required, that
+timestamp is passed as additional argument to the function or return value.
+
+See :ref:`plugins_api_evdev_global` for a list of known usages.
+
+.. warning:: Evdev frames have an implementation-defined size limit of how many
+ events can be added to a single frame. This limit should never be
+ hit by valid plugins.
+
+.. _plugins_api_logglobal:
+
+................................................................................
+The ``log`` global
+................................................................................
+
+The ``log`` global is used to log messages from the plugin through libinput.
+Whether a message is displayed in the log depends on libinput's log priority,
+set by the caller.
+
+.. function:: log.debug(message)
+
+ Log a debug message.
+
+.. function:: log.info(message)
+
+ Log an info message.
+
+.. function:: log.error(message)
+
+ Log an error message.
+
+A compositor may disable stdout and stderr. Log messages should be preferred
+over Lua's ``print()`` function to ensure the messages end up in the same
+location as other libinput log messages and are not discarded.
+
+.. _plugins_api_libinputglobal:
+
+................................................................................
+The ``libinput`` global object
+................................................................................
+
+The core of our plugin's API is the ``libinput`` global object. A script must
+immediately ``register()`` to be active, otherwise it is unloaded immediately.
+
+All libinput-specific APIs can be accessed through the ``libinput`` object.
+
+.. function:: libinput:register({1, 2, ...})
+
+ Register this plugin with the given table of supported version numbers and
+ returns the version number selected by libinput for this plugin. See
+ :ref:`plugins_api_version_stability` for details.
+
+ .. code-block:: lua
+
+ -- this plugin can support versions 1, 4 and 5
+ version = libinput:register({1, 4, 5})
+ if version == 1:
+ ....
+
+ This function must be the first function called.
+ If the plugin calls any other functions before ``register()``, those functions
+ return ``nil``, 0, an empty table, etc.
+
+ If the plugin does not call ``register()`` it will be removed immediately.
+ Once registered, any connected callbacks will be invoked whenever libinput
+ detects new devices, removes devices, etc.
+
+ This function must only be called once.
+
+.. function:: libinput:unregister()
+
+ Unregister this plugin. This removes the plugin from libinput and releases
+ any resources. This call must be the last call in your plugin, it is
+ effectively equivalent to Lua's
+ `os.exit() `_.
+
+.. function:: libinput:now()
+
+ Returns the current time in microseconds in ``CLOCK_MONOTONIC``. This is
+ the timestamp libinput uses internally. This timestamp cannot be mapped
+ to any particular time of day, see the
+ `clock_gettime() man page `_
+ for details.
+
+.. function:: libinput:version()
+
+ Returns the agreed-on version of the plugin, see ``libinput:register()``.
+ If called before ``libinput:register()`` this function returns 0.
+
+.. function:: libinput:connect(name, function)
+
+ Set the callback to the given event name. Only one callback
+ may be set for an event name at any time, subsequent callbacks
+ will replace any earlier callbacks for the same name.
+
+ Version 1 of the plugin API supports the following events and callback arguments:
+
+ - ``"new-evdev-device"``: A new :ref:`EvdevDevice `
+ has been seen by libinput but not yet added.
+
+ .. code-block:: lua
+
+ libinput:connect("new-evdev-device", function (device) ... end)
+
+ - ``"timer-expired"``: The timer for this plugin has expired. This event is
+ only sent if the plugin has set a timer with ``timer_set()``.
+
+ .. code-block:: lua
+
+ libinput:connect("timer-expired", function (plugin, now) ... end)
+
+ The ``now`` argument is the current time in microseconds in
+ ``CLOCK_MONOTONIC`` (see ``libinput.now()``).
+
+.. function:: libinput:timer_cancel()
+
+ Cancel the timer for this plugin. This is a no-op if the timer
+ has not been set or has already expired.
+
+.. function:: libinput:timer_set_absolute(time)
+
+ Set a timer for this plugin, with the given time in microseconds.
+ The timeout specifies an absolute time in microseconds (see
+ ``libinput.now()``) The timer will expire once and then call the
+ ``"timer-expired"`` event handler (if any).
+
+ See ``libinput:timer_set_relative()`` for a relative timer.
+
+ The following two lines of code are equivalent:
+
+ .. code-block:: lua
+
+ libinput:timer_set_relative(1000000) -- 1 second from now
+ libinput:timer_set_absolute(libinput.now() + 1000000) -- 1 second from now
+
+ Calling this function will cancel any existing (relative or absolute) timer.
+
+.. function:: libinput:timer_set_relative(timeout)
+
+ Set a timer for this plugin, with the given timeout in microseconds from
+ the current time. The timer will expire once and then call the
+ ``"timer-expired"`` event handler (if any).
+
+ See ``libinput:timer_set_absolute()`` for a relative timer.
+
+ The following two lines of code are equivalent:
+
+ .. code-block:: lua
+
+ libinput:timer_set_relative(1000000) -- 1 second from now
+ libinput:timer_set_absolute(libinput.now() + 1000000) -- 1 second from now
+
+ Calling this function will cancel any existing (relative or absolute) timer.
+
+.. _plugins_api_evdevdevice:
+
+................................................................................
+The ``EvdevDevice`` type
+................................................................................
+
+The ``EvdevDevice`` type represents a device available in the system
+but not (yet) added by libinput. This device may be used to modify
+a device's capabilities before the device is processed by libinput.
+
+.. function:: EvdevDevice:info()
+
+ A table containing static information about the device, e.g.
+
+ .. code-block:: lua
+
+ {
+ bustype = evdev.BUS_USB,
+ vid = 0x1234,
+ pid = 0x5678,
+ }
+
+ A plugin must ignore keys it does not know about.
+
+ Version 1 of the plugin API supports the following keys and values:
+
+ - ``bustype``: The numeric bustype of the device. See the
+ ``BUS_*`` defines in ``linux/input.h`` for the list of possible values.
+ - ``vid``: The 16-bit vendor ID of the device
+ - ``pid``: The 16-bit product ID of the device
+
+.. function:: EvdevDevice:name()
+
+ The device name as set by the kernel
+
+.. function:: EvdevDevice:usages()
+
+ Returns a nested table of all usages that are currently enabled for this
+ device. Any type that exists on the device has a table assigned and in this
+ table any code that exists on the device is a boolean true.
+ For example:
+
+ .. code-block:: lua
+
+ {
+ evdev.REL_X = true,
+ evdev.REL_Y = true,
+ evdev.BTN_LEFT = true,
+ }
+
+ All other usage ``nil``, so that the following code is possible:
+
+ .. code-block:: lua
+
+ if code[evdev.REL_X] then
+ -- do something
+ end
+
+
+ If the device has since been discarded by libinput, this function returns an
+ empty table.
+
+.. function:: EvdevDevice:absinfos()
+
+ Returns a table of all ``EV_ABS`` codes that are currently enabled for this device.
+ The event code is the key, each value is a table containing the following keys:
+ ``minimum``, ``maximum``, ``fuzz``, ``flat``, ``resolution``.
+
+ .. code-block:: lua
+
+ {
+ evdev.ABS_X = {
+ minimum = 0,
+ maximum = 1234,
+ fuzz = 0,
+ flat = 0,
+ resolution = 45,
+ },
+ }
+
+ If the device has since been discarded by libinput, this function returns an
+ empty table.
+
+.. function:: EvdevDevice:udev_properties()
+
+ Returns a table containing a filtered list of udev properties available on this device
+ in the form ``{ property_name = property_value, ... }``.
+ udev properties used as a boolean (e.g. ``ID_INPUT``) are only present if their
+ value is a logical true.
+
+ Version 1 of the plugin API supports the following udev properties:
+
+ - ``ID_INPUT`` and all of ``ID_INPUT_*`` that denote the device type as assigned
+ by udev. This information is usually used by libinput to determine a
+ device type. Note that for historical reasons these properties have
+ varying rules - some properties may be mutually exclusive, others are
+ independent, others may only be set if another property is set. Refer to
+ the udev documentation (if any) for details. ``ID_INPUT_WIDTH_MM`` and
+ ``ID_INPUT_HEIGHT_MM`` are excluded from this set.
+
+ If the device has since been discarded by libinput, this function returns an
+ empty table.
+
+.. function:: EvdevDevice:enable_evdev_usage(usage)
+
+ Enable the given :ref:`evdev usage ` for this device.
+ Use :ref:`plugins_api_evdev_global` for better readability,
+ e.g. ``device:enable_evdev_usage(evdev.REL_X)``.
+ This function must not be used for ``ABS_*`` events, use ``set_absinfo()`` instead.
+
+ If the device has since been discarded by libinput, this function does nothing.
+
+.. function:: EvdevDevice:disable_evdev_usage(usage)
+
+ Disable the given :ref:`evdev usage ` for this device.
+ Use :ref:`plugins_api_evdev_global` for better readability,
+ e.g. ``device:disable_evdev_usage(evdev.REL_X)``.
+
+ If the device has since been discarded by libinput, this function does nothing.
+
+.. function:: EvdevDevice:set_absinfo(usage, absinfo)
+
+ Set the absolute axis information for the given :ref:`evdev usage `
+ if it does not yet exist on the device. The ``absinfo`` argument is a table
+ containing zero or more of the following keys: ``min``, ``max``, ``fuzz``,
+ ``flat``, ``resolution``. Any missing key defaults the corresponding
+ value from the device if the device already has this event code or zero otherwise.
+ In other words the following code is enough to change the resolution but leave
+ everything else as-is:
+
+ .. code-block:: lua
+
+ local absinfo = {
+ resolution = 40,
+ }
+ device:set_absinfo(evdev.ABS_X, absinfo)
+ device:set_absinfo(evdev.ABS_Y, absinfo)
+
+ Use :ref:`plugins_api_evdev_global` for better readability as shown in the
+ example above.
+
+ If the device has since been discarded by libinput, this function does nothing.
+
+ .. note:: Overriding the absinfo values often indicates buggy firmware. This should
+ typically be fixed with an entry in the
+ `60-evdev.hwdb `_
+ or :ref:`device-quirks` instead of a plugin so all users of that
+ device can benefit from the fix.
+
+.. function:: EvdevDevice:connect(name, function)
+
+ Set the callback to the given event name. Only one callback
+ may be set for an event name at any time, subsequent callbacks
+ will overwrite any earlier callbacks for the same name.
+
+ If the device has since been discarded by libinput, this function does nothing.
+
+ Version 1 of the plugin API supports the following events and callback arguments:
+
+ - ``"evdev-frame"``: A new :ref:`evdev frame ` has
+ started for this device. If the callback returns a value other than
+ ``nil`` or an empty table, that value is the frame with any modified
+ events.
+
+ .. code-block:: lua
+
+ device:connect("evdev-frame", function (device, frame, timestamp)
+ -- change any event into a movement left by 1 pixel
+ move_left = {
+ { usage = evdev.EV_REL, code = evdev.REL_X, value = -1, },
+ }
+ return move_left
+ end
+
+ The timestamp of an event frame is in microseconds in ``CLOCK_MONOTONIC``, see
+ ``libinput.now()`` for details.
+
+ For performance reasons plugins that do not modify the event frame should
+ return ``nil`` (or nothing) instead of the event frame given as argument.
+
+ - ``"device-removed"``: This device was removed by libinput. This may happen
+ without the device ever becoming a libinput device as seen by libinput's
+ public API (e.g. if the device does not meet the requirements to be
+ added). Once this callback is invoked, the plugin should remove any
+ references to this device and stop using it.
+
+ .. code-block:: lua
+
+ device:connect("new-evdev-device", function (device) ... end)
+
+ Functions to query the device's capabilities (e.g. ``usages()``) will
+ return an empty table.
+
+.. function:: EvdevDevice:disconnect(name)
+
+ Disconnect the existing callback (if any) for the given event name. See
+ ``EvdevDevice:connect()`` for a list of supported names.
+
+.. function:: EvdevDevice:inject_frame(frame)
+
+ .. warning:: This function is only available from inside a timer callback.
+
+ Inject an :ref:`evdev frame ` into the event stream
+ for this device. This emulates that same event frame being sent by the kernel
+ immediately with the current time.
+
+ Assuming three plugins P1, P2 and P3, if P2 injects a frame the frame is
+ seen by P1, P2 and P3.
+
+ This is rarely the right API to use. Injecting frames at the lowest level
+ may make other plugins behave unexpectedly. Use ``prepend_frame`` or
+ ``append_frame`` instead.
+
+ .. warning:: The injected frame will be seen by all plugins, including the
+ injecting frame. Ensure a guard is in place to prevent recursion.
+
+.. function:: EvdevDevice:prepend_frame(frame)
+
+ Prepend an :ref:`evdev frame ` for this device
+ **before** the current frame (if any). This function can only be called from
+ within a device's ``frame()`` handler or from within the plugin's timer
+ callback function.
+
+ Assuming three plugins P1, P2 and P3, if P2 injects a frame the frame is
+ seen only by P3.
+
+ For example, to change a single event into a drag, prepend a button
+ down and append a button up before each event:
+
+ .. code:: lua
+
+ function frame_handler(device, frame, timestamp)
+ device:prepend_frame({
+ { usage = evdev.BTN_LEFT, value = 1}
+ })
+ device:append_frame({
+ { usage = evdev.BTN_LEFT, value = 0}
+ })
+ return nil -- return the frame unmodified
+
+ -- this results in the event sequence
+ -- button down, frame, button up
+ -- to be passed to the next plugin
+ end
+
+ If called from within the plugin's timer there is no current frame and this
+ function is identical to ``append_frame()``.
+
+.. function:: EvdevDevice:append_frame(frame)
+
+ Appends an :ref:`evdev frame ` for this device
+ **after** the current frame (if any). This function can only be called from
+ within a device's ``frame()`` handler or from within the plugin's timer
+ callback function.
+
+ If called from within the plugin's timer there is no current frame and this
+ function is identical to ``prepend_frame()``.
+
+ See ``prepend_frame()`` for more details.
diff --git a/doc/user/meson.build b/doc/user/meson.build
index 53d8fb3b..9ca63186 100644
--- a/doc/user/meson.build
+++ b/doc/user/meson.build
@@ -155,6 +155,7 @@ src_rst = files(
'middle-button-emulation.rst',
'normalization-of-relative-motion.rst',
'palm-detection.rst',
+ 'lua-plugins.rst',
'pointer-acceleration.rst',
'reporting-bugs.rst',
'scrolling.rst',
diff --git a/meson.build b/meson.build
index 3da428cb..057da9fa 100644
--- a/meson.build
+++ b/meson.build
@@ -2,7 +2,7 @@ project('libinput', 'c',
version : '1.29.0',
license : 'MIT/Expat',
default_options : [ 'c_std=gnu99', 'warning_level=2' ],
- meson_version : '>= 0.56.0')
+ meson_version : '>= 0.64.0')
libinput_version = meson.project_version().split('.')
@@ -168,7 +168,21 @@ dep_libevdev = dependency('libevdev', version: '>= 1.10.0')
dep_lm = cc.find_library('m', required : false)
dep_rt = cc.find_library('rt', required : false)
-config_h.set10('HAVE_PLUGINS', true)
+dep_lua = dependency('lua-5.4', 'lua5.4', 'lua',
+ version : '>= 5.4',
+ required : get_option('lua-plugins'))
+have_lua = dep_lua.found()
+config_h.set10('HAVE_LUA', have_lua)
+
+have_plugins = dep_lua.found()
+config_h.set10('HAVE_PLUGINS', have_plugins)
+
+summary({
+ 'Plugins enabled' : have_plugins,
+ 'Lua Plugin support' : have_lua,
+ },
+ section : 'Plugins',
+ bool_yn : true)
# Include directories
includes_include = include_directories('include')
@@ -417,10 +431,17 @@ src_libinput = src_libfilter + [
'src/timer.c',
'src/util-libinput.c',
]
+
if have_mtdev
src_libinput += ['src/libinput-plugin-mtdev.c']
endif
+if dep_lua.found()
+ src_libinput += [
+ 'src/libinput-plugin-lua.c',
+ ]
+endif
+
deps_libinput = [
dep_mtdev,
dep_udev,
@@ -430,7 +451,8 @@ deps_libinput = [
dep_rt,
dep_libwacom,
dep_libinput_util,
- dep_libquirks
+ dep_libquirks,
+ dep_lua,
]
libinput_version_h_config = configuration_data()
@@ -479,6 +501,8 @@ git_version_h = vcs_tag(command : ['git', 'describe'],
input : 'src/libinput-git-version.h.in',
output :'libinput-git-version.h')
+subdir('plugins')
+
############ documentation ############
if get_option('documentation')
@@ -1024,6 +1048,10 @@ if get_option('tests')
'test/test-switch.c',
'test/test-quirks.c',
]
+ if have_plugins and have_lua
+ tests_sources += ['test/test-plugins-lua.c']
+ endif
+
libinput_test_runner_sources = litest_sources + tests_sources
libinput_test_runner = executable('libinput-test-suite',
libinput_test_runner_sources,
@@ -1065,6 +1093,9 @@ if get_option('tests')
'trackpoint',
'udev',
]
+ if have_plugins and have_lua
+ collections += ['lua']
+ endif
foreach group : collections
test('libinput-test-suite-@0@'.format(group),
diff --git a/meson_options.txt b/meson_options.txt
index d872f331..784ab58d 100644
--- a/meson_options.txt
+++ b/meson_options.txt
@@ -42,3 +42,7 @@ option('internal-event-debugging',
type: 'boolean',
value: false,
description: 'Enable additional internal event debug tracing. This will print key values to the logs and thus must never be enabled in a release build')
+option('lua-plugins',
+ type: 'feature',
+ value: 'auto',
+ description: 'Enable support for Lua plugins')
diff --git a/plugins/10-delay-motion.lua b/plugins/10-delay-motion.lua
new file mode 100644
index 00000000..ddc122e3
--- /dev/null
+++ b/plugins/10-delay-motion.lua
@@ -0,0 +1,61 @@
+-- SPDX-License-Identifier: MIT
+--
+-- This is an example libinput plugin
+--
+-- This plugin delays any event with relative motion by the given DELAY
+-- by storing it in a table and replaying it via a timer callback later.
+
+-- UNCOMMENT THIS LINE TO ACTIVATE THE PLUGIN
+-- libinput:register({1})
+
+DELAY = 1500 * 1000 -- 1.5s
+next_timer_expiry = 0
+devices = {}
+
+function timer_expired(time_in_microseconds)
+ next_timer_expiry = 0
+ for device, frames in pairs(devices) do
+ while #frames > 0 and frames[1].time <= time_in_microseconds do
+ --- we don't have a current frame so it doesn't matter
+ --- whether we prepend or append
+ device:prepend_frame(frames[1].frame)
+ table.remove(frames, 1)
+ end
+ local next_frame = frames[1]
+ if next_frame and (next_timer_expiry == 0 or next_frame.time < next_timer_expiry) then
+ next_timer_expiry = next_frame.time
+ end
+ end
+ if next_timer_expiry ~= 0 then
+ libinput:timer_set_absolute(next_timer_expiry)
+ end
+end
+
+function frame(device, frame, timestamp)
+ for _, v in ipairs(frame) do
+ if v.usage == evdev.REL_X or v.usage == evdev.REL_Y then
+ next_time = timestamp + DELAY
+ table.insert(devices[device], {
+ time = next_time,
+ frame = frame
+ })
+ if next_timer_expiry == 0 then
+ next_timer_expiry = next_time
+ libinput:timer_set_absolute(next_timer_expiry)
+ end
+ return {} -- discard frame
+ end
+ end
+ return nil
+end
+
+function device_new(device)
+ local usages = device:usages()
+ if usages[evdev.REL_X] then
+ devices[device] = {}
+ device:connect("evdev-frame", frame)
+ end
+end
+
+libinput:connect("new-evdev-device", device_new)
+libinput:connect("timer-expired", timer_expired)
diff --git a/plugins/10-dwt.lua b/plugins/10-dwt.lua
new file mode 100644
index 00000000..79a3b584
--- /dev/null
+++ b/plugins/10-dwt.lua
@@ -0,0 +1,40 @@
+-- SPDX-License-Identifier: MIT
+--
+-- This plugin implements a very simple version of disable-while-typing.
+-- It monitors all keyboard devices and if any of them send an event,
+-- any touchpad device is disabled for 2 seconds.
+-- And "disabled" means any event from that touchpad is simply
+-- discarded.
+--
+-- Install this file in /etc/libinput/plugins and
+--
+-- UNCOMMENT THIS LINE TO ACTIVATE THE PLUGIN
+-- libinput:register({1})
+
+tp_enabled = true
+
+libinput:connect("timer-expired", function(_, now)
+ log.debug("touchpad enabled")
+ tp_enabled = true
+end)
+
+libinput:connect("new-evdev-device", function (_, device)
+ local props = device:udev_properties()
+ if props.ID_INPUT_KEYBOARD then
+ device:connect("evdev-frame", function (_, _, _)
+ libinput:timer_set_relative(2000000)
+ if tp_enabled then
+ log.debug("touchpad disabled")
+ tp_enabled = false
+ end
+ end)
+ elseif props.ID_INPUT_TOUCHPAD then
+ log.debug("Touchpad detected: " .. device:name())
+ device:connect("evdev-frame", function (_, frame, _)
+ if not tp_enabled then
+ -- Returning an empty table discards the event.
+ return {}
+ end
+ end)
+ end
+end)
diff --git a/plugins/10-example.lua b/plugins/10-example.lua
new file mode 100644
index 00000000..9fe7c6ba
--- /dev/null
+++ b/plugins/10-example.lua
@@ -0,0 +1,87 @@
+-- SPDX-License-Identifier: MIT
+--
+-- This is an example libinput plugin
+--
+-- This plugin swaps left and right buttons on any device that has both buttons.
+
+-- Let's create a plugin. A single Lua script may create more than one
+-- plugin instance but that's a bit of a nice use-case. Most scripts
+-- should be a single plugin.
+
+-- A plugin needs to be registered to activate. If it isn't, it is
+-- cleaned up immediately. In the register call we supply
+-- the list of plugin versions we support. Currently we only
+-- have version 1.
+--
+-- UNCOMMENT THIS LINE TO ACTIVATE THE PLUGIN
+-- libinput:register({1})
+
+-- Note to the reader: it will be easier to understand this example
+-- if you read it bottom-up from here on.
+
+-- The callback for our "evdev-frame" signal.
+-- These frames are sent *before* libinput gets to handle them so
+-- any modifications will affect libinput.
+function frame(device, frame, time_in_microseconds)
+ -- Frame is a table in the form
+ -- { { usage: 123, value: 3 }, ... }
+ -- Let's use the evdev module to make it more readable, evdev.KEY_A
+ -- is simply the value (0x1 << 16) | 0x1 (see linux/input-event-codes.h)
+ for _, v in ipairs(frame) do
+ -- If we get a right button event, change it to left, and
+ -- vice versa. Because this happens before libinput (or the next
+ -- plugin in the precedence order) sees the
+ -- frame it doesn't know that the swap happend.
+ if v.usage == evdev.BTN_RIGHT then
+ v.usage = evdev.BTN_LEFT
+ elseif v.usage == evdev.BTN_LEFT then
+ v.usage = evdev.BTN_RIGHT
+ end
+ end
+ -- We changed the frame, let's return it. If we return nil
+ -- the original frame is being used as-is.
+ return frame
+end
+
+-- This is a timer callback. It is invoked after one second
+-- (see below) but only once - re-set the timer so
+-- it goes off every second.
+function timer_expired(time_in_microseconds)
+ libinput:timer_set_absolute(time_in_microseconds + 1000000)
+end
+
+-- Callback for the "new-evdev-device" signal, see below
+-- The argument is the EvdevDevice object, see the documentation.
+function device_new(device)
+ -- A table of evdev usages available on our device.
+ -- Using the evdev module makes it more readable but you can
+ -- use numbers (which is what evdev.EV_KEY and
+ -- friends resolve to anyway).
+ local usages = device:usages()
+ if usages[evdev.BTN_LEFT] and usages[evdev.BTN_RIGHT] then
+ -- The "evdev-frame" callback is invoked whenever the device
+ -- provided us with one evdev frame, i.e. a bunch of events up
+ -- to excluding EV_SYN SYN_REPORT.
+ device:connect("evdev-frame", frame)
+ end
+
+ -- The device has udev information, let's print it out. Right
+ -- now all we get are the ID_INPUT_ bits.
+ -- If this is empty we know libinput will ignore this device anyway
+ local udev_info = device:udev_properties()
+ for k, v in pairs(udev_info) do
+ log.debug(k .. "=" .. v)
+ end
+end
+
+-- Let's connect to the "new-evdev-device" signal. This function
+-- is invoked when libinput detects a new evdev device (but before
+-- that device is actually available to libinput as libinput device).
+-- This allows us to e.g. change properties on the device.
+libinput:connect("new-evdev-device", device_new)
+
+-- Set our timer to expire 1s from now (in microseconds).
+-- Timers are absolute, so they need to be added to the
+-- current time
+libinput:connect("timer-expired", timer_expired)
+libinput:timer_set_relative(1000000)
diff --git a/plugins/10-logitech-mx-master-horiz-scroll.lua b/plugins/10-logitech-mx-master-horiz-scroll.lua
new file mode 100644
index 00000000..d182d631
--- /dev/null
+++ b/plugins/10-logitech-mx-master-horiz-scroll.lua
@@ -0,0 +1,32 @@
+-- SPDX-License-Identifier: MIT
+--
+-- This plugin inverts the horizontal scroll direction of
+-- the Logitech MX Master mouse. OOTB the mouse scrolls
+-- in the opposite direction to all other mice out there.
+--
+-- This plugin is only needed when the mouse is connected
+-- to the Logitech Bolt receiver - on that receiver we cannot
+-- tell which device is connected.
+--
+-- For the Logitech Unifying receiver and Bluetooth please
+-- add the ModelInvertHorizontalScrolling=1 quirk
+-- in quirks/30-vendor-logitech.quirks.
+--
+--
+-- Install this file in /etc/libinput/plugins and
+--
+-- UNCOMMENT THIS LINE TO ACTIVATE THE PLUGIN
+-- libinput:register({1})
+libinput:connect("new-evdev-device", function (_, device)
+ local info = device:info()
+ if info.vid == 0x046D and info.pid == 0xC548 then
+ device:connect("evdev-frame", function (_, frame, timestamp)
+ for _, event in ipairs(frame) do
+ if event.usage == evdev.REL_HWHEEL or event.usage == evdev.REL_HWHEEL_HI_RES then
+ event.value = -event.value
+ end
+ end
+ return frame
+ end)
+ end
+end)
diff --git a/plugins/10-pointer-go-faster.lua b/plugins/10-pointer-go-faster.lua
new file mode 100644
index 00000000..23e8b4be
--- /dev/null
+++ b/plugins/10-pointer-go-faster.lua
@@ -0,0 +1,22 @@
+-- SPDX-License-Identifier: MIT
+--
+-- An example plugin to make the pointer go three times as fast
+--
+-- Install this file in /etc/libinput/plugins and
+--
+-- UNCOMMENT THIS LINE TO ACTIVATE THE PLUGIN
+-- libinput:register({1})
+libinput:connect("new-evdev-device", function(_, device)
+ local usages = device:usages()
+ if usages[evdev.REL_X] then
+ device:connect("evdev-frame", function(_, frame, timestamp)
+ for _, v in ipairs(frame) do
+ if v.usage == evdev.REL_X or v.usage == evdev.REL_Y then
+ -- Multiply the relative motion by 3
+ v.value = v.value * 3
+ end
+ end
+ return frame
+ end)
+ end
+end)
diff --git a/plugins/10-pointer-go-slower.lua b/plugins/10-pointer-go-slower.lua
new file mode 100644
index 00000000..56bdea2b
--- /dev/null
+++ b/plugins/10-pointer-go-slower.lua
@@ -0,0 +1,53 @@
+-- SPDX-License-Identifier: MIT
+--
+-- An example plugin to make the pointer go three times as slow
+--
+-- Install this file in /etc/libinput/plugins and
+--
+-- UNCOMMENT THIS LINE TO ACTIVATE THE PLUGIN
+-- libinput:register({1})
+remainders = {}
+
+function split(v)
+ if math.abs(v) >= 1.0 then
+ local i = math.floor(math.abs(v))
+ local r = math.abs(v) % 1.0
+ if v < 0.0 then
+ i = -i
+ r = -r
+ end
+ return i, r
+ else
+ return 0, v
+ end
+end
+
+function decelerate(device, x, y)
+ local remainder = remainders[device]
+ local rx, ry = 0, 0
+ if x ~= 0.0 then
+ rx, remainder.x = split(remainder.x + x/3.0)
+ end
+ if y ~= 0.0 then
+ ry, remainder.y = split(remainder.y + y/3.0)
+ end
+
+ return rx, ry
+end
+
+libinput:connect("new-evdev-device", function(device)
+ local usages = device:usages()
+ if usages[evdev.REL_X] then
+ remainders[device] = { x = 0.0, y = 0.0 }
+ device:connect("evdev-frame", function(_, frame, timestamp)
+ for _, v in ipairs(frame) do
+ if v.usage == evdev.REL_X then
+ v.value, _ = decelerate(device, v.value, 0.0)
+ elseif v.usage == evdev.REL_Y then
+ _, v.value = decelerate(device, 0.0, v.value)
+ end
+ end
+ return frame
+ end)
+ end
+end)
diff --git a/plugins/meson.build b/plugins/meson.build
new file mode 100644
index 00000000..2be48f46
--- /dev/null
+++ b/plugins/meson.build
@@ -0,0 +1,13 @@
+plugins = [
+ '10-example.lua',
+ '10-dwt.lua',
+ '10-logitech-mx-master-horiz-scroll.lua',
+ '10-pointer-go-faster.lua',
+ '10-pointer-go-slower.lua',
+ '10-delay-motion.lua',
+]
+
+fs = import('fs')
+foreach plugin : plugins
+ fs.copyfile(plugin)
+endforeach
diff --git a/src/libinput-plugin-lua.c b/src/libinput-plugin-lua.c
new file mode 100644
index 00000000..00130165
--- /dev/null
+++ b/src/libinput-plugin-lua.c
@@ -0,0 +1,1361 @@
+/*
+ * Copyright © 2025 Red Hat, Inc.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a
+ * copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice (including the next
+ * paragraph) shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+ * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+ * DEALINGS IN THE SOFTWARE.
+ */
+
+#include "config.h"
+
+#include
+#include
+#include
+#include
+#include
+
+#include "util-mem.h"
+#include "util-strings.h"
+
+#include "evdev-frame.h"
+#include "libinput-log.h"
+#include "libinput-plugin-lua.h"
+#include "libinput-plugin.h"
+#include "libinput-util.h"
+#include "timer.h"
+
+const uint32_t LIBINPUT_PLUGIN_VERSION = 1U;
+
+#define PLUGIN_METATABLE "LibinputPlugin"
+#define EVDEV_DEVICE_METATABLE "EvdevDevice"
+
+static const char libinput_lua_plugin_key = 'p'; /* key to lua registry */
+static const char libinput_key = 'l'; /* key to lua registry */
+
+DEFINE_TRIVIAL_CLEANUP_FUNC(lua_State *, lua_close);
+
+struct udev_property {
+ struct list link;
+ char *key;
+ char *value;
+};
+
+static inline struct udev_property *
+udev_property_new(const char *key, const char *value)
+{
+ struct udev_property *prop = zalloc(sizeof(*prop));
+ prop->key = safe_strdup(key);
+ prop->value = safe_strdup(value);
+ return prop;
+}
+
+static inline void
+udev_property_destroy(struct udev_property *prop)
+{
+ list_remove(&prop->link);
+ free(prop->key);
+ free(prop->value);
+ free(prop);
+}
+
+/* A thin wrapper struct that just needs to exist, all
+ * the actual logic is struct libinput_lua_plugin */
+typedef struct {
+} LibinputPlugin;
+
+typedef struct {
+ struct list link;
+ int refid;
+
+ struct libinput_device *device;
+
+ unsigned int id;
+ unsigned int bustype;
+ unsigned int vid;
+ unsigned int pid;
+ char *name;
+ struct list udev_properties_list;
+
+ struct libevdev *evdev;
+
+ int device_removed_refid;
+ int frame_refid;
+} EvdevDevice;
+
+struct libinput_lua_plugin {
+ struct libinput_plugin *parent;
+ lua_State *L;
+ int sandbox_table_idx;
+ bool register_called;
+
+ struct list evdev_devices; /* EvdevDevice */
+
+ size_t version;
+ int device_new_refid;
+ int timer_expired_refid;
+
+ struct libinput_plugin_timer *timer;
+ bool in_timer_func;
+ struct list timer_injected_events;
+};
+
+static struct libinput_lua_plugin *
+lua_get_libinput_lua_plugin(lua_State *L)
+{
+ struct libinput_lua_plugin *plugin = NULL;
+
+ lua_pushlightuserdata(L, (void *)&libinput_lua_plugin_key);
+ lua_gettable(L, LUA_REGISTRYINDEX);
+ plugin = lua_touserdata(L, -1);
+ lua_pop(L, 1);
+
+ return plugin;
+}
+
+/* Prints the current stack "layout" with a message */
+#define lua_show_stack(L, ...) { \
+ etrace(__VA_ARGS__); \
+ etrace("pcall: stack has: %d", lua_gettop(L)); \
+ for (int i = -1; i >= -lua_gettop(L); i--) \
+ etrace(" stack %d: %s", i, lua_typename(L, lua_type(L, i))); \
+}
+
+static struct libinput *
+lua_get_libinput(lua_State *L)
+{
+ struct libinput *libinput = NULL;
+
+ lua_pushlightuserdata(L, (void *)&libinput_key);
+ lua_gettable(L, LUA_REGISTRYINDEX);
+ libinput = lua_touserdata(L, -1);
+ lua_pop(L, 1);
+
+ return libinput;
+}
+
+static void
+lua_push_evdev_device(lua_State *L,
+ struct libinput_lua_plugin *plugin,
+ struct libinput_device *device,
+ struct libevdev *evdev,
+ struct udev_device *udev_device)
+{
+ EvdevDevice *lua_device = lua_newuserdata(L, sizeof(*lua_device));
+ memset(lua_device, 0, sizeof(*lua_device));
+ lua_device->device = libinput_device_ref(device);
+ lua_device->evdev = evdev;
+ lua_device->bustype = libinput_device_get_id_bustype(device);
+ lua_device->vid = libinput_device_get_id_vendor(device);
+ lua_device->pid = libinput_device_get_id_product(device);
+ lua_device->name = strdup(libinput_device_get_name(device));
+ lua_device->device_removed_refid = LUA_NOREF;
+ lua_device->frame_refid = LUA_NOREF;
+ list_init(&lua_device->udev_properties_list);
+
+ struct udev_list_entry *e = udev_device_get_properties_list_entry(udev_device);
+ while (e) {
+ const char *key = udev_list_entry_get_name(e);
+ if (strstartswith(key, "ID_INPUT_") &&
+ !streq(key, "ID_INPUT_WIDTH_MM") &&
+ !streq(key, "ID_INPUT_HEIGHT_MM")) {
+ const char *value = udev_list_entry_get_value(e);
+ if (!streq(value, "0")) {
+ struct udev_property *prop =
+ udev_property_new(key, value);
+ list_insert(&lua_device->udev_properties_list,
+ &prop->link);
+ }
+ }
+ e = udev_list_entry_get_next(e);
+ }
+
+ list_insert(&plugin->evdev_devices, &lua_device->link);
+
+ lua_pushvalue(L, -1); /* Copy to top */
+ lua_device->refid = luaL_ref(L, LUA_REGISTRYINDEX); /* ref to device */
+
+ luaL_getmetatable(L, EVDEV_DEVICE_METATABLE);
+ lua_setmetatable(L, -2);
+}
+
+static void
+lua_push_evdev_frame(lua_State *L, struct evdev_frame *frame)
+{
+ size_t nevents;
+ struct evdev_event *events = evdev_frame_get_events(frame, &nevents);
+
+ lua_newtable(L);
+ for (size_t i = 0; i < nevents; i++) {
+ struct evdev_event *e = &events[i];
+
+ if (evdev_usage_eq(e->usage, EVDEV_SYN_REPORT))
+ break;
+
+ lua_newtable(L);
+ lua_pushinteger(L, evdev_usage_as_uint32_t(e->usage));
+ lua_setfield(L, -2, "usage");
+ lua_pushinteger(L, e->value);
+ lua_setfield(L, -2, "value");
+ lua_rawseti(L, -2, i + 1);
+ }
+}
+
+static void
+lua_pop_evdev_frame(struct libinput_lua_plugin *plugin, struct evdev_frame *frame_out)
+{
+ lua_State *L = plugin->L;
+
+ if (lua_isnil(L, -1)) {
+ lua_pop(L, 1);
+ return;
+ }
+
+ if (!lua_istable(L, -1)) {
+ plugin_log_bug(plugin->parent,
+ "expected table like `{ events = { ... } }`, got %s",
+ lua_typename(L, lua_type(L, -1)));
+ return;
+ }
+
+ struct evdev_event events[64] = { 0 };
+ size_t nevents = 0;
+
+ lua_pushnil(L);
+ while (lua_next(L, -2) != 0 && nevents < ARRAY_LENGTH(events)) {
+
+ /* -2 is the index, -1 our { usage = ... } table */
+ if (!lua_istable(L, -1)) {
+ plugin_log_bug(
+ plugin->parent,
+ "expected table like `{ type = ..., code = ...}`, got %s",
+ lua_typename(L, lua_type(L, -1)));
+ lua_pop(L, 1);
+ return;
+ }
+
+ lua_getfield(L, -1, "usage");
+ uint32_t usage = luaL_checkinteger(L, -1);
+ lua_pop(L, 1);
+
+ lua_getfield(L, -1, "value");
+ int32_t value = luaL_checkinteger(L, -1);
+ lua_pop(L, 1);
+
+ lua_pop(L, 1); /* pop { usage = ..., value = ...} */
+
+ struct evdev_event *e = &events[nevents++];
+ e->usage = evdev_usage_from_uint32_t(usage);
+ e->value = value;
+
+ if (evdev_usage_eq(e->usage, EVDEV_SYN_REPORT)) {
+ lua_pop(L, 1); /* force-pop the nil */
+ break;
+ }
+ }
+
+ if (nevents == 0) {
+ events[0].usage = evdev_usage_from_uint32_t(EVDEV_SYN_REPORT);
+ events[0].value = 0;
+ nevents++;
+ }
+
+ if (evdev_frame_set(frame_out, events, nevents) == -ENOMEM) {
+ plugin_log_bug(plugin->parent, "too many events in frame");
+ }
+}
+
+static bool
+libinput_lua_pcall(struct libinput_lua_plugin *plugin, int narg, int nres)
+{
+ lua_State *L = plugin->L;
+
+ int rc = lua_pcall(L, narg, nres, 0);
+ if (rc != LUA_OK) {
+ auto libinput_plugin = plugin->parent;
+ const char *errormsg = lua_tostring(L, -1);
+ if (strstr(errormsg, "@@unregistering@@") == NULL) {
+ plugin_log_bug(libinput_plugin,
+ "unloading after error: %s\n",
+ errormsg);
+ }
+ lua_pop(L, 1); /* pop error message */
+
+ if (plugin->timer)
+ libinput_plugin_timer_cancel(plugin->timer);
+ libinput_plugin_unregister(libinput_plugin);
+ /* plugin system will destroy the plugin later */
+ }
+ return rc == LUA_OK;
+}
+
+static void
+libinput_lua_plugin_device_new(struct libinput_plugin *libinput_plugin,
+ struct libinput_device *device,
+ struct libevdev *evdev,
+ struct udev_device *udev_device)
+{
+ struct libinput_lua_plugin *plugin =
+ libinput_plugin_get_user_data(libinput_plugin);
+
+ lua_rawgeti(plugin->L, LUA_REGISTRYINDEX, plugin->device_new_refid);
+ lua_push_evdev_device(plugin->L, plugin, device, evdev, udev_device);
+
+ libinput_lua_pcall(plugin, 1, 0);
+}
+
+static void
+remove_device(struct libinput_lua_plugin *plugin, EvdevDevice *evdev)
+{
+ /* Don't allow access to the libevdev context during remove */
+ evdev->evdev = NULL;
+ if (evdev->device_removed_refid != LUA_NOREF) {
+ lua_rawgeti(plugin->L, LUA_REGISTRYINDEX, evdev->device_removed_refid);
+ lua_rawgeti(plugin->L, LUA_REGISTRYINDEX, evdev->refid);
+
+ if (!libinput_lua_pcall(plugin, 1, 0))
+ return;
+ }
+ luaL_unref(plugin->L, evdev->refid, LUA_REGISTRYINDEX);
+ evdev->refid = LUA_NOREF;
+ list_remove(&evdev->link);
+ list_init(&evdev->link); /* so we can list_remove in _gc */
+
+ struct udev_property *prop;
+ list_for_each_safe(prop, &evdev->udev_properties_list, link) {
+ udev_property_destroy(prop);
+ }
+ free(evdev->name);
+ evdev->name = NULL;
+ evdev->device = libinput_device_unref(evdev->device);
+
+ /* This device no longer exists but our lua code may have a
+ * reference to it */
+}
+
+static void
+libinput_lua_plugin_device_ignored(struct libinput_plugin *libinput_plugin,
+ struct libinput_device *device)
+{
+ struct libinput_lua_plugin *plugin =
+ libinput_plugin_get_user_data(libinput_plugin);
+
+ EvdevDevice *evdev;
+ list_for_each_safe(evdev, &plugin->evdev_devices, link) {
+ if (evdev->device != device)
+ continue;
+ remove_device(plugin, evdev);
+ }
+}
+
+static void
+libinput_lua_plugin_device_removed(struct libinput_plugin *libinput_plugin,
+ struct libinput_device *device)
+{
+ struct libinput_lua_plugin *plugin =
+ libinput_plugin_get_user_data(libinput_plugin);
+
+ EvdevDevice *evdev;
+ list_for_each_safe(evdev, &plugin->evdev_devices, link) {
+ if (evdev->device != device)
+ continue;
+ remove_device(plugin, evdev);
+ }
+}
+
+static void
+libinput_lua_plugin_evdev_frame(struct libinput_plugin *libinput_plugin,
+ struct libinput_device *device,
+ struct evdev_frame *frame)
+{
+ struct libinput_lua_plugin *plugin =
+ libinput_plugin_get_user_data(libinput_plugin);
+
+ EvdevDevice *evdev;
+ list_for_each_safe(evdev, &plugin->evdev_devices, link) {
+ if (evdev->device != device)
+ continue;
+
+ if (evdev->frame_refid == LUA_NOREF)
+ continue;
+
+ lua_rawgeti(plugin->L, LUA_REGISTRYINDEX, evdev->frame_refid);
+ lua_rawgeti(plugin->L, LUA_REGISTRYINDEX, evdev->refid);
+ lua_push_evdev_frame(plugin->L, frame);
+ lua_pushinteger(plugin->L, evdev_frame_get_time(frame));
+
+ if (!libinput_lua_pcall(plugin, 3, 1))
+ return;
+ lua_pop_evdev_frame(plugin, frame);
+ }
+}
+
+static void
+register_func(struct lua_State *L, int stack_index, int *refid)
+{
+ if (*refid != LUA_NOREF)
+ luaL_unref(L, LUA_REGISTRYINDEX, *refid);
+ lua_pushvalue(L, stack_index); /* Copy function to top */
+ *refid = luaL_ref(L, LUA_REGISTRYINDEX); /* ref to function */
+}
+
+static void
+unregister_func(struct lua_State *L, int *refid)
+{
+ if (*refid != LUA_NOREF) {
+ luaL_unref(L, LUA_REGISTRYINDEX, *refid);
+ *refid = LUA_NOREF;
+ }
+}
+
+static int
+libinputplugin_connect(lua_State *L)
+{
+ LibinputPlugin *p = luaL_checkudata(L, 1, PLUGIN_METATABLE);
+ luaL_argcheck(L, p != NULL, 1, PLUGIN_METATABLE " expected");
+
+ const char *name = luaL_checkstring(L, 2);
+ luaL_checktype(L, 3, LUA_TFUNCTION);
+
+ struct libinput_lua_plugin *plugin = lua_get_libinput_lua_plugin(L);
+
+ /* Version 1 signals */
+ if (streq(name, "new-evdev-device")) {
+ register_func(L, 3, &plugin->device_new_refid);
+ } else if (streq(name, "timer-expired")) {
+ register_func(L, 3, &plugin->timer_expired_refid);
+ } else {
+ return luaL_error(L, "Unknown name: %s", name);
+ }
+
+ return 0;
+}
+
+static int
+libinputplugin_now(lua_State *L)
+{
+ LibinputPlugin *p = luaL_checkudata(L, 1, PLUGIN_METATABLE);
+ luaL_argcheck(L, p != NULL, 1, PLUGIN_METATABLE " expected");
+
+ struct libinput *libinput = lua_get_libinput(L);
+ uint64_t now = libinput_now(libinput);
+
+ lua_pushinteger(L, now);
+
+ return 1;
+}
+
+static int
+libinputplugin_version(lua_State *L)
+{
+ LibinputPlugin *p = luaL_checkudata(L, 1, PLUGIN_METATABLE);
+ luaL_argcheck(L, p != NULL, 1, PLUGIN_METATABLE " expected");
+
+ struct libinput_lua_plugin *plugin = lua_get_libinput_lua_plugin(L);
+ lua_pushinteger(L, plugin->version);
+ return 1;
+}
+
+static int
+libinputplugin_register(lua_State *L)
+{
+ LibinputPlugin *p = luaL_checkudata(L, 1, PLUGIN_METATABLE);
+ luaL_argcheck(L, p != NULL, 1, PLUGIN_METATABLE " expected");
+
+ struct libinput_lua_plugin *plugin = lua_get_libinput_lua_plugin(L);
+ if (plugin->register_called) {
+ return luaL_error(L, "plugin already registered");
+ }
+
+ uint32_t versions[16] = { 0 };
+ size_t idx = 0;
+
+ luaL_checktype(L, 2, LUA_TTABLE);
+ lua_pushnil(L);
+ while (idx < ARRAY_LENGTH(versions) && lua_next(L, -2) != 0) {
+ int version = luaL_checkinteger(L, -1);
+ lua_pop(L, 1);
+ if (version <= 0) {
+ return luaL_error(L, "Invalid version number");
+ }
+ versions[idx++] = version;
+ }
+
+ ARRAY_FOR_EACH(versions, v) {
+ if (*v == 0)
+ break;
+ if (*v == LIBINPUT_PLUGIN_VERSION) {
+ plugin->version = *v;
+ plugin->register_called = true;
+
+ lua_pushinteger(L, plugin->version);
+
+ return 1;
+ }
+ }
+
+ return luaL_error(L, "None of this plugin's versions are supported");
+}
+
+static int
+libinputplugin_unregister(lua_State *L)
+{
+ LibinputPlugin *p = luaL_checkudata(L, 1, PLUGIN_METATABLE);
+ luaL_argcheck(L, p != NULL, 1, PLUGIN_METATABLE " expected");
+
+ /* Bit of a hack: unregister should work like os.exit(1)
+ * but we're in a lua context here so the easiest way
+ * to handle this is pretend we have an error, let
+ * our error handler unwind and just search for this
+ * magic string to *not* print log message */
+ return luaL_error(L, "@@unregistering@@");
+}
+
+static int
+libinputplugin_gc(lua_State *L)
+{
+ LibinputPlugin *p = luaL_checkudata(L, 1, PLUGIN_METATABLE);
+ luaL_argcheck(L, p != NULL, 1, PLUGIN_METATABLE " expected");
+
+ struct libinput_lua_plugin *plugin = lua_get_libinput_lua_plugin(L);
+ if (plugin->timer)
+ libinput_plugin_timer_cancel(plugin->timer);
+
+ /* We're about to destroy the plugin so the timer is the only
+ * thing we need to stop, the rest will be cleaned up
+ * when we destroy the plugin */
+
+ return 0;
+}
+
+struct timer_injected_event {
+ struct list link;
+ struct evdev_frame *frame;
+ int device_refid;
+};
+
+static void
+plugin_timer_func(struct libinput_plugin *libinput_plugin, uint64_t now, void *data)
+{
+ struct libinput_lua_plugin *plugin = data;
+ struct lua_State *L = plugin->L;
+
+ lua_rawgeti(L, LUA_REGISTRYINDEX, plugin->timer_expired_refid);
+ lua_pushinteger(L, now);
+
+ /* To allow for injecting events */
+ plugin->in_timer_func = true;
+ libinput_lua_pcall(plugin, 1, 0);
+ plugin->in_timer_func = false;
+
+ struct timer_injected_event *injected_event;
+ list_for_each_safe(injected_event, &plugin->timer_injected_events, link) {
+ EvdevDevice *device;
+ list_for_each(device, &plugin->evdev_devices, link) {
+ if (device->refid == injected_event->device_refid) {
+ libinput_plugin_inject_evdev_frame(
+ plugin->parent,
+ device->device,
+ injected_event->frame);
+ break;
+ }
+ }
+
+ list_remove(&injected_event->link);
+ evdev_frame_unref(injected_event->frame);
+ free(injected_event);
+ }
+}
+
+static int
+libinputplugin_timer_set(lua_State *L, uint64_t offset)
+{
+ LibinputPlugin *p = luaL_checkudata(L, 1, PLUGIN_METATABLE);
+ luaL_argcheck(L, p != NULL, 1, PLUGIN_METATABLE " expected");
+
+ struct libinput_lua_plugin *plugin = lua_get_libinput_lua_plugin(L);
+ uint64_t timeout = luaL_checkinteger(L, 2);
+
+ if (!plugin->timer) {
+ plugin->timer = libinput_plugin_timer_new(
+ plugin->parent,
+ libinput_plugin_get_name(plugin->parent),
+ plugin_timer_func,
+ plugin);
+ }
+
+ libinput_plugin_timer_set(plugin->timer, offset + timeout);
+
+ return 0;
+}
+
+static int
+libinputplugin_timer_set_absolute(lua_State *L)
+{
+ return libinputplugin_timer_set(L, 0);
+}
+
+static int
+libinputplugin_timer_set_relative(lua_State *L)
+{
+ auto libinput = lua_get_libinput(L);
+ return libinputplugin_timer_set(L, libinput_now(libinput));
+}
+
+static int
+libinputplugin_timer_cancel(lua_State *L)
+{
+ LibinputPlugin *p = luaL_checkudata(L, 1, PLUGIN_METATABLE);
+ luaL_argcheck(L, p != NULL, 1, PLUGIN_METATABLE " expected");
+
+ struct libinput_lua_plugin *plugin = lua_get_libinput_lua_plugin(L);
+ if (plugin->timer)
+ libinput_plugin_timer_cancel(plugin->timer);
+
+ return 0;
+}
+
+static const struct luaL_Reg libinputplugin_vtable[] = {
+ { "now", libinputplugin_now },
+ { "version", libinputplugin_version },
+ { "connect", libinputplugin_connect },
+ { "register", libinputplugin_register },
+ { "unregister", libinputplugin_unregister },
+ { "timer_cancel", libinputplugin_timer_cancel },
+ { "timer_set_absolute", libinputplugin_timer_set_absolute },
+ { "timer_set_relative", libinputplugin_timer_set_relative },
+ { "__gc", libinputplugin_gc },
+ { NULL, NULL }
+};
+
+static void
+libinputplugin_init(lua_State *L)
+{
+ luaL_newmetatable(L, PLUGIN_METATABLE);
+ lua_pushstring(L, "__index");
+ lua_pushvalue(L, -2); /* push metatable */
+ lua_settable(L, -3); /* metatable.__index = metatable */
+ luaL_setfuncs(L, libinputplugin_vtable, 0);
+}
+
+static int
+evdevdevice_info(lua_State *L)
+{
+ EvdevDevice *device = luaL_checkudata(L, 1, EVDEV_DEVICE_METATABLE);
+ luaL_argcheck(L, device != NULL, 1, EVDEV_DEVICE_METATABLE " expected");
+
+ lua_newtable(L); /* { bustype: ..., vid: ..., pid: ..., name: ... } */
+
+ lua_pushinteger(L, device->bustype);
+ lua_setfield(L, -2, "bustype");
+ lua_pushinteger(L, device->vid);
+ lua_setfield(L, -2, "vid");
+ lua_pushinteger(L, device->pid);
+ lua_setfield(L, -2, "pid");
+
+ return 1;
+}
+
+static int
+evdevdevice_name(lua_State *L)
+{
+ EvdevDevice *device = luaL_checkudata(L, 1, EVDEV_DEVICE_METATABLE);
+ luaL_argcheck(L, device != NULL, 1, EVDEV_DEVICE_METATABLE "expected");
+
+ lua_pushstring(L, device->name);
+
+ return 1;
+}
+
+static int
+evdevdevice_usages(lua_State *L)
+{
+ EvdevDevice *device = luaL_checkudata(L, 1, EVDEV_DEVICE_METATABLE);
+ luaL_argcheck(L, device != NULL, 1, EVDEV_DEVICE_METATABLE "expected");
+
+ lua_newtable(L); /* { evdev.REL_X: ... } */
+
+ if (device->evdev == NULL)
+ return 1;
+
+ for (unsigned int t = 0; t < EV_MAX; t++) {
+ if (!libevdev_has_event_type(device->evdev, t))
+ continue;
+
+ int max = libevdev_event_type_get_max(t);
+ for (unsigned int code = 0; (int)code < max; code++) {
+ if (!libevdev_has_event_code(device->evdev, t, code))
+ continue;
+
+ evdev_usage_t usage = evdev_usage_from_code(t, code);
+ lua_pushboolean(L, true);
+ lua_rawseti(L, -2, evdev_usage_as_uint32_t(usage));
+ }
+ }
+
+ return 1;
+}
+
+static int
+evdevdevice_absinfos(lua_State *L)
+{
+ EvdevDevice *device = luaL_checkudata(L, 1, EVDEV_DEVICE_METATABLE);
+ luaL_argcheck(L, device != NULL, 1, EVDEV_DEVICE_METATABLE "expected");
+
+ lua_newtable(L); /* { ABS_X: { min: 1, max: 2, ... }, ... } */
+
+ if (device->evdev == NULL)
+ return 1;
+
+ for (unsigned int code = 0; code < ABS_MAX; code++) {
+ const struct input_absinfo *abs =
+ libevdev_get_abs_info(device->evdev, code);
+ if (!abs)
+ continue;
+
+ lua_newtable(L);
+ lua_pushinteger(L, abs->minimum);
+ lua_setfield(L, -2, "minimum");
+ lua_pushinteger(L, abs->maximum);
+ lua_setfield(L, -2, "maximum");
+ lua_pushinteger(L, abs->fuzz);
+ lua_setfield(L, -2, "fuzz");
+ lua_pushinteger(L, abs->flat);
+ lua_setfield(L, -2, "flat");
+ lua_pushinteger(L, abs->resolution);
+ lua_setfield(L, -2, "resolution");
+
+ evdev_usage_t usage = evdev_usage_from_code(EV_ABS, code);
+ lua_rawseti(
+ L,
+ -2,
+ evdev_usage_as_uint32_t(usage)); /* Assign to top-level table */
+ }
+
+ return 1;
+}
+
+static int
+evdevdevice_udev_properties(lua_State *L)
+{
+ EvdevDevice *device = luaL_checkudata(L, 1, EVDEV_DEVICE_METATABLE);
+ luaL_argcheck(L, device != NULL, 1, EVDEV_DEVICE_METATABLE "expected");
+
+ lua_newtable(L); /* { ID_INPUT: { ... } , ... } */
+
+ if (device->evdev == NULL)
+ return 1;
+
+ struct udev_property *prop;
+ list_for_each(prop, &device->udev_properties_list, link) {
+ lua_pushstring(L, prop->value);
+ lua_setfield(L, -2, prop->key); /* Assign to top-level table */
+ }
+
+ return 1;
+}
+
+static int
+evdevdevice_enable_evdev_usage(lua_State *L)
+{
+ EvdevDevice *device = luaL_checkudata(L, 1, EVDEV_DEVICE_METATABLE);
+ luaL_argcheck(L, device != NULL, 1, EVDEV_DEVICE_METATABLE "expected");
+
+ struct libinput_lua_plugin *plugin = lua_get_libinput_lua_plugin(L);
+
+ evdev_usage_t usage = evdev_usage_from_uint32_t(luaL_checkinteger(L, 2));
+ uint16_t type = evdev_usage_type(usage);
+ uint16_t code = evdev_usage_code(usage);
+ if (type > EV_MAX) {
+ plugin_log_bug(plugin->parent,
+ ,
+ "Ignoring invalid evdev usage %#x\n",
+ evdev_usage_as_uint32_t(usage));
+ return 0;
+ }
+
+ if (device->evdev == NULL || type == EV_ABS)
+ return 0;
+
+ libevdev_enable_event_code(device->evdev, type, code, NULL);
+
+ return 0;
+}
+
+static int
+evdevdevice_disable_evdev_usage(lua_State *L)
+{
+ EvdevDevice *device = luaL_checkudata(L, 1, EVDEV_DEVICE_METATABLE);
+ luaL_argcheck(L, device != NULL, 1, EVDEV_DEVICE_METATABLE "expected");
+
+ evdev_usage_t usage = evdev_usage_from_uint32_t(luaL_checkinteger(L, 2));
+ uint16_t type = evdev_usage_type(usage);
+ uint16_t code = evdev_usage_code(usage);
+
+ if (device->evdev == NULL || type > EV_MAX)
+ return 0;
+
+ libevdev_disable_event_code(device->evdev, type, code);
+
+ return 0;
+}
+
+static int
+evdevdevice_set_absinfo(lua_State *L)
+{
+ EvdevDevice *device = luaL_checkudata(L, 1, EVDEV_DEVICE_METATABLE);
+ luaL_argcheck(L, device != NULL, 1, EVDEV_DEVICE_METATABLE "expected");
+
+ evdev_usage_t usage = evdev_usage_from_uint32_t(luaL_checkinteger(L, 2));
+ luaL_checktype(L, 3, LUA_TTABLE);
+
+ if (evdev_usage_type(usage) != EV_ABS)
+ return 0;
+
+ if (!device->evdev)
+ return 0;
+
+ uint16_t code = evdev_usage_code(usage);
+ const struct input_absinfo *absinfo =
+ libevdev_get_abs_info(device->evdev, code);
+ struct input_absinfo abs = {};
+ if (absinfo)
+ abs = *absinfo;
+
+ lua_getfield(L, 3, "minimum");
+ if (lua_isnumber(L, -1))
+ abs.minimum = luaL_checkinteger(L, -1);
+ lua_getfield(L, 3, "maximum");
+ if (lua_isnumber(L, -1))
+ abs.maximum = luaL_checkinteger(L, -1);
+ lua_getfield(L, 3, "resolution");
+ if (lua_isnumber(L, -1))
+ abs.resolution = luaL_checkinteger(L, -1);
+ lua_getfield(L, 3, "fuzz");
+ if (lua_isnumber(L, -1))
+ abs.fuzz = luaL_checkinteger(L, -1);
+ lua_getfield(L, 3, "flat");
+ if (lua_isnumber(L, -1))
+ abs.flat = luaL_checkinteger(L, -1);
+
+ libevdev_enable_event_code(device->evdev, EV_ABS, code, &abs);
+
+ return 0;
+}
+
+static int
+evdevdevice_connect(lua_State *L)
+{
+ EvdevDevice *device = luaL_checkudata(L, 1, EVDEV_DEVICE_METATABLE);
+ luaL_argcheck(L, device != NULL, 1, EVDEV_DEVICE_METATABLE " expected");
+
+ const char *name = luaL_checkstring(L, 2);
+ luaL_checktype(L, 3, LUA_TFUNCTION);
+
+ /* No refid means we got removed, so quietly
+ * drop any connect call */
+ if (device->refid == LUA_NOREF)
+ return 0;
+
+ if (streq(name, "device-removed")) {
+ register_func(L, 3, &device->device_removed_refid);
+ } else if (streq(name, "evdev-frame")) {
+ struct libinput_lua_plugin *plugin = lua_get_libinput_lua_plugin(L);
+ libinput_plugin_enable_device_event_frame(plugin->parent,
+ device->device,
+ true);
+ register_func(L, 3, &device->frame_refid);
+ } else {
+ return luaL_error(L, "Unknown name: %s", name);
+ }
+
+ return 0;
+}
+
+static int
+evdevdevice_disconnect(lua_State *L)
+{
+ EvdevDevice *device = luaL_checkudata(L, 1, EVDEV_DEVICE_METATABLE);
+ luaL_argcheck(L, device != NULL, 1, EVDEV_DEVICE_METATABLE " expected");
+
+ const char *name = luaL_checkstring(L, 2);
+
+ /* No refid means we got removed, so quietly
+ * drop any disconnect call */
+ if (device->refid == LUA_NOREF)
+ return 0;
+
+ if (streq(name, "device-removed")) {
+ unregister_func(L, &device->device_removed_refid);
+ } else if (streq(name, "evdev-frame")) {
+ struct libinput_lua_plugin *plugin = lua_get_libinput_lua_plugin(L);
+ libinput_plugin_enable_device_event_frame(plugin->parent,
+ device->device,
+ false);
+ unregister_func(L, &device->frame_refid);
+ } else {
+ return luaL_error(L, "Unknown name: %s", name);
+ }
+
+ return 0;
+}
+
+static struct evdev_frame *
+evdevdevice_frame(lua_State *L, struct libinput_lua_plugin *plugin)
+{
+ auto frame = evdev_frame_new(64);
+ lua_pop_evdev_frame(plugin, frame);
+
+ struct libinput *libinput = lua_get_libinput(L);
+ uint64_t now = libinput_now(libinput);
+ evdev_frame_set_time(frame, now);
+
+ return frame;
+}
+
+static int
+evdevdevice_inject_frame(lua_State *L)
+{
+ EvdevDevice *device = luaL_checkudata(L, 1, EVDEV_DEVICE_METATABLE);
+ luaL_argcheck(L, device != NULL, 1, EVDEV_DEVICE_METATABLE " expected");
+
+ luaL_checktype(L, 2, LUA_TTABLE);
+
+ /* No refid means we got removed, so quietly
+ * drop any disconnect call */
+ if (device->refid == LUA_NOREF)
+ return 0;
+
+ struct libinput_lua_plugin *plugin = lua_get_libinput_lua_plugin(L);
+ if (!plugin->in_timer_func) {
+ return luaL_error(L, "Injecting events only possible in a timer func");
+ }
+ _unref_(evdev_frame) *frame = evdevdevice_frame(L, plugin);
+
+ /* Lua is unhappy if we inject an event which calls into our lua state
+ * immediately so we need to queue this for later when we're out of the timer
+ * func */
+ struct timer_injected_event *event = zalloc(sizeof(*event));
+ event->device_refid = device->refid;
+ event->frame = steal(&frame);
+ list_insert(&plugin->timer_injected_events, &event->link);
+
+ return 0;
+}
+
+static int
+evdevdevice_prepend_frame(lua_State *L)
+{
+ EvdevDevice *device = luaL_checkudata(L, 1, EVDEV_DEVICE_METATABLE);
+ luaL_argcheck(L, device != NULL, 1, EVDEV_DEVICE_METATABLE " expected");
+
+ luaL_checktype(L, 2, LUA_TTABLE);
+
+ /* No refid means we got removed, so quietly
+ * drop any disconnect call */
+ if (device->refid == LUA_NOREF)
+ return 0;
+
+ struct libinput_lua_plugin *plugin = lua_get_libinput_lua_plugin(L);
+ _unref_(evdev_frame) *frame = evdevdevice_frame(L, plugin);
+ /* FIXME: need to really ensure that the device can never be dangling */
+ libinput_plugin_prepend_evdev_frame(plugin->parent, device->device, frame);
+
+ return 0;
+}
+
+static int
+evdevdevice_append_frame(lua_State *L)
+{
+ EvdevDevice *device = luaL_checkudata(L, 1, EVDEV_DEVICE_METATABLE);
+ luaL_argcheck(L, device != NULL, 1, EVDEV_DEVICE_METATABLE " expected");
+
+ luaL_checktype(L, 2, LUA_TTABLE);
+
+ /* No refid means we got removed, so quietly
+ * drop any disconnect call */
+ if (device->refid == LUA_NOREF)
+ return 0;
+
+ struct libinput_lua_plugin *plugin = lua_get_libinput_lua_plugin(L);
+ _unref_(evdev_frame) *frame = evdevdevice_frame(L, plugin);
+
+ /* FIXME: need to really ensure that the device can never be dangling */
+ libinput_plugin_append_evdev_frame(plugin->parent, device->device, frame);
+
+ return 0;
+}
+
+static int
+evdevdevice_gc(lua_State *L)
+{
+ EvdevDevice *device = luaL_checkudata(L, 1, EVDEV_DEVICE_METATABLE);
+ luaL_argcheck(L, device != NULL, 1, EVDEV_DEVICE_METATABLE "expected");
+
+ list_remove(&device->link);
+ struct udev_property *prop;
+ list_for_each_safe(prop, &device->udev_properties_list, link) {
+ udev_property_destroy(prop);
+ }
+ free(device->name);
+
+ return 0;
+}
+
+static const struct luaL_Reg evdevdevice_vtable[] = {
+ { "info", evdevdevice_info },
+ { "name", evdevdevice_name },
+ { "usages", evdevdevice_usages },
+ { "absinfos", evdevdevice_absinfos },
+ { "udev_properties", evdevdevice_udev_properties },
+ { "enable_evdev_usage", evdevdevice_enable_evdev_usage },
+ { "disable_evdev_usage", evdevdevice_disable_evdev_usage },
+ { "set_absinfo", evdevdevice_set_absinfo },
+ { "connect", evdevdevice_connect },
+ { "disconnect", evdevdevice_disconnect },
+ { "inject_frame", evdevdevice_inject_frame },
+ { "prepend_frame", evdevdevice_prepend_frame },
+ { "append_frame", evdevdevice_append_frame },
+ { "__gc", evdevdevice_gc },
+ { NULL, NULL }
+};
+
+static void
+evdevdevice_init(lua_State *L)
+{
+ luaL_newmetatable(L, EVDEV_DEVICE_METATABLE);
+ lua_pushstring(L, "__index");
+ lua_pushvalue(L, -2); /* push metatable */
+ lua_settable(L, -3); /* metatable.__index = metatable */
+ luaL_setfuncs(L, evdevdevice_vtable, 0);
+}
+
+static int
+logfunc(lua_State *L, enum libinput_log_priority pri)
+{
+ auto plugin = lua_get_libinput_lua_plugin(L);
+
+ const char *message = luaL_checkstring(L, 1);
+
+ plugin_log_msg(plugin->parent, pri, "%s\n", message);
+
+ return 0;
+}
+
+static int
+log_lua_error(lua_State *L)
+{
+ return logfunc(L, LIBINPUT_LOG_PRIORITY_ERROR);
+}
+
+static int
+log_lua_info(lua_State *L)
+{
+ return logfunc(L, LIBINPUT_LOG_PRIORITY_INFO);
+}
+
+static int
+log_lua_debug(lua_State *L)
+{
+ return logfunc(L, LIBINPUT_LOG_PRIORITY_DEBUG);
+}
+
+/* Exposes log.debug, log.info, log.error() */
+static const struct luaL_Reg log_funcs[] = { { "debug", log_lua_debug },
+ { "info", log_lua_info },
+ { "error", log_lua_error },
+ { NULL, NULL } };
+
+static void
+libinput_lua_plugin_destroy(struct libinput_lua_plugin *plugin)
+{
+ if (plugin->timer)
+ libinput_plugin_timer_cancel(plugin->timer);
+
+ EvdevDevice *evdev;
+ list_for_each_safe(evdev, &plugin->evdev_devices, link) {
+ remove_device(plugin, evdev);
+ }
+
+ if (plugin->timer)
+ plugin->timer = libinput_plugin_timer_unref(plugin->timer);
+ if (plugin->L)
+ lua_close(plugin->L);
+ free(plugin);
+}
+
+DEFINE_DESTROY_CLEANUP_FUNC(libinput_lua_plugin);
+
+static void
+libinput_plugin_destroy(struct libinput_plugin *libinput_plugin)
+{
+ struct libinput_lua_plugin *plugin =
+ libinput_plugin_get_user_data(libinput_plugin);
+ if (plugin)
+ libinput_lua_plugin_destroy(plugin);
+}
+
+static void
+libinput_lua_plugin_run(struct libinput_plugin *libinput_plugin)
+{
+ struct libinput_lua_plugin *plugin =
+ libinput_plugin_get_user_data(libinput_plugin);
+ struct lua_State *L = plugin->L;
+
+ /* Main entry point into the plugin, so we need to push our sandbox
+ * as _ENV. This only needs to be done here because all other entry
+ * points into the Lua script are callbacks that are set up during
+ * this run (and thus share the _ENV)
+ */
+ lua_pushvalue(L, plugin->sandbox_table_idx);
+ const char *upval = lua_setupvalue(L, -2, 1);
+ if (!upval || !streq(upval, "_ENV")) {
+ plugin_log_bug_libinput(libinput_plugin, "Failed to set up sandbox\n");
+ libinput_plugin_unregister(libinput_plugin);
+ return;
+ }
+
+ if (libinput_lua_pcall(plugin, 0, 0) && !plugin->register_called) {
+ plugin_log_bug(libinput_plugin,
+ "plugin never registered, unloading plugin\n");
+ libinput_plugin_unregister(libinput_plugin);
+ /* plugin system will destroy the plugin later */
+ }
+}
+
+static void
+libinput_lua_init_evdev_global(lua_State *L, int sandbox_table_idx)
+{
+ lua_newtable(L);
+ for (unsigned int t = 0; t < EV_MAX; t++) {
+ const char *typename = libevdev_event_type_get_name(t);
+ if (!typename)
+ continue;
+
+ int max = libevdev_event_type_get_max(t);
+ if (max < 0)
+ continue;
+
+ for (int i = 0; i < max; i++) {
+ const char *name = libevdev_event_code_get_name(t, i);
+ if (!name)
+ continue;
+
+ evdev_usage_t usage = evdev_usage_from_code(t, i);
+ lua_pushinteger(L, evdev_usage_as_uint32_t(usage));
+ lua_setfield(L, -2, name);
+ }
+ }
+
+#define pushbus(name, value) do { \
+ lua_pushinteger(L, value); \
+ lua_setfield(L, -2, #name); \
+ } while (0)
+
+ pushbus(BUS_PCI, 0x01);
+ pushbus(BUS_ISAPNP, 0x02);
+ pushbus(BUS_USB, 0x03);
+ pushbus(BUS_HIL, 0x04);
+ pushbus(BUS_BLUETOOTH, 0x05);
+ pushbus(BUS_VIRTUAL, 0x06);
+
+ pushbus(BUS_ISA, 0x10);
+ pushbus(BUS_I8042, 0x11);
+ pushbus(BUS_XTKBD, 0x12);
+ pushbus(BUS_RS232, 0x13);
+ pushbus(BUS_GAMEPORT, 0x14);
+ pushbus(BUS_PARPORT, 0x15);
+ pushbus(BUS_AMIGA, 0x16);
+ pushbus(BUS_ADB, 0x17);
+ pushbus(BUS_I2C, 0x18);
+ pushbus(BUS_HOST, 0x19);
+ pushbus(BUS_GSC, 0x1A);
+ pushbus(BUS_ATARI, 0x1B);
+ pushbus(BUS_SPI, 0x1C);
+ pushbus(BUS_RMI, 0x1D);
+ pushbus(BUS_CEC, 0x1E);
+ pushbus(BUS_INTEL_ISHTP, 0x1F);
+ pushbus(BUS_AMD_SFH, 0x20);
+
+#undef pushbus
+
+ lua_setfield(L, sandbox_table_idx, "evdev");
+}
+
+static const struct libinput_plugin_interface interface = {
+ .run = libinput_lua_plugin_run,
+ .destroy = libinput_plugin_destroy,
+ .device_new = libinput_lua_plugin_device_new,
+ .device_ignored = libinput_lua_plugin_device_ignored,
+ .device_added = NULL,
+ .device_removed = libinput_lua_plugin_device_removed,
+ .evdev_frame = libinput_lua_plugin_evdev_frame,
+};
+
+static lua_State *
+libinput_lua_plugin_init_lua(struct libinput *libinput,
+ struct libinput_lua_plugin *plugin)
+{
+ lua_State *L = luaL_newstate();
+ if (!L)
+ return NULL;
+
+ /* This will be our _ENV later, see libinput_lua_pcall */
+ lua_newtable(L);
+ int sandbox_table_idx = lua_gettop(L);
+ plugin->sandbox_table_idx = sandbox_table_idx;
+
+ /* Load the modules we want to (partially) expose.
+ * An (outdated?) list of safe function is here:
+ * http://lua-users.org/wiki/SandBoxes
+ *
+ * Math, String and Table seem to be safe given that our plugins
+ * all have their own individual sandbox.
+ */
+
+ luaL_requiref(L, LUA_GNAME, luaopen_base, 0);
+ static const char *allowed_funcs[] = {
+ "assert", "error", "ipairs", "next", "pcall", "pairs", "pairs",
+ "print", "warn", "tonumber", "tostring", "type", "xpcall",
+ };
+ ARRAY_FOR_EACH(allowed_funcs, func) {
+ lua_getfield(L, -1, *func);
+ lua_setfield(L, sandbox_table_idx, *func);
+ }
+
+ /* Math is fine as a whole */
+ luaL_requiref(L, LUA_MATHLIBNAME, luaopen_math, 0);
+ lua_setfield(L, sandbox_table_idx, "math");
+
+ /* Table is fine as a whole */
+ luaL_requiref(L, LUA_TABLIBNAME, luaopen_table, 0);
+ lua_setfield(L, sandbox_table_idx, "table");
+
+ /* Table is fine as a whole */
+ luaL_requiref(L, LUA_STRLIBNAME, luaopen_table, 0);
+ lua_setfield(L, sandbox_table_idx, "string");
+
+ /* Override metatable to prevent access to unregistered globals */
+ lua_pushvalue(L, sandbox_table_idx);
+ lua_newtable(L);
+ lua_setfield(L, -2, "__index");
+ lua_setmetatable(L, sandbox_table_idx);
+
+ /* Our objects */
+ libinputplugin_init(L);
+ evdevdevice_init(L);
+
+ /* Our globals */
+ luaL_newlib(L, log_funcs);
+ lua_setfield(L, sandbox_table_idx, "log");
+ libinput_lua_init_evdev_global(L, sandbox_table_idx);
+
+ /* The libinput global object */
+ lua_newuserdata(L, sizeof(LibinputPlugin));
+ luaL_getmetatable(L, PLUGIN_METATABLE);
+ lua_setmetatable(L, -2);
+ lua_setfield(L, sandbox_table_idx, "libinput");
+
+ /* Make struct libinput available in our callbacks */
+ lua_pushlightuserdata(L, (void *)&libinput_key);
+ lua_pushlightuserdata(L, libinput);
+ lua_settable(L, LUA_REGISTRYINDEX);
+
+ /* Make struct libinput_lua_plugin available in our callbacks */
+ lua_pushlightuserdata(L, (void *)&libinput_lua_plugin_key);
+ lua_pushlightuserdata(L, plugin);
+ lua_settable(L, LUA_REGISTRYINDEX);
+
+ return L;
+}
+
+struct libinput_plugin *
+libinput_lua_plugin_new_from_path(struct libinput *libinput, const char *path)
+{
+ _destroy_(libinput_lua_plugin) *plugin = zalloc(sizeof(*plugin));
+ _autofree_ char *name = safe_strdup(safe_basename(path));
+
+ /* libinput's plugin system keeps a ref, we don't need
+ * a separate ref here, the plugin system will outlast us.
+ */
+ _unref_(libinput_plugin) *p =
+ libinput_plugin_new(libinput, name, &interface, NULL);
+
+ plugin->parent = p;
+ plugin->register_called = false;
+ plugin->version = LIBINPUT_PLUGIN_VERSION;
+ plugin->device_new_refid = LUA_NOREF;
+ plugin->timer_expired_refid = LUA_NOREF;
+ list_init(&plugin->evdev_devices);
+ list_init(&plugin->timer_injected_events);
+
+ _cleanup_(lua_closep) lua_State *L =
+ libinput_lua_plugin_init_lua(libinput, plugin);
+ if (!L) {
+ plugin_log_bug(plugin->parent,
+ "Failed to create lua state for %s\n",
+ name);
+ libinput_plugin_unregister(p);
+ return NULL;
+ }
+
+ int ret = luaL_loadfile(L, path);
+ if (ret == LUA_OK) {
+ plugin->L = steal(&L);
+
+ libinput_plugin_set_user_data(p, steal(&plugin));
+ return p;
+ } else {
+ const char *lua_error = lua_tostring(L, -1);
+ const char *error = lua_error;
+ if (!error) {
+ switch (ret) {
+ case LUA_ERRMEM:
+ error = "out of memory";
+ break;
+ case LUA_ERRFILE:
+ error = "file not found or not readable";
+ break;
+ case LUA_ERRSYNTAX:
+ error = "syntax error";
+ break;
+ default:
+ break;
+ }
+ }
+
+ if (ret == LUA_ERRSYNTAX &&
+ log_is_logged(libinput, LIBINPUT_LOG_PRIORITY_DEBUG)) {
+ luaL_traceback(L, L, NULL, 1);
+ for (int i = -1; i > -4; i--) {
+ const char *msg = lua_tostring(L, i);
+ if (!msg)
+ break;
+ log_debug(libinput, "%s %s\n", name, msg);
+ }
+ lua_pop(L, 1); /* traceback */
+ }
+
+ plugin_log_bug(plugin->parent, "Failed to load %s: %s\n", path, error);
+
+ lua_pop(L, 1); /* the lua_error message */
+
+ libinput_plugin_unregister(p);
+
+ return NULL;
+ }
+}
diff --git a/src/libinput-plugin-lua.h b/src/libinput-plugin-lua.h
new file mode 100644
index 00000000..b8995f66
--- /dev/null
+++ b/src/libinput-plugin-lua.h
@@ -0,0 +1,32 @@
+/*
+ * Copyright © 2025 Red Hat, Inc.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a
+ * copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice (including the next
+ * paragraph) shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+ * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+ * DEALINGS IN THE SOFTWARE.
+ */
+
+#pragma once
+
+#include "config.h"
+
+#include "libinput-plugin.h"
+#include "libinput.h"
+
+struct libinput_plugin *
+libinput_lua_plugin_new_from_path(struct libinput *libinput, const char *path);
diff --git a/src/libinput-plugin.c b/src/libinput-plugin.c
index 181b8cf7..72d969fe 100644
--- a/src/libinput-plugin.c
+++ b/src/libinput-plugin.c
@@ -31,6 +31,7 @@
#include "evdev-frame.h"
#include "evdev-plugin.h"
#include "libinput-plugin-button-debounce.h"
+#include "libinput-plugin-lua.h"
#include "libinput-plugin-mouse-wheel-lowres.h"
#include "libinput-plugin-mouse-wheel.h"
#include "libinput-plugin-mtdev.h"
@@ -363,6 +364,18 @@ libinput_plugin_system_load_plugins(struct libinput *libinput,
return 0;
}
+#if HAVE_LUA
+ _autostrvfree_ char **directories = steal(&libinput->plugin_system.directories);
+ size_t nfiles = 0;
+ _autostrvfree_ char **plugin_files =
+ list_files((const char **)directories, ".lua", &nfiles);
+ for (size_t i = 0; i < nfiles; i++) {
+ char *path = plugin_files[i];
+ log_debug(libinput, "Loading plugin from %s\n", path);
+ libinput_lua_plugin_new_from_path(libinput, path);
+ }
+#endif
+
libinput_plugin_system_load_internal_plugins(libinput,
&libinput->plugin_system);
libinput->plugin_system.loaded = true;
diff --git a/test/test-plugins-lua.c b/test/test-plugins-lua.c
new file mode 100644
index 00000000..d1888661
--- /dev/null
+++ b/test/test-plugins-lua.c
@@ -0,0 +1,1098 @@
+/*
+ * Copyright © 2025 Red Hat, Inc.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a
+ * copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice (including the next
+ * paragraph) shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+ * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+ * DEALINGS IN THE SOFTWARE.
+ */
+
+#include "config.h"
+
+#include
+#include
+
+#include "util-files.h"
+#include "util-strings.h"
+#include "util-time.h"
+
+#include "libinput.h"
+#include "litest.h"
+
+static char *
+_litest_write_plugin(const char *tmpdir, const char *filename, const char *content)
+{
+ static int counter = 0;
+ counter += 10;
+
+ char *path = strdup_printf("%s/%d-%s.lua", tmpdir, counter, filename);
+ _autoclose_ int fd = open(path, O_WRONLY | O_CREAT, 0644);
+ litest_assert_errno_success(fd);
+
+ if (content) {
+ write(fd, content, strlen(content));
+ fsync(fd);
+ }
+
+ return path;
+}
+
+#define litest_write_plugin(tmpdir_, content_) \
+ _litest_write_plugin(tmpdir_, __func__, content_)
+
+START_TEST(lua_load_failure)
+{
+ _destroy_(tmpdir) *tmpdir = tmpdir_create(NULL);
+ const char *lua = "asfasdk1298'..asdfasdf'123@2;asd"; /* invalid lua */
+ _autofree_ char *path = litest_write_plugin(tmpdir->path, lua);
+ _litest_context_destroy_ struct libinput *li =
+ litest_create_context_with_plugindir(tmpdir->path);
+
+ litest_with_logcapture(li, capture) {
+ libinput_plugin_system_load_plugins(li, LIBINPUT_PLUGIN_FLAG_NONE);
+ litest_drain_events(li);
+
+ size_t index = 0;
+ litest_assert(
+ strv_find_substring(capture->errors, "Failed to load", &index));
+ litest_assert_str_in(path, capture->errors[index]);
+ }
+}
+END_TEST
+
+enum content {
+ EMPTY,
+ NOTHING,
+ BASIC,
+ COMMENT,
+ DUPLICATE_CALL,
+ VERSION_NOT_A_TABLE,
+ MISSING_REGISTER,
+};
+
+START_TEST(lua_load_success_but_no_register)
+{
+ enum content content = litest_test_param_get_i32(test_env->params, "content");
+
+ _destroy_(tmpdir) *tmpdir = tmpdir_create(NULL);
+
+ const char *lua = NULL;
+ switch (content) {
+ case DUPLICATE_CALL:
+ lua = "v1 = libinput:register({1})\np2 = libinput:register({2})\n";
+ break;
+ case VERSION_NOT_A_TABLE:
+ lua = "v1 = libinput:register(1)\n";
+ break;
+ case MISSING_REGISTER:
+ lua = "libinput:connect(\"new-evdev-device\", function(device) assert(false) end)\n";
+ break;
+ case BASIC:
+ lua = "a = 1 + 10";
+ break;
+ case COMMENT:
+ lua = "-- near-empty file";
+ break;
+ case NOTHING:
+ lua = "";
+ break;
+ case EMPTY:
+ break;
+ }
+
+ _autofree_ char *path = litest_write_plugin(tmpdir->path, lua);
+ _litest_context_destroy_ struct libinput *li =
+ litest_create_context_with_plugindir(tmpdir->path);
+
+ litest_with_logcapture(li, capture) {
+ libinput_plugin_system_load_plugins(li, LIBINPUT_PLUGIN_FLAG_NONE);
+ switch (content) {
+ case VERSION_NOT_A_TABLE:
+ litest_assert_strv_substring(capture->errors,
+ "unloading after error");
+ break;
+ case DUPLICATE_CALL:
+ litest_assert_strv_substring(capture->errors,
+ "plugin already registered");
+ break;
+ default:
+ litest_assert_strv_substring(capture->errors,
+ "plugin never registered");
+ break;
+ }
+ }
+}
+END_TEST
+
+START_TEST(lua_register_noop)
+{
+ _destroy_(tmpdir) *tmpdir = tmpdir_create(NULL);
+ const char *lua = "libinput:register({1})";
+ _autofree_ char *path = litest_write_plugin(tmpdir->path, lua);
+ _litest_context_destroy_ struct libinput *li =
+ litest_create_context_with_plugindir(tmpdir->path);
+ litest_with_logcapture(li, capture) {
+ libinput_plugin_system_load_plugins(li, LIBINPUT_PLUGIN_FLAG_NONE);
+ litest_drain_events(li);
+ litest_assert_logcapture_no_errors(capture);
+ }
+}
+END_TEST
+
+START_TEST(lua_unregister_is_last)
+{
+ const char *when = litest_test_param_get_string(test_env->params, "when");
+
+ _destroy_(tmpdir) *tmpdir = tmpdir_create(NULL);
+ _autofree_ char *lua = strdup_printf(
+ "libinput:register({1})\n"
+ "libinput:connect(\"new-evdev-device\", function(device)\n %s\n log.error(\"abort abort\")\nend)\n"
+ "%slog.error(\"must not happen\")",
+ streq(when, "connect") ? "libinput:unregister()" : "",
+ streq(when, "run") ? "libinput:unregister()\n" : "--");
+ _autofree_ char *path = litest_write_plugin(tmpdir->path, lua);
+
+ _litest_context_destroy_ struct libinput *li =
+ litest_create_context_with_plugindir(tmpdir->path);
+ litest_with_logcapture(li, capture) {
+ libinput_plugin_system_load_plugins(li, LIBINPUT_PLUGIN_FLAG_NONE);
+ _destroy_(litest_device) *device = litest_add_device(li, LITEST_MOUSE);
+ litest_drain_events(li);
+ litest_assert_logcapture_no_errors(capture);
+ }
+}
+END_TEST
+
+START_TEST(lua_test_log_global)
+{
+ _destroy_(tmpdir) *tmpdir = tmpdir_create(NULL);
+
+ enum libinput_log_priority priority =
+ litest_test_param_get_i32(test_env->params, "priority");
+
+ const char *lua = NULL;
+ switch (priority) {
+ case LIBINPUT_LOG_PRIORITY_DEBUG:
+ lua = "log.debug(\"deb-ug\");";
+ break;
+ case LIBINPUT_LOG_PRIORITY_INFO:
+ lua = "log.info(\"inf-o\");";
+ break;
+ case LIBINPUT_LOG_PRIORITY_ERROR:
+ lua = "log.error(\"err-or\");";
+ break;
+ default:
+ litest_assert_not_reached();
+ break;
+ }
+
+ _autofree_ char *path = litest_write_plugin(tmpdir->path, lua);
+ _litest_context_destroy_ struct libinput *li =
+ litest_create_context_with_plugindir(tmpdir->path);
+ libinput_log_set_priority(li, priority);
+
+ litest_with_logcapture(li, capture) {
+ libinput_plugin_system_load_plugins(li, LIBINPUT_PLUGIN_FLAG_NONE);
+ litest_drain_events(li);
+
+ switch (priority) {
+ case LIBINPUT_LOG_PRIORITY_DEBUG:
+ litest_assert(
+ strv_find_substring(capture->debugs, "deb-ug", NULL));
+ litest_assert(
+ !strv_find_substring(capture->infos, "inf-o", NULL));
+ litest_assert(
+ !strv_find_substring(capture->errors, "err-or", NULL));
+ break;
+ case LIBINPUT_LOG_PRIORITY_INFO:
+ litest_assert(
+ !strv_find_substring(capture->debugs, "deb-ug", NULL));
+ litest_assert(
+ strv_find_substring(capture->infos, "inf-o", NULL));
+ litest_assert(
+ !strv_find_substring(capture->errors, "err-or", NULL));
+ break;
+ case LIBINPUT_LOG_PRIORITY_ERROR:
+ litest_assert(
+ !strv_find_substring(capture->debugs, "deb-ug", NULL));
+ litest_assert(
+ !strv_find_substring(capture->infos, "inf-o", NULL));
+ litest_assert(
+ strv_find_substring(capture->errors, "err-or", NULL));
+ break;
+ }
+ }
+}
+END_TEST
+
+START_TEST(lua_test_evdev_global)
+{
+ _destroy_(tmpdir) *tmpdir = tmpdir_create(NULL);
+
+ /* This is generated, if a few of them work the
+ * rest should work too */
+ const char *lua =
+ "libinput:register({1}); a = evdev.ABS_X\nb = evdev.REL_X\nc = evdev.KEY_A\nd = evdev.EV_SYN\n";
+ _autofree_ char *path = litest_write_plugin(tmpdir->path, lua);
+ _litest_context_destroy_ struct libinput *li =
+ litest_create_context_with_plugindir(tmpdir->path);
+ litest_with_logcapture(li, capture) {
+ libinput_plugin_system_load_plugins(li, LIBINPUT_PLUGIN_FLAG_NONE);
+ litest_drain_events(li);
+
+ litest_assert_logcapture_no_errors(capture);
+ }
+}
+END_TEST
+
+START_TEST(lua_test_libinput_now)
+{
+ _destroy_(tmpdir) *tmpdir = tmpdir_create(NULL);
+ const char *lua = "log.error(\">>>\" .. libinput:now())";
+ _autofree_ char *path = litest_write_plugin(tmpdir->path, lua);
+ _litest_context_destroy_ struct libinput *li =
+ litest_create_context_with_plugindir(tmpdir->path);
+ litest_with_logcapture(li, capture) {
+ libinput_plugin_system_load_plugins(li, LIBINPUT_PLUGIN_FLAG_NONE);
+ litest_drain_events(li);
+
+ uint64_t test_now;
+ int rc = now_in_us(&test_now);
+ litest_assert_neg_errno_success(rc);
+
+ size_t index = 0;
+ litest_assert(strv_find_substring(capture->errors, ">>>", &index));
+ size_t nelem = 0;
+ _autostrvfree_ char **tokens =
+ strv_from_string(capture->errors[index], ">>>", &nelem);
+ litest_assert_int_eq(nelem, 2U);
+
+ uint64_t plugin_now = strtoull(tokens[1], NULL, 10);
+
+ litest_assert_int_le(plugin_now, test_now);
+ /* Even a slow test runner hopefully doesn't take >300ms to get to the
+ * log print */
+ litest_assert_int_gt(plugin_now, test_now - ms2us(300));
+ }
+}
+END_TEST
+
+START_TEST(lua_test_libinput_timer)
+{
+ _destroy_(tmpdir) *tmpdir = tmpdir_create(NULL);
+
+ const char *mode = litest_test_param_get_string(test_env->params, "mode");
+ bool reschedule = litest_test_param_get_bool(test_env->params, "reschedule");
+
+ _autofree_ char *timeout =
+ strdup_printf("%s%" PRIu64,
+ streq(mode, "absolute") ? "libinput:now() + " : "",
+ ms2us(100));
+ _autofree_ char *reschedule_timeout =
+ strdup_printf("libinput:timer_set_%s(%s%" PRIu64 ")\n",
+ mode,
+ streq(mode, "absolute") ? "t + " : "",
+ ms2us(100));
+ _autofree_ char *lua = strdup_printf(
+ "libinput:register({1})\n"
+ "libinput:connect(\"timer-expired\",\n"
+ " function(t)\n"
+ " log.error(\">>>\" .. t)\n"
+ " %s\n"
+ " end)\n"
+ "libinput:timer_set_%s(%s)\n",
+ reschedule ? reschedule_timeout : "",
+ mode,
+ timeout);
+
+ _autofree_ char *path = litest_write_plugin(tmpdir->path, lua);
+ _litest_context_destroy_ struct libinput *li =
+ litest_create_context_with_plugindir(tmpdir->path);
+
+ litest_with_logcapture(li, capture) {
+ libinput_plugin_system_load_plugins(li, LIBINPUT_PLUGIN_FLAG_NONE);
+ litest_drain_events(li);
+
+ litest_assert_logcapture_no_errors(capture);
+
+ size_t nloops = reschedule ? 4 : 1;
+ for (size_t i = 0; i < nloops; i++) {
+ libinput_dispatch(li);
+ msleep(100);
+ libinput_dispatch(li);
+
+ uint64_t test_now;
+ int rc = now_in_us(&test_now);
+ litest_assert_neg_errno_success(rc);
+
+ _autostrvfree_ char **msg = steal(&capture->errors);
+ litest_assert_ptr_notnull(msg);
+ size_t index;
+ litest_assert(strv_find_substring(msg, ">>>", &index));
+
+ size_t nelem = 0;
+ _autostrvfree_ char **tokens =
+ strv_from_string(msg[index], ">>>", &nelem);
+ litest_assert_int_eq(nelem, 2U);
+
+ uint64_t plugin_now = strtoull(tokens[1], NULL, 10);
+ litest_assert_int_le(plugin_now, test_now);
+ /* Even a slow test runner hopefully doesn't take >300ms between
+ * dispatch and now_in_us */
+ litest_assert_int_gt(plugin_now, test_now - ms2us(300));
+ }
+
+ if (!reschedule) {
+ libinput_dispatch(li);
+ msleep(120);
+ libinput_dispatch(li);
+ }
+
+ litest_assert_logcapture_no_errors(capture);
+ }
+}
+END_TEST
+
+enum connect_error {
+ BAD_TYPE,
+ TOO_FEW_ARGS,
+ TOO_MANY_ARGS,
+};
+
+START_TEST(lua_bad_connect)
+{
+ _destroy_(tmpdir) *tmpdir = tmpdir_create(NULL);
+
+ const char *handler = litest_test_param_get_string(test_env->params, "handler");
+ enum connect_error error = litest_test_param_get_i32(test_env->params, "error");
+
+ const char *func = NULL;
+ switch (error) {
+ case BAD_TYPE:
+ func = "a";
+ break;
+ case TOO_FEW_ARGS:
+ func = "function(p) log.debug(\"few\"); end";
+ break;
+ case TOO_MANY_ARGS:
+ func = "function(p, a, b) log.debug(\"many\"); end";
+ break;
+ }
+
+ _autofree_ char *lua = strdup_printf(
+ "libinput:register({1})\n"
+ "a = 10\n"
+ "libinput:connect(\"%s\", %s)\n",
+ handler,
+ func);
+
+ _autofree_ char *path = litest_write_plugin(tmpdir->path, lua);
+ _litest_context_destroy_ struct libinput *li =
+ litest_create_context_with_plugindir(tmpdir->path);
+ litest_with_logcapture(li, capture) {
+ libinput_plugin_system_load_plugins(li, LIBINPUT_PLUGIN_FLAG_NONE);
+ litest_drain_events(li);
+
+ switch (error) {
+ /* These don't trigger a lua erro so we just test they don't segfault us
+ */
+ case TOO_FEW_ARGS:
+ case TOO_MANY_ARGS:
+ litest_assert_logcapture_no_errors(capture);
+ break;
+ case BAD_TYPE:
+ litest_assert_strv_substring(capture->errors,
+ "bad argument #2 to 'connect'");
+ break;
+ }
+ }
+}
+END_TEST
+
+START_TEST(lua_register_multiversions)
+{
+
+ _destroy_(tmpdir) *tmpdir = tmpdir_create(NULL);
+ const char *lua =
+ "v = libinput:register({1, 3, 4, 10, 15})\nlog.info(\"VERSION:\" .. v)\n";
+ _autofree_ char *path = litest_write_plugin(tmpdir->path, lua);
+ _litest_context_destroy_ struct libinput *li =
+ litest_create_context_with_plugindir(tmpdir->path);
+ if (libinput_log_get_priority(li) > LIBINPUT_LOG_PRIORITY_INFO)
+ libinput_log_set_priority(li, LIBINPUT_LOG_PRIORITY_INFO);
+
+ litest_with_logcapture(li, capture) {
+ libinput_plugin_system_load_plugins(li, LIBINPUT_PLUGIN_FLAG_NONE);
+ litest_drain_events(li);
+
+ litest_assert_logcapture_no_errors(capture);
+ litest_assert_strv_substring(capture->infos, "VERSION:1");
+ }
+}
+END_TEST
+
+START_TEST(lua_allowed_functions)
+{
+ _destroy_(tmpdir) *tmpdir = tmpdir_create(NULL);
+ /* This tests on the assumption that if some of these work,
+ * then the others we allow will work too. */
+ const char *lua =
+ "\n"
+ "libinput:register({1})\n"
+ "a = {10, 20}\n"
+ "for _, v in ipairs(a) do\n"
+ " v = v + 1\n"
+ "end\n"
+ "b = {foo = 1}"
+ "for k, v in pairs(a) do\n"
+ " v = v + 1\n"
+ "end\n"
+ "print(math.maxinteger)\n"
+ "table.sort({10, 2, 4})\n"
+ "assert(true)\n"
+ "";
+ _autofree_ char *path = litest_write_plugin(tmpdir->path, lua);
+ _litest_context_destroy_ struct libinput *li =
+ litest_create_context_with_plugindir(tmpdir->path);
+ litest_with_logcapture(li, capture) {
+ libinput_plugin_system_load_plugins(li, LIBINPUT_PLUGIN_FLAG_NONE);
+ litest_drain_events(li);
+
+ litest_assert_logcapture_no_errors(capture);
+ }
+}
+END_TEST
+
+START_TEST(lua_disallowed_functions)
+{
+ _destroy_(tmpdir) *tmpdir = tmpdir_create(NULL);
+ /* This tests on the assumption that if some of these work,
+ * then the others we allow will work too. */
+ const char *lua =
+ "\n"
+ "libinput:register({1})\n"
+ "assert(io == nil)\n"
+ "assert(require == nil)\n"
+ "assert(rawget == nil)\n"
+ "assert(rawset == nil)\n"
+ "assert(setfenv == nil)\n"
+ "assert(getmetatable == nil)\n"
+ "assert(setmetatable == nil)\n"
+ "assert(package == nil)\n"
+ "assert(os == nil)\n"
+ "assert(debug == nil)\n"
+ "";
+ _autofree_ char *path = litest_write_plugin(tmpdir->path, lua);
+ _litest_context_destroy_ struct libinput *li =
+ litest_create_context_with_plugindir(tmpdir->path);
+ litest_with_logcapture(li, capture) {
+ libinput_plugin_system_load_plugins(li, LIBINPUT_PLUGIN_FLAG_NONE);
+ litest_drain_events(li);
+
+ litest_assert_logcapture_no_errors(capture);
+ }
+}
+END_TEST
+
+START_TEST(lua_frame_handler)
+{
+ _destroy_(tmpdir) *tmpdir = tmpdir_create(NULL);
+ const char *lua =
+ "libinput:register({1})\n"
+ "function frame_handler(_, frame, timestamp)\n"
+ " log.info(\"T:\" .. timestamp)\n"
+ " for _, e in ipairs(frame) do\n"
+ " log.info(\"E:\" .. e.usage .. \":\" .. e.value)\n"
+ " end\n"
+ "end\n"
+ "libinput:connect(\"new-evdev-device\", function(device) device:connect(\"evdev-frame\", frame_handler) end)\n";
+
+ _autofree_ char *path = litest_write_plugin(tmpdir->path, lua);
+ _litest_context_destroy_ struct libinput *li =
+ litest_create_context_with_plugindir(tmpdir->path);
+ if (libinput_log_get_priority(li) > LIBINPUT_LOG_PRIORITY_INFO)
+ libinput_log_set_priority(li, LIBINPUT_LOG_PRIORITY_INFO);
+
+ litest_with_logcapture(li, capture) {
+ libinput_plugin_system_load_plugins(li, LIBINPUT_PLUGIN_FLAG_NONE);
+ litest_drain_events(li);
+
+ _destroy_(litest_device) *device = litest_add_device(li, LITEST_MOUSE);
+ litest_drain_events(li);
+
+ uint64_t before, after;
+ now_in_us(&before);
+ msleep(1);
+ litest_button_click_debounced(device, li, BTN_LEFT, 1);
+ litest_button_click_debounced(device, li, BTN_LEFT, 0);
+ litest_assert_logcapture_no_errors(capture);
+ msleep(1);
+ now_in_us(&after);
+
+ /* EV_KEY << 16 | BTN_LEFT -> 65808 */
+
+ litest_assert_strv_substring(capture->infos, "E:65808:1");
+ litest_assert_strv_substring(capture->infos, "E:65808:0");
+ /* SYN_REPORT shouldn't show up in the frame */
+ litest_assert(!strv_find_substring(capture->infos, "E:0:0", NULL));
+
+ size_t idx;
+ litest_assert(strv_find_substring(capture->infos, "T:", &idx));
+
+ _autofree_ char *str = safe_strdup(capture->infos[idx]);
+ for (size_t i = 0; str[i]; i++) {
+ if (str[i] == '\n') {
+ str[i] = '\0';
+ break;
+ }
+ }
+
+ size_t nelems;
+ _autostrvfree_ char **split = strv_from_string(str, ":", &nelems);
+ litest_assert_int_gt(nelems, 1U);
+ char *strtime = split[nelems - 1];
+ uint64_t timestamp = 0;
+ litest_assert(safe_atou64(strtime, ×tamp));
+ litest_assert_int_gt(timestamp, before);
+ litest_assert_int_lt(timestamp, after);
+ }
+}
+END_TEST
+
+START_TEST(lua_device_info)
+{
+ _destroy_(tmpdir) *tmpdir = tmpdir_create(NULL);
+ const char *lua =
+ "libinput:register({1})\n"
+ "function info_printer(device)\n"
+ " local info = device:info()\n"
+ " log.info(\"BUS:\" .. info.bustype)\n"
+ " log.info(\"VID:\" .. info.vid)\n"
+ " log.info(\"PID:\" .. info.pid)\n"
+ "end\n"
+ "libinput:connect(\"new-evdev-device\", info_printer)\n";
+
+ _autofree_ char *path = litest_write_plugin(tmpdir->path, lua);
+ _litest_context_destroy_ struct libinput *li =
+ litest_create_context_with_plugindir(tmpdir->path);
+ if (libinput_log_get_priority(li) > LIBINPUT_LOG_PRIORITY_INFO)
+ libinput_log_set_priority(li, LIBINPUT_LOG_PRIORITY_INFO);
+
+ litest_with_logcapture(li, capture) {
+ libinput_plugin_system_load_plugins(li, LIBINPUT_PLUGIN_FLAG_NONE);
+ litest_drain_events(li);
+
+ _destroy_(litest_device) *device = litest_add_device(li, LITEST_MOUSE);
+ litest_drain_events(li);
+
+ litest_assert_strv_substring(capture->infos, "BUS:3");
+ litest_assert_strv_substring(capture->infos, "VID:6127");
+ litest_assert_strv_substring(capture->infos, "PID:24601");
+ }
+}
+END_TEST
+
+START_TEST(lua_set_absinfo)
+{
+ _destroy_(tmpdir) *tmpdir = tmpdir_create(NULL);
+ const char *lua =
+ "libinput:register({1})\n"
+ "function absinfo_setter(device)\n"
+ " local absinfos = device:absinfos()\n"
+ " for u, a in pairs(absinfos) do\n"
+ " log.info(\"A:\" .. u .. \":\" .. a.minimum .. \":\" .. a.maximum .. \":\" .. a.resolution .. \":\" .. a.fuzz .. \":\" .. a.flat)\n"
+ " end\n"
+ " device:set_absinfo(evdev.ABS_X, { minimum = 0, maximum = 1000, resolution = 100 })\n"
+ " device:set_absinfo(evdev.ABS_Y, { minimum = 0, maximum = 200, resolution = 10 })\n"
+ " device:set_absinfo(evdev.ABS_MT_POSITION_X, { minimum = 0, maximum = 1000, resolution = 100 })\n"
+ " device:set_absinfo(evdev.ABS_MT_POSITION_Y, { minimum = 0, maximum = 200, resolution = 10 })\n"
+ "end\n"
+ "libinput:connect(\"new-evdev-device\", absinfo_setter)\n";
+
+ _autofree_ char *path = litest_write_plugin(tmpdir->path, lua);
+ _litest_context_destroy_ struct libinput *li =
+ litest_create_context_with_plugindir(tmpdir->path);
+ if (libinput_log_get_priority(li) > LIBINPUT_LOG_PRIORITY_INFO)
+ libinput_log_set_priority(li, LIBINPUT_LOG_PRIORITY_INFO);
+
+ litest_with_logcapture(li, capture) {
+ libinput_plugin_system_load_plugins(li, LIBINPUT_PLUGIN_FLAG_NONE);
+ litest_drain_events(li);
+
+ _destroy_(litest_device) *device =
+ litest_add_device(li, LITEST_GENERIC_MULTITOUCH_SCREEN);
+ litest_drain_events(li);
+
+ litest_assert_logcapture_no_errors(capture);
+
+ for (int code = 0; code <= ABS_MAX; code++) {
+ if (!libevdev_has_event_code(device->evdev, EV_ABS, code)) {
+ _autofree_ char *prefix =
+ strdup_printf("A:%u", (EV_ABS << 16) | code);
+ litest_assert(!strv_find_substring(capture->infos,
+ prefix,
+ NULL));
+ continue;
+ }
+
+ const struct input_absinfo *absinfo =
+ libevdev_get_abs_info(device->evdev, code);
+ _autofree_ char *message = strdup_printf("A:%u:%d:%d:%d:%d:%d",
+ (EV_ABS << 16) | code,
+ absinfo->minimum,
+ absinfo->maximum,
+ absinfo->resolution,
+ absinfo->fuzz,
+ absinfo->flat);
+ litest_assert_strv_substring(capture->infos, message);
+ }
+
+ /* If the absinfo worked, our device is 10x20mm big */
+ double w, h;
+ libinput_device_get_size(device->libinput_device, &w, &h);
+ litest_assert_double_eq(w, 10.0);
+ litest_assert_double_eq(h, 20.0);
+ }
+}
+END_TEST
+
+START_TEST(lua_enable_disable_evdev_usage)
+{
+ _destroy_(tmpdir) *tmpdir = tmpdir_create(NULL);
+ /* We have two plugins here, one that enables codes and one that prints
+ * the frame.
+ *
+ * The first plugin also inserts a REL_Z event into the frame since we
+ * can't send that through the kernel.
+ */
+ const char *lua1 =
+ "libinput:register({1})\n"
+ "function frame_handler(_, frame, timestamp)\n"
+ " table.insert(frame, { usage = evdev.REL_Z, value = 3 })\n"
+ " return frame\n"
+ "end\n"
+ "function enabler(device)\n"
+ " device:enable_evdev_usage(evdev.REL_Z)\n"
+ " device:enable_evdev_usage(evdev.BTN_STYLUS2)\n"
+ " device:disable_evdev_usage(evdev.REL_WHEEL)\n"
+ " device:connect(\"evdev-frame\", frame_handler)\n"
+ "end\n"
+ "libinput:connect(\"new-evdev-device\", enabler)\n";
+
+ const char *lua2 =
+ "libinput:register({1})\n"
+ "function frame_handler(_, frame, timestamp)\n"
+ " log.info(\"frame\")\n"
+ " for _, e in ipairs(frame) do\n"
+ " log.info(\"E:\" .. e.usage .. \":\" .. e.value)\n"
+ " end\n"
+ "end\n"
+ "function f(device)\n"
+ " log.info(\"F: \" .. device:name())\n"
+ " device:connect(\"evdev-frame\", frame_handler)\n"
+ "end\n"
+ "libinput:connect(\"new-evdev-device\", f)\n";
+
+ _autofree_ char *p1 = litest_write_plugin(tmpdir->path, lua1);
+ _autofree_ char *p2 = litest_write_plugin(tmpdir->path, lua2);
+ _litest_context_destroy_ struct libinput *li =
+ litest_create_context_with_plugindir(tmpdir->path);
+ if (libinput_log_get_priority(li) > LIBINPUT_LOG_PRIORITY_INFO)
+ libinput_log_set_priority(li, LIBINPUT_LOG_PRIORITY_INFO);
+
+ litest_with_logcapture(li, capture) {
+ libinput_plugin_system_load_plugins(li, LIBINPUT_PLUGIN_FLAG_NONE);
+ litest_drain_events(li);
+
+ _destroy_(litest_device) *device = litest_add_device(li, LITEST_MOUSE);
+ litest_drain_events(li);
+
+ /* We enabled that one ourselves */
+ litest_assert(
+ libinput_device_pointer_has_button(device->libinput_device,
+ BTN_STYLUS2));
+
+ litest_assert_logcapture_no_errors(capture);
+
+ litest_event(device, EV_REL, REL_X, 1);
+ litest_event(device, EV_REL, REL_Y, 2);
+ litest_event(device, EV_REL, REL_WHEEL, -1);
+ litest_event(device, EV_SYN, SYN_REPORT, 0);
+ litest_dispatch(li);
+
+ litest_assert_logcapture_no_errors(capture);
+
+ litest_assert_strv_substring(capture->infos, "E:131072:1");
+ litest_assert_strv_substring(capture->infos, "E:131073:2");
+ litest_assert_strv_substring(capture->infos, "E:131074:3");
+ litest_assert(!strv_find_substring(capture->infos, "E:131080", NULL));
+ }
+}
+END_TEST
+
+START_TEST(lua_udev_properties)
+{
+ _destroy_(tmpdir) *tmpdir = tmpdir_create(NULL);
+ const char *lua =
+ "libinput:register({1})\n"
+ "function prop_printer(device)\n"
+ " local properties = device:udev_properties()\n"
+ " for k, v in pairs(properties) do\n"
+ " log.info(k .. \"=\" .. v)\n"
+ " end\n"
+ "end\n"
+ "libinput:connect(\"new-evdev-device\", prop_printer)\n";
+
+ _autofree_ char *path = litest_write_plugin(tmpdir->path, lua);
+ _litest_context_destroy_ struct libinput *li =
+ litest_create_context_with_plugindir(tmpdir->path);
+ if (libinput_log_get_priority(li) > LIBINPUT_LOG_PRIORITY_INFO)
+ libinput_log_set_priority(li, LIBINPUT_LOG_PRIORITY_INFO);
+
+ litest_with_logcapture(li, capture) {
+ libinput_plugin_system_load_plugins(li, LIBINPUT_PLUGIN_FLAG_NONE);
+ litest_drain_events(li);
+
+ enum litest_device_type which =
+ litest_test_param_get_i32(test_env->params, "which");
+ _destroy_(litest_device) *device = litest_add_device(li, which);
+ litest_drain_events(li);
+
+ litest_assert_logcapture_no_errors(capture);
+
+ switch (which) {
+ case LITEST_TRACKPOINT:
+ litest_assert_strv_substring(capture->infos,
+ "ID_INPUT_POINTINGSTICK=1");
+ _fallthrough_;
+ case LITEST_MOUSE:
+ litest_assert_strv_substring(capture->infos,
+ "ID_INPUT_MOUSE=1");
+ break;
+ case LITEST_GENERIC_MULTITOUCH_SCREEN:
+ litest_assert_strv_substring(capture->infos,
+ "ID_INPUT_TOUCHSCREEN=1");
+ break;
+ default:
+ litest_assert_not_reached();
+ break;
+ }
+ litest_assert(!strv_find_substring(capture->infos,
+ "ID_INPUT_WIDTH_MM",
+ NULL));
+ litest_assert(!strv_find_substring(capture->infos,
+ "ID_INPUT_WIDTH_MM",
+ NULL));
+ }
+}
+END_TEST
+
+START_TEST(lua_append_prepend_frame)
+{
+ bool append = litest_test_param_get_bool(test_env->params, "append");
+ bool in_timer = litest_test_param_get_bool(test_env->params, "in_timer");
+ _destroy_(tmpdir) *tmpdir = tmpdir_create(NULL);
+ _autofree_ char *lua = strdup_printf(
+ "libinput:register({1})\n"
+ "mydev = nil\n"
+ "function frame_handler(device, frame, timestamp)\n"
+ " device:%s_frame({{ usage = evdev.BTN_LEFT, value = 1}})\n" /* commented
+ out
+ if
+ !in_timer
+ */
+ " return nil\n"
+ "end\n"
+ "libinput:connect(\"new-evdev-device\", function(device)\n"
+ " mydev = device\n"
+ " %sdevice:connect(\"evdev-frame\", frame_handler)\n"
+ " %slibinput:timer_set_relative(200000)\n" /* commented out if
+ !in_timer */
+ "end)\n"
+ "function timer_expired(t)\n"
+ " mydev:%s_frame({{ usage = evdev.BTN_LEFT, value = 1 }})\n"
+ "end\n"
+ "libinput:connect(\"timer-expired\", timer_expired)\n",
+ append ? "append" : "prepend",
+ in_timer ? "-- " : "",
+ in_timer ? "" : "-- ",
+ append ? "append" : "prepend");
+ _autofree_ char *path = litest_write_plugin(tmpdir->path, lua);
+ _litest_context_destroy_ struct libinput *li =
+ litest_create_context_with_plugindir(tmpdir->path);
+ libinput_plugin_system_load_plugins(li, LIBINPUT_PLUGIN_FLAG_NONE);
+ litest_drain_events(li);
+
+ _destroy_(litest_device) *device = litest_add_device(li, LITEST_MOUSE);
+ litest_drain_events(li);
+ msleep(10); /* trigger the timer, if any */
+ litest_dispatch(li);
+
+ if (in_timer) {
+ litest_assert_button_event(li, BTN_LEFT, LIBINPUT_BUTTON_STATE_PRESSED);
+ }
+
+ litest_event(device, EV_REL, REL_X, 1);
+ litest_event(device, EV_REL, REL_Y, 2);
+ litest_event(device, EV_SYN, SYN_REPORT, 0);
+ litest_dispatch(li);
+ litest_timeout_debounce(li);
+ litest_dispatch(li);
+
+ if (!in_timer && !append) {
+ litest_assert_button_event(li, BTN_LEFT, LIBINPUT_BUTTON_STATE_PRESSED);
+ }
+
+ _destroy_(libinput_event) *ev = libinput_get_event(li);
+ litest_is_motion_event(ev);
+
+ if (!in_timer && append) {
+ litest_assert_button_event(li, BTN_LEFT, LIBINPUT_BUTTON_STATE_PRESSED);
+ }
+
+ litest_assert_empty_queue(li);
+}
+END_TEST
+
+START_TEST(lua_inject_frame)
+{
+ bool in_timer = litest_test_param_get_bool(test_env->params, "in_timer");
+ _destroy_(tmpdir) *tmpdir = tmpdir_create(NULL);
+
+ /* Plugin 1 swaps left to right */
+ const char *lua1 =
+ "libinput:register({1})\n"
+ "function frame_handler(device, frame, timestamp)\n"
+ " for _, e in ipairs(frame) do\n"
+ " if e.usage == evdev.BTN_LEFT then\n"
+ " e.usage = evdev.BTN_RIGHT\n"
+ " end\n"
+ " end\n"
+ " return frame\n"
+ "end\n"
+ "libinput:connect(\"new-evdev-device\", function(device)\n"
+ " device:connect(\"evdev-frame\", frame_handler)\n"
+ "end)\n";
+
+ /* Plugin 2 injects a left button if a middle button is pressed */
+ _autofree_ char *lua2 = strdup_printf(
+ "libinput:register({1})\n"
+ "mydev = nil\n"
+ "function frame_handler(device, frame, timestamp)\n"
+ " for _, e in ipairs(frame) do\n"
+ " if e.usage == evdev.BTN_SIDE then\n"
+ " e.usage = evdev.BTN_EXTRA\n"
+ " end\n"
+ " if e.usage == evdev.BTN_MIDDLE then\n"
+ " log.debug(\"Injecting frame BTN_LEFT value \" .. e.value)\n"
+ " %sdevice:inject_frame({{ usage = evdev.BTN_LEFT, value = e.value }})\n"
+ " %slibinput:timer_set_relative(200000)\n" /* commented out
+ if !in_timer */
+ " end\n"
+ " end\n"
+ " return frame\n"
+ "end\n"
+ "function timer_expired(t)\n"
+ " log.debug(\"Injecting timer BTN_LEFT\")\n"
+ " mydev:inject_frame({{ usage = evdev.BTN_LEFT, value = 1 }})\n"
+ "end\n"
+ "libinput:connect(\"new-evdev-device\", function(device)\n"
+ " mydev = device\n"
+ " device:connect(\"evdev-frame\", frame_handler)\n"
+ "end)\n"
+ "libinput:connect(\"timer-expired\", timer_expired)\n",
+ in_timer ? "-- " : "",
+ in_timer ? "" : "-- ");
+
+ _autofree_ char *p1 = litest_write_plugin(tmpdir->path, lua1);
+ _autofree_ char *p2 = litest_write_plugin(tmpdir->path, lua2);
+ _litest_context_destroy_ struct libinput *li =
+ litest_create_context_with_plugindir(tmpdir->path);
+ libinput_plugin_system_load_plugins(li, LIBINPUT_PLUGIN_FLAG_NONE);
+ litest_drain_events(li);
+
+ _destroy_(litest_device) *device = litest_add_device(li, LITEST_CYBORG_RAT);
+ litest_drain_events(li);
+ litest_dispatch(li);
+
+ litest_log_group("P1 should swap left to right") {
+ litest_button_click_debounced(device, li, BTN_LEFT, 1);
+ litest_button_click_debounced(device, li, BTN_LEFT, 0);
+ litest_dispatch(li);
+
+ litest_assert_button_event(li,
+ BTN_RIGHT,
+ LIBINPUT_BUTTON_STATE_PRESSED);
+ litest_assert_button_event(li,
+ BTN_RIGHT,
+ LIBINPUT_BUTTON_STATE_RELEASED);
+ litest_drain_events(li);
+ }
+
+ litest_log_group("P1/P2 should leave BTN_EXTRA untouched") {
+ litest_button_click_debounced(device, li, BTN_EXTRA, 1);
+ litest_button_click_debounced(device, li, BTN_EXTRA, 0);
+ litest_dispatch(li);
+
+ /* This might be a false positive */
+ litest_assert_button_event(li,
+ BTN_EXTRA,
+ LIBINPUT_BUTTON_STATE_PRESSED);
+ litest_assert_button_event(li,
+ BTN_EXTRA,
+ LIBINPUT_BUTTON_STATE_RELEASED);
+ }
+
+ litest_log_group("P2 should map BTN_SIDE to BTN_EXTRA") {
+ litest_button_click_debounced(device, li, BTN_SIDE, 1);
+ litest_button_click_debounced(device, li, BTN_SIDE, 0);
+ litest_dispatch(li);
+ litest_timeout_debounce(li);
+ litest_dispatch(li);
+
+ litest_assert_button_event(li,
+ BTN_EXTRA,
+ LIBINPUT_BUTTON_STATE_PRESSED);
+ litest_assert_button_event(li,
+ BTN_EXTRA,
+ LIBINPUT_BUTTON_STATE_RELEASED);
+ }
+
+ if (in_timer) {
+ litest_log_group(
+ "P2 should inject left on middle via timer, P1 changes that left to right in timer") {
+ litest_button_click_debounced(device, li, BTN_MIDDLE, 1);
+ litest_dispatch(li);
+
+ litest_assert_button_event(li,
+ BTN_MIDDLE,
+ LIBINPUT_BUTTON_STATE_PRESSED);
+ litest_assert_button_event(li,
+ BTN_RIGHT,
+ LIBINPUT_BUTTON_STATE_PRESSED);
+
+ litest_button_click_debounced(device, li, BTN_MIDDLE, 0);
+ litest_dispatch(li);
+ litest_assert_button_event(li,
+ BTN_MIDDLE,
+ LIBINPUT_BUTTON_STATE_RELEASED);
+ /* We only inject BTN_RIGHT down, so the second inject should
+ * get filtered */
+ litest_assert_empty_queue(li);
+ }
+ } else {
+ litest_log_group("P2 tries to inject during frame, gets unloaded") {
+ litest_set_log_handler_bug(li);
+ litest_button_click_debounced(device, li, BTN_MIDDLE, 1);
+ litest_restore_log_handler(li);
+ litest_button_click_debounced(device, li, BTN_MIDDLE, 0);
+ litest_dispatch(li);
+
+ litest_assert_button_event(li,
+ BTN_MIDDLE,
+ LIBINPUT_BUTTON_STATE_PRESSED);
+ litest_assert_button_event(li,
+ BTN_MIDDLE,
+ LIBINPUT_BUTTON_STATE_RELEASED);
+ }
+
+ litest_log_group("P2 is unloaded, expect BTN_SIDE") {
+ litest_button_click_debounced(device, li, BTN_SIDE, 1);
+ litest_button_click_debounced(device, li, BTN_SIDE, 0);
+ litest_dispatch(li);
+
+ litest_assert_button_event(li,
+ BTN_SIDE,
+ LIBINPUT_BUTTON_STATE_PRESSED);
+ litest_assert_button_event(li,
+ BTN_SIDE,
+ LIBINPUT_BUTTON_STATE_RELEASED);
+ }
+ }
+}
+END_TEST
+
+TEST_COLLECTION(lua)
+{
+ /* clang-format off */
+ litest_add_no_device(lua_load_failure);
+ litest_with_parameters(params,
+ "content", 'I', 6,
+ litest_named_i32(EMPTY),
+ litest_named_i32(BASIC),
+ litest_named_i32(COMMENT),
+ litest_named_i32(DUPLICATE_CALL),
+ litest_named_i32(MISSING_REGISTER),
+ litest_named_i32(VERSION_NOT_A_TABLE)) {
+ litest_add_parametrized_no_device(lua_load_success_but_no_register,
+ params);
+ }
+ litest_add_no_device(lua_register_noop);
+ litest_with_parameters(params, "when", 's', 2, "run", "connect") {
+ litest_add_parametrized_no_device(lua_unregister_is_last, params);
+ }
+ litest_add_no_device(lua_test_evdev_global);
+ litest_add_no_device(lua_test_libinput_now);
+ litest_with_parameters(params,
+ "mode", 's', 2, "absolute", "relative",
+ "reschedule", 'b') {
+ litest_add_parametrized_no_device(lua_test_libinput_timer, params);
+ }
+
+ litest_with_parameters(params,
+ "priority", 'I', 3,
+ litest_named_i32(LIBINPUT_LOG_PRIORITY_DEBUG),
+ litest_named_i32(LIBINPUT_LOG_PRIORITY_INFO),
+ litest_named_i32(LIBINPUT_LOG_PRIORITY_ERROR)) {
+ litest_add_parametrized_no_device(lua_test_log_global, params);
+ }
+
+ litest_with_parameters(params,
+ "handler", 's', 2, "new-evdev-device", "timer-expired",
+ "error", 'I', 3,
+ litest_named_i32(BAD_TYPE),
+ litest_named_i32(TOO_FEW_ARGS),
+ litest_named_i32(TOO_MANY_ARGS)) {
+ litest_add_parametrized_no_device(lua_bad_connect, params);
+ }
+
+ litest_add_no_device(lua_register_multiversions);
+ litest_add_no_device(lua_allowed_functions);
+ litest_add_no_device(lua_disallowed_functions);
+
+ litest_add_no_device(lua_frame_handler);
+ litest_add_no_device(lua_device_info);
+ litest_add_no_device(lua_set_absinfo);
+ litest_add_no_device(lua_enable_disable_evdev_usage);
+
+ litest_with_parameters(params,
+ "which", 'I', 3,
+ litest_named_i32(LITEST_MOUSE),
+ litest_named_i32(LITEST_TRACKPOINT),
+ litest_named_i32(LITEST_GENERIC_MULTITOUCH_SCREEN)) {
+ litest_add_parametrized_no_device(lua_udev_properties, params);
+ }
+
+ litest_with_parameters(params, "append", 'b', "in_timer", 'b') {
+ litest_add_parametrized_no_device(lua_append_prepend_frame, params);
+ }
+
+ litest_with_parameters(params, "in_timer", 'b') {
+ litest_add_parametrized_no_device(lua_inject_frame, params);
+ }
+ /* clang-format on */
+}