From 12aef07241813a1f933e52199371e50bffc64256 Mon Sep 17 00:00:00 2001 From: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:39:02 -0500 Subject: [PATCH 1/4] fix(linux): map virtual touch and pen devices to streamed outputs --- .github/workflows/ci-build.yml | 1 + CMakeLists.txt | 5 + docs/usage.md | 16 + src/CMakeLists.txt | 13 +- src/include/libvirtualhid/types.hpp | 10 + src/platform/linux/output_mapping.cpp | 138 ++++++ src/platform/linux/output_mapping.hpp | 38 ++ src/platform/linux/uhid_backend.cpp | 24 + tests/CMakeLists.txt | 5 + .../fixtures/linux_backend_test_hooks.hpp | 14 + tests/fixtures/linux_backend_test_hooks.cpp | 41 ++ tests/unit/test_linux_output_mapping.cpp | 421 ++++++++++++++++++ 12 files changed, 725 insertions(+), 1 deletion(-) create mode 100644 src/platform/linux/output_mapping.cpp create mode 100644 src/platform/linux/output_mapping.hpp create mode 100644 tests/unit/test_linux_output_mapping.cpp diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index ac2e7cd..89553fc 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -166,6 +166,7 @@ jobs: clang \ cmake \ libevdev-dev \ + libglib2.0-dev \ libinput-dev \ libsdl2-dev \ libx11-dev \ diff --git a/CMakeLists.txt b/CMakeLists.txt index 945b0d5..424afc8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -40,6 +40,7 @@ option(LIBVIRTUALHID_BUILD_TOOLS "Build libvirtualhid diagnostic tools" ${LIBVIR option(LIBVIRTUALHID_TOOLS_STATIC_RUNTIME "Link libvirtualhid tools to static compiler runtimes where supported" ON) option(LIBVIRTUALHID_TOOLS_FULLY_STATIC "Attempt to link libvirtualhid tools as fully static binaries" OFF) option(LIBVIRTUALHID_TOOLS_STATIC_SDL3 "Prefer a static SDL3 diagnostic UI dependency when available" ON) +option(LIBVIRTUALHID_ENABLE_OUTPUT_MAPPING "Enable KDE Wayland output assignment when GIO is available" ON) option(LIBVIRTUALHID_ENABLE_XTEST "Enable X11/XTest keyboard and mouse fallback on evdev platforms" ON) option(LIBVIRTUALHID_BUILD_WINDOWS_DRIVER "Build the Windows UMDF2 driver package with the WDK/MSVC toolchain" OFF) option(LIBVIRTUALHID_BUILD_WINDOWS_BROKER @@ -162,6 +163,10 @@ if(LIBVIRTUALHID_INSTALL) "find_dependency(PkgConfig)\n" "pkg_check_modules(LIBEVDEV REQUIRED IMPORTED_TARGET libevdev)\n") endif() + if(LIBVIRTUALHID_USES_GIO) + string(APPEND LIBVIRTUALHID_CONFIG_DEPENDENCIES + "pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0)\n") + endif() if(LIBVIRTUALHID_USES_XTEST) string(APPEND LIBVIRTUALHID_CONFIG_DEPENDENCIES "find_dependency(X11 COMPONENTS Xtst)\n") diff --git a/docs/usage.md b/docs/usage.md index 7f98c85..9a505db 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -133,3 +133,19 @@ processes. See [platform support](platform-support.md) for availability and [development](development.md) for build commands and optional targets. + +## Touch and pen output assignment + +Set `TouchContact::viewport.output_name` or `PenToolState::viewport.output_name` +to the streamed display's native connector name (for example `DP-1`) to assign +the library's virtual device to that display on KDE Wayland. Keep coordinates +normalized within that display; the compositor handles its position and scale. +The library waits briefly for newly created event devices to appear in KWin, +caches successful assignments, and updates the mapping when the name changes. +A failed KDE assignment returns an error before submitting the position. + +This integration is built when `gio-2.0` is available. Empty names and other +compositors retain the compositor's default mapping; configure those devices +through the desktop's input settings. KDE Plasma 6.5.1 or later includes the +fix for changing an already assigned device's output. Native output names do +not affect the mouse's existing desktop/viewport coordinate mapping. diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 97c96c3..d03952b 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -54,10 +54,20 @@ function(libvirtualhid_add_library target_name) if(LIBVIRTUALHID_ENABLE_XTEST) find_package(X11 QUIET COMPONENTS Xtst) endif() + if(CMAKE_SYSTEM_NAME STREQUAL "Linux" AND LIBVIRTUALHID_ENABLE_OUTPUT_MAPPING) + find_package(PkgConfig REQUIRED) + pkg_check_modules(GIO QUIET IMPORTED_TARGET gio-2.0) + if(GIO_FOUND) + set(LIBVIRTUALHID_USES_GIO ON PARENT_SCOPE) + target_compile_definitions(${target_name} PRIVATE LIBVIRTUALHID_HAVE_GIO=1) + target_link_libraries(${target_name} PRIVATE PkgConfig::GIO) + endif() + endif() target_sources(${target_name} PRIVATE - "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/platform/linux/uhid_backend.cpp") + "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/platform/linux/uhid_backend.cpp" + "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/platform/linux/output_mapping.cpp") target_link_libraries(${target_name} PUBLIC Threads::Threads @@ -133,6 +143,7 @@ set_target_properties(${PROJECT_NAME} PROPERTIES EXPORT_NAME libvirtualhid OUTPUT_NAME virtualhid) set(LIBVIRTUALHID_USES_XTEST ${LIBVIRTUALHID_USES_XTEST} PARENT_SCOPE) +set(LIBVIRTUALHID_USES_GIO ${LIBVIRTUALHID_USES_GIO} PARENT_SCOPE) if(LIBVIRTUALHID_INSTALL) install(TARGETS ${PROJECT_NAME} diff --git a/src/include/libvirtualhid/types.hpp b/src/include/libvirtualhid/types.hpp index a471d51..1d4b0db 100644 --- a/src/include/libvirtualhid/types.hpp +++ b/src/include/libvirtualhid/types.hpp @@ -496,6 +496,16 @@ namespace lvh { * @brief Viewport height in native screen units, or `0` to use the platform default. */ std::int32_t height = 0; + + /** + * @brief Optional native display name for compositor-managed touch and pen devices. + * + * On KDE Wayland this is the connector name, such as `DP-1`. The backend + * assigns only its own event device to that output and keeps contact + * coordinates monitor-local. An empty name preserves compositor defaults. + * Backends without display assignment support ignore this hint. + */ + std::string output_name {}; }; /** diff --git a/src/platform/linux/output_mapping.cpp b/src/platform/linux/output_mapping.cpp new file mode 100644 index 0000000..70bb94b --- /dev/null +++ b/src/platform/linux/output_mapping.cpp @@ -0,0 +1,138 @@ +/** + * @file src/platform/linux/output_mapping.cpp + * @brief KDE Wayland output assignment for virtual touch and pen devices. + */ + +// standard includes +#include +#include +#include +#include + +// lib includes +#if defined(LIBVIRTUALHID_HAVE_GIO) + #include +#endif + +// local includes +#include "output_mapping.hpp" + +namespace lvh::detail { +#if defined(LIBVIRTUALHID_HAVE_GIO) + namespace { + constexpr auto kwin_service = "org.kde.KWin"; ///< Compositor session service. + constexpr auto device_interface = "org.kde.KWin.InputDevice"; ///< Device properties. + constexpr int call_timeout_ms = 100; ///< Bound each compositor request. + + /** + * @brief Write one KWin property without activating an absent compositor. + * @param connection Session bus connection. + * @param path Owned event device object path. + * @param name Property name. + * @param value Property value, consumed by the request. + * @param error Receives a D-Bus error on failure. + * @return True if KWin accepted the property. + */ + bool set_property(GDBusConnection *connection, const std::string &path, const char *name, GVariant *value, GError **error) { + g_autoptr(GVariant) reply = g_dbus_connection_call_sync( + connection, + kwin_service, + path.c_str(), + "org.freedesktop.DBus.Properties", + "Set", + g_variant_new("(ssv)", device_interface, name, value), + nullptr, + G_DBUS_CALL_FLAGS_NO_AUTO_START, + call_timeout_ms, + nullptr, + error + ); + return reply != nullptr; + } + + /** + * @brief Apply output assignment after KWin registers a newly created device. + * @param connection Session bus connection. + * @param path Owned event device object path. + * @param output_name Connector name. + * @param tablet Whether to disable workspace-wide tablet mapping. + * @return Success or the compositor error. + */ + OperationStatus assign_output(GDBusConnection *connection, const std::string &path, const std::string &output_name, bool tablet) { + for (int attempt = 0;; ++attempt) { + g_autoptr(GError) error = nullptr; + if (set_property(connection, path, "outputName", g_variant_new_string(output_name.c_str()), &error)) { + if (!tablet || set_property(connection, path, "mapToWorkspace", g_variant_new_boolean(false), &error)) { + break; + } + } + const bool registration_pending = g_error_matches(error, G_DBUS_ERROR, G_DBUS_ERROR_UNKNOWN_OBJECT) || + g_error_matches(error, G_DBUS_ERROR, G_DBUS_ERROR_UNKNOWN_METHOD); + if (!registration_pending || attempt == 9) { + return OperationStatus::failure(ErrorCode::backend_failure, "failed to map virtual input to " + output_name + ": " + error->message); + } + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + return OperationStatus::success(); + } + + /** + * @brief Apply the display hint when the session is managed by KWin Wayland. + * @param devnode Owned event node. + * @param output_name Native display connector. + * @param tablet Whether workspace-wide tablet mapping must be disabled. + * @return Success for unsupported sessions, or the KDE mapping result. + */ + OperationStatus assign_if_supported(std::string_view devnode, const std::string &output_name, bool tablet) { + g_autoptr(GError) error = nullptr; + g_autoptr(GDBusConnection) connection = g_bus_get_sync(G_BUS_TYPE_SESSION, nullptr, &error); + if (!connection) { + return OperationStatus::success(); + } + // This manager is exposed by KWin's Wayland input backend. Detect it + // directly because a user service may not inherit WAYLAND_DISPLAY. + g_autoptr(GVariant) manager = g_dbus_connection_call_sync( + connection, + kwin_service, + "/org/kde/KWin/InputDevice", + "org.freedesktop.DBus.Properties", + "Get", + g_variant_new("(ss)", "org.kde.KWin.InputDeviceManager", "devicesSysNames"), + G_VARIANT_TYPE("(v)"), + G_DBUS_CALL_FLAGS_NO_AUTO_START, + call_timeout_ms, + nullptr, + nullptr + ); + if (!manager) { + return OperationStatus::success(); + } + constexpr std::string_view prefix = "/dev/input/event"; + if (!devnode.starts_with(prefix) || devnode.size() == prefix.size() || !std::ranges::all_of(devnode.substr(prefix.size()), [](char c) { + return c >= '0' && c <= '9'; + }) || + !g_utf8_validate(output_name.c_str(), static_cast(output_name.size()), nullptr)) { + return OperationStatus::failure(ErrorCode::invalid_argument, "invalid virtual input device node or display name"); + } + const auto path = "/org/kde/KWin/InputDevice/" + std::string {devnode.substr(std::string_view {"/dev/input/"}.size())}; + return assign_output(connection, path, output_name, tablet); + } + } // namespace +#endif + + OperationStatus OutputMapping::update(std::string_view devnode, const std::string &output_name, bool tablet) { + if (output_name.empty() || (devnode == devnode_ && output_name == output_name_)) { + return OperationStatus::success(); + } +#if defined(LIBVIRTUALHID_HAVE_GIO) + if (const auto status = assign_if_supported(devnode, output_name, tablet); !status.ok()) { + return status; + } +#else + static_cast(tablet); +#endif + devnode_ = devnode; + output_name_ = output_name; + return OperationStatus::success(); + } +} // namespace lvh::detail diff --git a/src/platform/linux/output_mapping.hpp b/src/platform/linux/output_mapping.hpp new file mode 100644 index 0000000..980da82 --- /dev/null +++ b/src/platform/linux/output_mapping.hpp @@ -0,0 +1,38 @@ +/** + * @file src/platform/linux/output_mapping.hpp + * @brief Compositor output assignment for virtual absolute input devices. + */ +#pragma once + +// standard includes +#include +#include + +// lib includes +#include + +namespace lvh::detail { + /** + * @brief Cache output assignment for one owned Linux event device. + */ + class OutputMapping { + public: + /** + * @brief Assign an absolute input device to a native display when supported. + * + * KDE Wayland exposes output assignment through its session D-Bus service. + * Other compositors and builds without GIO preserve their existing mapping. + * Successful assignments are cached; failed assignments can be retried. + * + * @param devnode Event device owned by the caller, such as `/dev/input/event7`. + * @param output_name Native connector name, or empty to preserve defaults. + * @param tablet Whether workspace-wide tablet mapping must be disabled. + * @return Success, or a failed KDE assignment before input is submitted. + */ + OperationStatus update(std::string_view devnode, const std::string &output_name, bool tablet); + + private: + std::string devnode_; ///< Event node whose mapping was last applied. + std::string output_name_; ///< Last successfully requested output. + }; +} // namespace lvh::detail diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp index bf58868..cc5fa9a 100644 --- a/src/platform/linux/uhid_backend.cpp +++ b/src/platform/linux/uhid_backend.cpp @@ -62,6 +62,7 @@ // local includes #include "core/backend.hpp" +#include "output_mapping.hpp" #include "platform/shared/xbox_bluetooth_transport.hpp" #if defined(__linux__) #include "shared/playstation_feature_reports.hpp" @@ -1038,6 +1039,20 @@ namespace lvh::detail { std::vector uinput_device_nodes(const std::string &device_name) const; + /** + * @brief Assign this owned event device to a compositor output. + * @param viewport Target display metadata. + * @param tablet Whether this device is a pen tablet. + * @return Success or an output assignment error before emitting input. + */ + OperationStatus map_output(const PointerViewport &viewport, bool tablet) { + if (viewport.output_name.empty()) { + return OperationStatus::success(); + } + const auto *node = uinput_device_ ? libevdev_uinput_get_devnode(uinput_device_) : nullptr; + return output_mapping_.update(node ? node : "", viewport.output_name, tablet); + } + OperationStatus emit_event(std::uint16_t type, std::uint16_t code, std::int32_t value) { std::lock_guard lock {write_mutex_}; return emit_event_locked(type, code, value); @@ -1102,6 +1117,7 @@ namespace lvh::detail { int fd_ = -1; libevdev_uinput *uinput_device_ = nullptr; + OutputMapping output_mapping_; ///< Per-device compositor output assignment. std::atomic_bool open_ = true; std::mutex write_mutex_; }; @@ -1943,6 +1959,11 @@ namespace lvh::detail { if (!is_open()) { return OperationStatus::failure(ErrorCode::device_closed, "uinput touch device is closed"); } + if (!update_trackpad_buttons) { + if (const auto status = map_output(contact.viewport, false); !status.ok()) { + return status; + } + } if (contact.id < 0) { return OperationStatus::failure(ErrorCode::invalid_argument, "touch contact id must not be negative"); } @@ -2230,6 +2251,9 @@ namespace lvh::detail { return OperationStatus::failure(ErrorCode::device_closed, "uinput pen tablet is closed"); } + if (const auto status = map_output(state.viewport, true); !status.ok()) { + return status; + } if (const auto status = update_pen_tool(state.tool); !status.ok()) { return status; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 1331a71..f3a2a0f 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -133,6 +133,11 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Linux") PkgConfig::LIBINPUT PkgConfig::SDL2) + if(LIBVIRTUALHID_USES_GIO) + target_sources(${TEST_BINARY} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_linux_output_mapping.cpp") + target_link_libraries(${TEST_BINARY} PRIVATE PkgConfig::GIO) + endif() + set(LIBVIRTUALHID_TEST_SDL3_TARGET SDL3::SDL3) if(TARGET SDL3::SDL3-static) set(LIBVIRTUALHID_TEST_SDL3_TARGET SDL3::SDL3-static) diff --git a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp index 8060c56..6f1ad16 100644 --- a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp +++ b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp @@ -981,6 +981,20 @@ namespace lvh::detail::test { */ LinuxMouseInputSubmissionResult linux_uinput_mouse_submit_split_pipe_sequence(const std::vector &events); + /** + * @brief Submit a contact through an owned fake uinput device and real output mapping. + * @param contact Monitor-local contact with an optional display name. + * @return Submission status and events emitted after mapping. + */ + LinuxInputSubmissionResult linux_uinput_mapped_contact(const TouchContact &contact); + + /** + * @brief Submit a pen tool through an owned fake uinput device and real output mapping. + * @param tool Monitor-local pen state with an optional display name. + * @return Submission status and events emitted after mapping. + */ + LinuxInputSubmissionResult linux_uinput_mapped_tool(const PenToolState &tool); + /** * @brief Place and release a contact through a pipe-backed uinput touchscreen. * diff --git a/tests/fixtures/linux_backend_test_hooks.cpp b/tests/fixtures/linux_backend_test_hooks.cpp index 4f10d59..ee5fe61 100644 --- a/tests/fixtures/linux_backend_test_hooks.cpp +++ b/tests/fixtures/linux_backend_test_hooks.cpp @@ -533,6 +533,13 @@ extern "C" int lvh_linux_test_libevdev_uinput_create_from_device( return ::libevdev_uinput_create_from_device(device, uinput_fd, uinput_device); } +extern "C" const char *lvh_linux_test_libevdev_uinput_get_devnode(libevdev_uinput *device) { + if (lvh::detail::test::active_test_syscalls() != nullptr && lvh::detail::test::active_test_syscalls()->override_libevdev) { + return "/dev/input/event7"; + } + return ::libevdev_uinput_get_devnode(device); +} + extern "C" void lvh_linux_test_libevdev_uinput_destroy(libevdev_uinput *raw_uinput_handle) { if (lvh::detail::test::active_test_syscalls() != nullptr && lvh::detail::test::active_test_syscalls()->override_libevdev) { ++lvh::detail::test::active_test_syscalls()->libevdev_destroy_count; @@ -566,6 +573,7 @@ extern "C" void lvh_linux_test_libevdev_uinput_destroy(libevdev_uinput *raw_uinp #define libevdev_set_name lvh_linux_test_libevdev_set_name #define libevdev_uinput_create_from_device lvh_linux_test_libevdev_uinput_create_from_device #define libevdev_uinput_destroy lvh_linux_test_libevdev_uinput_destroy + #define libevdev_uinput_get_devnode lvh_linux_test_libevdev_uinput_get_devnode #if defined(LIBVIRTUALHID_HAVE_XTEST) #if defined(DefaultScreen) @@ -617,6 +625,7 @@ extern "C" void lvh_linux_test_libevdev_uinput_destroy(libevdev_uinput *raw_uinp #undef open #undef ioctl #undef access + #undef libevdev_uinput_get_devnode #undef libevdev_uinput_destroy #undef libevdev_uinput_create_from_device #undef libevdev_set_name @@ -1459,6 +1468,38 @@ namespace lvh::detail::test { return {std::move(status), std::move(relative_records), std::move(absolute_records)}; } + LinuxInputSubmissionResult linux_uinput_mapped_contact(const TouchContact &contact) { + LinuxTestSyscalls syscalls; + enable_fake_device_syscalls(syscalls); + syscalls.capture_input_events = true; + ScopedLinuxTestSyscalls scoped {syscalls}; + UinputTouchscreen device {open_test_fd()}; + CreateTouchscreenOptions options; + options.profile = profiles::touchscreen(); + auto status = device.create(1, options); + if (status.ok()) { + status = device.place_contact(contact); + } + static_cast(device.close()); + return {std::move(status), std::move(syscalls.written_input_events)}; + } + + LinuxInputSubmissionResult linux_uinput_mapped_tool(const PenToolState &tool) { + LinuxTestSyscalls syscalls; + enable_fake_device_syscalls(syscalls); + syscalls.capture_input_events = true; + ScopedLinuxTestSyscalls scoped {syscalls}; + UinputPenTablet device {open_test_fd()}; + CreatePenTabletOptions options; + options.profile = profiles::pen_tablet(); + auto status = device.create(1, options); + if (status.ok()) { + status = device.place_tool(tool); + } + static_cast(device.close()); + return {std::move(status), std::move(syscalls.written_input_events)}; + } + LinuxInputSubmissionResult linux_uinput_touchscreen_contact_pipe(const TouchContact &contact) { std::array descriptors {-1, -1}; if (::pipe(descriptors.data()) != 0) { diff --git a/tests/unit/test_linux_output_mapping.cpp b/tests/unit/test_linux_output_mapping.cpp new file mode 100644 index 0000000..17d44ec --- /dev/null +++ b/tests/unit/test_linux_output_mapping.cpp @@ -0,0 +1,421 @@ +/** + * @file tests/unit/test_linux_output_mapping.cpp + * @brief Exercise output assignment against an isolated KWin D-Bus service. + */ + +// test includes +#include "fixtures/fixtures.hpp" +#include "fixtures/linux_backend_test_hooks.hpp" + +// standard includes +#include +#include +#include +#include +#include +#include +#include + +// platform includes +#include + +// lib includes +#include + +// local includes +#include "platform/linux/output_mapping.hpp" + +namespace { + /** + * @brief Properties of one compositor input device. + */ + struct DeviceMapping { + std::string output = "HDMI-A-1"; ///< Initially selected output. + bool workspace = true; ///< Initial tablet workspace mapping. + int assignments = 0; ///< Successful output assignments. + }; + + /** + * @brief Private bus and compositor service, independent of installed devices. + */ + class KWinMappingTest: public testing::Test { + protected: + void SetUp() override { + bus_ = g_test_dbus_new(G_TEST_DBUS_NONE); + g_test_dbus_up(bus_); + g_setenv("WAYLAND_DISPLAY", "wayland-test", true); + context_ = g_main_context_new(); + loop_ = g_main_loop_new(context_, false); + std::promise ready; + worker_ = std::jthread {[this, &ready] { + g_main_context_push_thread_default(context_); + GError *error = nullptr; + connection_ = g_dbus_connection_new_for_address_sync( + g_test_dbus_get_bus_address(bus_), + GDBusConnectionFlags(G_DBUS_CONNECTION_FLAGS_AUTHENTICATION_CLIENT | G_DBUS_CONNECTION_FLAGS_MESSAGE_BUS_CONNECTION), + nullptr, + nullptr, + &error + ); + if (!connection_) { + startup_error_ = error->message; + g_error_free(error); + ready.set_value(); + g_main_context_pop_thread_default(context_); + return; + } + g_autoptr(GVariant) reply = g_dbus_connection_call_sync( + connection_, + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + "org.freedesktop.DBus", + "RequestName", + g_variant_new("(su)", "org.kde.KWin", 0U), + nullptr, + G_DBUS_CALL_FLAGS_NONE, + 1000, + nullptr, + &error + ); + if (error) { + startup_error_ = error->message; + g_error_free(error); + } + introspection_ = g_dbus_node_info_new_for_xml( + "" + "" + "" + "", + nullptr + ); + g_autoptr(GDBusNodeInfo) manager_info = g_dbus_node_info_new_for_xml( + "" + "", + nullptr + ); + static const GDBusInterfaceVTable manager_vtable { + .method_call = nullptr, + .get_property = [](GDBusConnection *, const gchar *, const gchar *, const gchar *, const gchar *, GError **, gpointer) -> GVariant * { + const char *nodes[] = {"event7", "event8", nullptr}; + return g_variant_new_strv(nodes, -1); + }, + .set_property = nullptr, + .padding = {}, + }; + manager_id_ = g_dbus_connection_register_object(connection_, "/org/kde/KWin/InputDevice", manager_info->interfaces[0], &manager_vtable, this, nullptr, nullptr); + registrations_.push_back(manager_id_); + register_device("event7"); + register_device("event8"); + ready.set_value(); + g_main_loop_run(loop_); + for (const auto id : registrations_) { + g_dbus_connection_unregister_object(connection_, id); + } + g_dbus_node_info_unref(introspection_); + g_dbus_connection_close_sync(connection_, nullptr, nullptr); + g_clear_object(&connection_); + g_main_context_pop_thread_default(context_); + }}; + ready.get_future().wait(); + ASSERT_TRUE(startup_error_.empty()) << startup_error_; + } + + void TearDown() override { + g_main_context_invoke(context_, [](gpointer data) -> gboolean { + g_main_loop_quit(static_cast(data)); + return G_SOURCE_REMOVE; + }, + loop_); + worker_.join(); + g_main_loop_unref(loop_); + g_main_context_unref(context_); + g_unsetenv("WAYLAND_DISPLAY"); + g_test_dbus_down(bus_); + g_object_unref(bus_); + } + + public: + /** + * @brief Register one virtual device in the compositor's event-node namespace. + * @param name Event node basename. + */ + void register_device(const char *name) { + const auto path = std::string {"/org/kde/KWin/InputDevice/"} + name; + devices_.try_emplace(path); + static const GDBusInterfaceVTable vtable { + .method_call = nullptr, + .get_property = [](GDBusConnection *, const gchar *, const gchar *path, const gchar *, const gchar *property, GError **, gpointer data) -> GVariant * { + auto &device = static_cast(data)->devices_.at(path); + return std::string_view {property} == "outputName" ? g_variant_new_string(device.output.c_str()) : g_variant_new_boolean(device.workspace); + }, + .set_property = [](GDBusConnection *, const gchar *, const gchar *path, const gchar *, const gchar *property, GVariant *value, GError **error, gpointer data) -> gboolean { + auto &self = *static_cast(data); + if (self.reject_property_ == property) { + g_set_error_literal(error, G_DBUS_ERROR, self.reject_code_, "mapping denied"); + return false; + } + auto &device = self.devices_.at(path); + if (std::string_view {property} == "outputName") { + device.output = g_variant_get_string(value, nullptr); + ++device.assignments; + } else { + device.workspace = g_variant_get_boolean(value); + } + return true; + }, + .padding = {}, + }; + registrations_.push_back(g_dbus_connection_register_object(connection_, path.c_str(), introspection_->interfaces[0], &vtable, this, nullptr, nullptr)); + } + + /** + * @brief Remove the Wayland manager while keeping the compositor service alive. + */ + void unregister_manager() { + g_dbus_connection_unregister_object(connection_, manager_id_); + registrations_.erase(registrations_.begin()); + } + + /** + * @brief Run an action on the compositor service thread and wait for completion. + * @param action Action that reads or changes service state. + */ + template + void on_service(Action action) { + std::packaged_task task {std::move(action)}; + auto completed = task.get_future(); + g_main_context_invoke(context_, [](gpointer data) -> gboolean { + (*static_cast *>(data))(); + return G_SOURCE_REMOVE; + }, + &task); + completed.get(); + } + + /** + * @brief Read the observable mapping of one virtual device. + * @param name Event node basename. + * @return Snapshot copied on the compositor service thread. + */ + DeviceMapping device(const char *name) { + DeviceMapping result; + on_service([&] { + result = devices_.at(std::string {"/org/kde/KWin/InputDevice/"} + name); + }); + return result; + } + + guint manager_id_ = 0; ///< Wayland input manager registration. + lvh::detail::OutputMapping mapping_; ///< Mapping under test. + GDBusError reject_code_ = G_DBUS_ERROR_ACCESS_DENIED; ///< Failure returned for a rejected property. + std::string reject_property_; ///< Property rejected by the fake compositor. + GMainContext *context_ = nullptr; ///< Service event context. + GDBusConnection *connection_ = nullptr; ///< Service bus connection. + + private: + GTestDBus *bus_ = nullptr; ///< Private D-Bus daemon. + GMainLoop *loop_ = nullptr; ///< Service event loop. + GDBusNodeInfo *introspection_ = nullptr; ///< Device property interface. + std::vector registrations_; ///< Objects registered in the private bus. + std::map devices_; ///< Compositor device state. + std::string startup_error_; ///< Service startup diagnostic. + std::jthread worker_; ///< Compositor service worker. + }; + + TEST_F(KWinMappingTest, MapsOnlyTheOwnedTouchscreenAndCachesTheAssignment) { + ASSERT_TRUE(mapping_.update("/dev/input/event7", "DP-1", false).ok()); + EXPECT_EQ(device("event7").output, "DP-1"); + EXPECT_EQ(device("event8").output, "HDMI-A-1"); + EXPECT_TRUE(device("event7").workspace); + ASSERT_TRUE(mapping_.update("/dev/input/event7", "DP-1", false).ok()); + EXPECT_EQ(device("event7").assignments, 1); + ASSERT_TRUE(mapping_.update("/dev/input/event7", "HDMI-A-1", false).ok()); + EXPECT_EQ(device("event7").output, "HDMI-A-1"); + EXPECT_EQ(device("event7").assignments, 2); + ASSERT_TRUE(mapping_.update("/dev/input/event8", "HDMI-A-1", false).ok()); + EXPECT_EQ(device("event8").assignments, 1); + } + + TEST_F(KWinMappingTest, MapsPenToOneOutputAndDisablesWorkspaceMapping) { + ASSERT_TRUE(mapping_.update("/dev/input/event7", "DP-1", true).ok()); + EXPECT_EQ(device("event7").output, "DP-1"); + EXPECT_FALSE(device("event7").workspace); + EXPECT_TRUE(device("event8").workspace); + } + + TEST_F(KWinMappingTest, LeavesEmptyTargetsAndSessionsWithoutInputManagerUnchanged) { + EXPECT_TRUE(mapping_.update("/dev/input/event7", "", false).ok()); + EXPECT_EQ(device("event7").assignments, 0); + on_service([&] { + unregister_manager(); + }); + EXPECT_TRUE(mapping_.update("/dev/input/event7", "DP-1", false).ok()); + EXPECT_EQ(device("event7").assignments, 0); + } + + TEST_F(KWinMappingTest, MapsWaylandDeviceWithoutWaylandDisplayEnvironment) { + g_unsetenv("WAYLAND_DISPLAY"); + EXPECT_TRUE(mapping_.update("/dev/input/event7", "DP-1", false).ok()); + EXPECT_EQ(device("event7").output, "DP-1"); + } + + TEST_F(KWinMappingTest, LeavesOtherCompositorsUnchanged) { + on_service([&] { + g_autoptr(GVariant) reply = g_dbus_connection_call_sync( + connection_, + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + "org.freedesktop.DBus", + "ReleaseName", + g_variant_new("(s)", "org.kde.KWin"), + nullptr, + G_DBUS_CALL_FLAGS_NONE, + 1000, + nullptr, + nullptr + ); + }); + EXPECT_TRUE(mapping_.update("/dev/input/event7", "DP-1", false).ok()); + EXPECT_EQ(device("event7").assignments, 0); + } + + TEST_F(KWinMappingTest, RejectsInvalidDeviceNodesAndDisplayNames) { + for (const auto *node : {"", "/dev/input/event", "/dev/input/event7/other", "/tmp/event7", "/dev/input/event-1", "/dev/input/eventz"}) { + EXPECT_EQ(mapping_.update(node, "DP-1", false).code(), lvh::ErrorCode::invalid_argument) << node; + } + EXPECT_EQ(mapping_.update("/dev/input/event7", std::string(1, '\xff'), false).code(), lvh::ErrorCode::invalid_argument); + EXPECT_EQ(mapping_.update("/dev/input/event7", std::string {"DP-1\0x", 6}, false).code(), lvh::ErrorCode::invalid_argument); + EXPECT_EQ(device("event7").assignments, 0); + } + + TEST_F(KWinMappingTest, RetriesFailedAssignments) { + on_service([&] { + reject_property_ = "outputName"; + }); + auto status = mapping_.update("/dev/input/event7", "DP-1", false); + EXPECT_EQ(status.code(), lvh::ErrorCode::backend_failure); + EXPECT_NE(status.message().find("mapping denied"), std::string::npos); + EXPECT_EQ(device("event7").output, "HDMI-A-1"); + on_service([&] { + reject_property_.clear(); + }); + EXPECT_TRUE(mapping_.update("/dev/input/event7", "DP-1", false).ok()); + EXPECT_EQ(device("event7").output, "DP-1"); + } + + TEST_F(KWinMappingTest, ReportsTabletWorkspaceMappingFailureAndRetries) { + on_service([&] { + reject_property_ = "mapToWorkspace"; + }); + EXPECT_EQ(mapping_.update("/dev/input/event7", "DP-1", true).code(), lvh::ErrorCode::backend_failure); + EXPECT_TRUE(device("event7").workspace); + on_service([&] { + reject_property_.clear(); + }); + EXPECT_TRUE(mapping_.update("/dev/input/event7", "DP-1", true).ok()); + EXPECT_FALSE(device("event7").workspace); + } + + TEST_F(KWinMappingTest, WaitsForNewDeviceRegistration) { + GSource *source = g_timeout_source_new(15); + g_source_set_callback(source, [](gpointer data) -> gboolean { + static_cast(data)->register_device("event9"); + return G_SOURCE_REMOVE; + }, + this, + nullptr); + g_source_attach(source, context_); + g_source_unref(source); + EXPECT_TRUE(mapping_.update("/dev/input/event9", "DP-1", false).ok()); + EXPECT_EQ(device("event9").output, "DP-1"); + } + + TEST_F(KWinMappingTest, BoundsTheWaitWhenCompositorReportsUnknownObject) { + on_service([&] { + reject_property_ = "outputName"; + reject_code_ = G_DBUS_ERROR_UNKNOWN_OBJECT; + }); + EXPECT_EQ(mapping_.update("/dev/input/event7", "DP-1", false).code(), lvh::ErrorCode::backend_failure); + EXPECT_EQ(device("event7").assignments, 0); + } + + TEST_F(KWinMappingTest, BoundsTheWaitForAnUnregisteredDevice) { + EXPECT_EQ(mapping_.update("/dev/input/event404", "DP-1", false).code(), lvh::ErrorCode::backend_failure); + EXPECT_EQ(device("event7").assignments, 0); + } + + TEST_F(KWinMappingTest, TouchAndPenKeepMonitorLocalCoordinatesAfterAssignment) { + lvh::TouchContact contact; + contact.id = 1; + contact.x = 0.5F; + contact.y = 0.25F; + contact.viewport = {.offset_x = 1920, .width = 1920, .height = 1080, .output_name = "DP-1"}; + const auto touch = lvh::detail::test::linux_uinput_mapped_contact(contact); + ASSERT_TRUE(touch.status.ok()) << touch.status.message(); + EXPECT_EQ(device("event7").output, "DP-1"); + EXPECT_TRUE(std::ranges::any_of(touch.events, [](const auto &event) { + return event.type == EV_ABS && event.code == ABS_MT_POSITION_X && event.value == 9600; + })); + EXPECT_TRUE(std::ranges::any_of(touch.events, [](const auto &event) { + return event.type == EV_ABS && event.code == ABS_MT_POSITION_Y && event.value == 2700; + })); + + lvh::PenToolState tool; + tool.x = 0.5F; + tool.y = 0.25F; + tool.viewport = contact.viewport; + const auto pen = lvh::detail::test::linux_uinput_mapped_tool(tool); + ASSERT_TRUE(pen.status.ok()) << pen.status.message(); + EXPECT_FALSE(device("event7").workspace); + EXPECT_TRUE(std::ranges::any_of(pen.events, [](const auto &event) { + return event.type == EV_ABS && event.code == ABS_X && event.value == 9600; + })); + EXPECT_TRUE(std::ranges::any_of(pen.events, [](const auto &event) { + return event.type == EV_ABS && event.code == ABS_Y && event.value == 2700; + })); + } + + TEST_F(KWinMappingTest, FailedAssignmentEmitsNoTouchOrPenInput) { + on_service([&] { + reject_property_ = "outputName"; + }); + lvh::TouchContact contact; + contact.viewport.output_name = "DP-1"; + const auto touch = lvh::detail::test::linux_uinput_mapped_contact(contact); + EXPECT_EQ(touch.status.code(), lvh::ErrorCode::backend_failure); + EXPECT_TRUE(touch.events.empty()); + lvh::PenToolState tool; + tool.viewport.output_name = "DP-1"; + const auto pen = lvh::detail::test::linux_uinput_mapped_tool(tool); + EXPECT_EQ(pen.status.code(), lvh::ErrorCode::backend_failure); + EXPECT_TRUE(pen.events.empty()); + } + + TEST_F(KWinMappingTest, UnresolvedOwnedDeviceCannotSendInputToAnotherDisplay) { + lvh::TouchContact contact; + contact.viewport.output_name = "DP-1"; + const auto touch = lvh::detail::test::linux_uinput_touchscreen_contact_pipe(contact); + EXPECT_EQ(touch.status.code(), lvh::ErrorCode::invalid_argument); + EXPECT_TRUE(touch.events.empty()); + lvh::PenToolState tool; + tool.viewport.output_name = "DP-1"; + const auto pen = lvh::detail::test::linux_uinput_pen_tablet_tool_pipe(tool); + EXPECT_EQ(pen.status.code(), lvh::ErrorCode::invalid_argument); + EXPECT_TRUE(pen.events.empty()); + } + + TEST(OutputMappingFallbackTest, LeavesASessionWithoutDBusUsable) { + const auto *old_address = g_getenv("DBUS_SESSION_BUS_ADDRESS"); + const std::string saved_address = old_address ? old_address : ""; + g_setenv("DBUS_SESSION_BUS_ADDRESS", "unix:path=/tmp/libvirtualhid-no-such-session-bus", true); + g_setenv("WAYLAND_DISPLAY", "wayland-test", true); + lvh::detail::OutputMapping mapping; + EXPECT_TRUE(mapping.update("/dev/input/event7", "DP-1", false).ok()); + if (saved_address.empty()) { + g_unsetenv("DBUS_SESSION_BUS_ADDRESS"); + } else { + g_setenv("DBUS_SESSION_BUS_ADDRESS", saved_address.c_str(), true); + } + g_unsetenv("WAYLAND_DISPLAY"); + } +} // namespace From 5757d99f70f4bce2bf244154711e3a3b0c234c19 Mon Sep 17 00:00:00 2001 From: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:43:49 -0500 Subject: [PATCH 2/4] refactor(linux): address output mapping Sonar findings --- src/platform/linux/output_mapping.cpp | 31 +-- src/platform/linux/uhid_backend.cpp | 17 +- tests/unit/test_linux_output_mapping.cpp | 229 +++++++++++++---------- 3 files changed, 148 insertions(+), 129 deletions(-) diff --git a/src/platform/linux/output_mapping.cpp b/src/platform/linux/output_mapping.cpp index 70bb94b..8d80f9d 100644 --- a/src/platform/linux/output_mapping.cpp +++ b/src/platform/linux/output_mapping.cpp @@ -61,14 +61,10 @@ namespace lvh::detail { OperationStatus assign_output(GDBusConnection *connection, const std::string &path, const std::string &output_name, bool tablet) { for (int attempt = 0;; ++attempt) { g_autoptr(GError) error = nullptr; - if (set_property(connection, path, "outputName", g_variant_new_string(output_name.c_str()), &error)) { - if (!tablet || set_property(connection, path, "mapToWorkspace", g_variant_new_boolean(false), &error)) { - break; - } + if (set_property(connection, path, "outputName", g_variant_new_string(output_name.c_str()), &error) && (!tablet || set_property(connection, path, "mapToWorkspace", g_variant_new_boolean(false), &error))) { + break; } - const bool registration_pending = g_error_matches(error, G_DBUS_ERROR, G_DBUS_ERROR_UNKNOWN_OBJECT) || - g_error_matches(error, G_DBUS_ERROR, G_DBUS_ERROR_UNKNOWN_METHOD); - if (!registration_pending || attempt == 9) { + if (const bool registration_pending = g_error_matches(error, G_DBUS_ERROR, G_DBUS_ERROR_UNKNOWN_OBJECT) || g_error_matches(error, G_DBUS_ERROR, G_DBUS_ERROR_UNKNOWN_METHOD); !registration_pending || attempt == 9) { return OperationStatus::failure(ErrorCode::backend_failure, "failed to map virtual input to " + output_name + ": " + error->message); } std::this_thread::sleep_for(std::chrono::milliseconds(10)); @@ -91,27 +87,12 @@ namespace lvh::detail { } // This manager is exposed by KWin's Wayland input backend. Detect it // directly because a user service may not inherit WAYLAND_DISPLAY. - g_autoptr(GVariant) manager = g_dbus_connection_call_sync( - connection, - kwin_service, - "/org/kde/KWin/InputDevice", - "org.freedesktop.DBus.Properties", - "Get", - g_variant_new("(ss)", "org.kde.KWin.InputDeviceManager", "devicesSysNames"), - G_VARIANT_TYPE("(v)"), - G_DBUS_CALL_FLAGS_NO_AUTO_START, - call_timeout_ms, - nullptr, - nullptr - ); - if (!manager) { + if (g_autoptr(GVariant) manager = g_dbus_connection_call_sync(connection, kwin_service, "/org/kde/KWin/InputDevice", "org.freedesktop.DBus.Properties", "Get", g_variant_new("(ss)", "org.kde.KWin.InputDeviceManager", "devicesSysNames"), G_VARIANT_TYPE("(v)"), G_DBUS_CALL_FLAGS_NO_AUTO_START, call_timeout_ms, nullptr, nullptr); !manager) { return OperationStatus::success(); } - constexpr std::string_view prefix = "/dev/input/event"; - if (!devnode.starts_with(prefix) || devnode.size() == prefix.size() || !std::ranges::all_of(devnode.substr(prefix.size()), [](char c) { + if (constexpr std::string_view prefix = "/dev/input/event"; !devnode.starts_with(prefix) || devnode.size() == prefix.size() || !std::ranges::all_of(devnode.substr(prefix.size()), [](char c) { return c >= '0' && c <= '9'; - }) || - !g_utf8_validate(output_name.c_str(), static_cast(output_name.size()), nullptr)) { + }) || !g_utf8_validate(output_name.c_str(), static_cast(output_name.size()), nullptr)) { return OperationStatus::failure(ErrorCode::invalid_argument, "invalid virtual input device node or display name"); } const auto path = "/org/kde/KWin/InputDevice/" + std::string {devnode.substr(std::string_view {"/dev/input/"}.size())}; diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp index cc5fa9a..925e4c6 100644 --- a/src/platform/linux/uhid_backend.cpp +++ b/src/platform/linux/uhid_backend.cpp @@ -1717,8 +1717,8 @@ namespace lvh::detail { UinputMouse( int relative_file_descriptor, int absolute_file_descriptor, - PointerViewport desktop = {}, - PointerViewport viewport = {} + const PointerViewport &desktop = {}, + const PointerViewport &viewport = {} ): relative_device_ {relative_file_descriptor}, absolute_device_ {absolute_file_descriptor}, @@ -1955,14 +1955,19 @@ namespace lvh::detail { return create_uinput_device(profile, id); } + /** + * @brief Submit a contact after assigning a touchscreen to its requested output. + * + * @param contact Monitor-local contact state and optional output identity. + * @param update_trackpad_buttons Whether to use trackpad buttons and preserve compositor mapping. + * @return Submission status, or a mapping error before any input is emitted. + */ OperationStatus place_touch_contact(const TouchContact &contact, bool update_trackpad_buttons) { if (!is_open()) { return OperationStatus::failure(ErrorCode::device_closed, "uinput touch device is closed"); } - if (!update_trackpad_buttons) { - if (const auto status = map_output(contact.viewport, false); !status.ok()) { - return status; - } + if (const auto status = update_trackpad_buttons ? OperationStatus::success() : map_output(contact.viewport, false); !status.ok()) { + return status; } if (contact.id < 0) { return OperationStatus::failure(ErrorCode::invalid_argument, "touch contact id must not be negative"); diff --git a/tests/unit/test_linux_output_mapping.cpp b/tests/unit/test_linux_output_mapping.cpp index 17d44ec..4c9f3b2 100644 --- a/tests/unit/test_linux_output_mapping.cpp +++ b/tests/unit/test_linux_output_mapping.cpp @@ -9,6 +9,8 @@ // standard includes #include +#include +#include #include #include #include @@ -40,6 +42,9 @@ namespace { */ class KWinMappingTest: public testing::Test { protected: + /** + * @brief Start an isolated bus and wait until the compositor service is ready. + */ void SetUp() override { bus_ = g_test_dbus_new(G_TEST_DBUS_NONE); g_test_dbus_up(bus_); @@ -48,80 +53,17 @@ namespace { loop_ = g_main_loop_new(context_, false); std::promise ready; worker_ = std::jthread {[this, &ready] { - g_main_context_push_thread_default(context_); - GError *error = nullptr; - connection_ = g_dbus_connection_new_for_address_sync( - g_test_dbus_get_bus_address(bus_), - GDBusConnectionFlags(G_DBUS_CONNECTION_FLAGS_AUTHENTICATION_CLIENT | G_DBUS_CONNECTION_FLAGS_MESSAGE_BUS_CONNECTION), - nullptr, - nullptr, - &error - ); - if (!connection_) { - startup_error_ = error->message; - g_error_free(error); - ready.set_value(); - g_main_context_pop_thread_default(context_); - return; - } - g_autoptr(GVariant) reply = g_dbus_connection_call_sync( - connection_, - "org.freedesktop.DBus", - "/org/freedesktop/DBus", - "org.freedesktop.DBus", - "RequestName", - g_variant_new("(su)", "org.kde.KWin", 0U), - nullptr, - G_DBUS_CALL_FLAGS_NONE, - 1000, - nullptr, - &error - ); - if (error) { - startup_error_ = error->message; - g_error_free(error); - } - introspection_ = g_dbus_node_info_new_for_xml( - "" - "" - "" - "", - nullptr - ); - g_autoptr(GDBusNodeInfo) manager_info = g_dbus_node_info_new_for_xml( - "" - "", - nullptr - ); - static const GDBusInterfaceVTable manager_vtable { - .method_call = nullptr, - .get_property = [](GDBusConnection *, const gchar *, const gchar *, const gchar *, const gchar *, GError **, gpointer) -> GVariant * { - const char *nodes[] = {"event7", "event8", nullptr}; - return g_variant_new_strv(nodes, -1); - }, - .set_property = nullptr, - .padding = {}, - }; - manager_id_ = g_dbus_connection_register_object(connection_, "/org/kde/KWin/InputDevice", manager_info->interfaces[0], &manager_vtable, this, nullptr, nullptr); - registrations_.push_back(manager_id_); - register_device("event7"); - register_device("event8"); - ready.set_value(); - g_main_loop_run(loop_); - for (const auto id : registrations_) { - g_dbus_connection_unregister_object(connection_, id); - } - g_dbus_node_info_unref(introspection_); - g_dbus_connection_close_sync(connection_, nullptr, nullptr); - g_clear_object(&connection_); - g_main_context_pop_thread_default(context_); + run_service(ready); }}; ready.get_future().wait(); ASSERT_TRUE(startup_error_.empty()) << startup_error_; } + /** + * @brief Stop the service thread and release the isolated bus. + */ void TearDown() override { - g_main_context_invoke(context_, [](gpointer data) -> gboolean { + g_main_context_invoke(context_, [](gpointer data) { g_main_loop_quit(static_cast(data)); return G_SOURCE_REMOVE; }, @@ -144,24 +86,13 @@ namespace { devices_.try_emplace(path); static const GDBusInterfaceVTable vtable { .method_call = nullptr, - .get_property = [](GDBusConnection *, const gchar *, const gchar *path, const gchar *, const gchar *property, GError **, gpointer data) -> GVariant * { - auto &device = static_cast(data)->devices_.at(path); + .get_property = [](GDBusConnection *, const gchar *, const gchar *path, const gchar *, const gchar *property, GError **, gpointer data) { + const auto &device = static_cast(data)->devices_.at(path); return std::string_view {property} == "outputName" ? g_variant_new_string(device.output.c_str()) : g_variant_new_boolean(device.workspace); }, - .set_property = [](GDBusConnection *, const gchar *, const gchar *path, const gchar *, const gchar *property, GVariant *value, GError **error, gpointer data) -> gboolean { - auto &self = *static_cast(data); - if (self.reject_property_ == property) { - g_set_error_literal(error, G_DBUS_ERROR, self.reject_code_, "mapping denied"); - return false; - } - auto &device = self.devices_.at(path); - if (std::string_view {property} == "outputName") { - device.output = g_variant_get_string(value, nullptr); - ++device.assignments; - } else { - device.workspace = g_variant_get_boolean(value); - } - return true; + // GDBus fixes the callback prefix; forward its property payload to a typed handler. + .set_property = [](GDBusConnection *, const gchar *, const gchar *path, const gchar *, auto... property_args) { + return set_device_property(path, property_args...); }, .padding = {}, }; @@ -184,7 +115,7 @@ namespace { void on_service(Action action) { std::packaged_task task {std::move(action)}; auto completed = task.get_future(); - g_main_context_invoke(context_, [](gpointer data) -> gboolean { + g_main_context_invoke(context_, [](gpointer data) { (*static_cast *>(data))(); return G_SOURCE_REMOVE; }, @@ -199,12 +130,115 @@ namespace { */ DeviceMapping device(const char *name) { DeviceMapping result; - on_service([&] { + on_service([this, &result, name] { result = devices_.at(std::string {"/org/kde/KWin/InputDevice/"} + name); }); return result; } + private: + /** + * @brief Serve compositor requests on the worker's thread-default context. + * + * @param ready Promise completed after service startup or a startup failure. + */ + void run_service(std::promise &ready) { + g_main_context_push_thread_default(context_); + GError *error = nullptr; + connection_ = g_dbus_connection_new_for_address_sync( + g_test_dbus_get_bus_address(bus_), + GDBusConnectionFlags(G_DBUS_CONNECTION_FLAGS_AUTHENTICATION_CLIENT | G_DBUS_CONNECTION_FLAGS_MESSAGE_BUS_CONNECTION), + nullptr, + nullptr, + &error + ); + if (!connection_) { + startup_error_ = error->message; + g_error_free(error); + ready.set_value(); + g_main_context_pop_thread_default(context_); + return; + } + g_autoptr(GVariant) reply = g_dbus_connection_call_sync( + connection_, + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + "org.freedesktop.DBus", + "RequestName", + g_variant_new("(su)", "org.kde.KWin", 0U), + nullptr, + G_DBUS_CALL_FLAGS_NONE, + 1000, + nullptr, + &error + ); + if (error) { + startup_error_ = error->message; + g_error_free(error); + } + introspection_ = g_dbus_node_info_new_for_xml( + "" + "" + "" + "", + nullptr + ); + g_autoptr(GDBusNodeInfo) manager_info = g_dbus_node_info_new_for_xml( + "" + "", + nullptr + ); + static const GDBusInterfaceVTable manager_vtable { + .method_call = nullptr, + .get_property = [](GDBusConnection *, const gchar *, const gchar *, const gchar *, const gchar *, GError **, gpointer) { + const std::array nodes {"event7", "event8"}; + return g_variant_new_strv(nodes.data(), static_cast(nodes.size())); + }, + .set_property = nullptr, + .padding = {}, + }; + manager_id_ = g_dbus_connection_register_object(connection_, "/org/kde/KWin/InputDevice", manager_info->interfaces[0], &manager_vtable, this, nullptr, nullptr); + registrations_.push_back(manager_id_); + register_device("event7"); + register_device("event8"); + ready.set_value(); + g_main_loop_run(loop_); + for (const auto id : registrations_) { + g_dbus_connection_unregister_object(connection_, id); + } + g_dbus_node_info_unref(introspection_); + g_dbus_connection_close_sync(connection_, nullptr, nullptr); + g_clear_object(&connection_); + g_main_context_pop_thread_default(context_); + } + + /** + * @brief Apply a property write to one registered compositor device. + * + * @param path Event device object path. + * @param property Property being updated. + * @param value New property value. + * @param error Receives a configured rejection error. + * @param data Fixture owning the registered device. + * @return Whether the property write succeeded. + */ + static gboolean set_device_property(const gchar *path, const gchar *property, GVariant *value, GError **error, gpointer data) { + auto &self = *static_cast(data); + if (self.reject_property_ == property) { + g_set_error_literal(error, G_DBUS_ERROR, self.reject_code_, "mapping denied"); + return false; + } + auto &device = self.devices_.at(path); + if (std::string_view {property} == "outputName") { + device.output = g_variant_get_string(value, nullptr); + ++device.assignments; + } else { + device.workspace = g_variant_get_boolean(value); + } + return true; + } + + protected: guint manager_id_ = 0; ///< Wayland input manager registration. lvh::detail::OutputMapping mapping_; ///< Mapping under test. GDBusError reject_code_ = G_DBUS_ERROR_ACCESS_DENIED; ///< Failure returned for a rejected property. @@ -212,12 +246,11 @@ namespace { GMainContext *context_ = nullptr; ///< Service event context. GDBusConnection *connection_ = nullptr; ///< Service bus connection. - private: GTestDBus *bus_ = nullptr; ///< Private D-Bus daemon. GMainLoop *loop_ = nullptr; ///< Service event loop. GDBusNodeInfo *introspection_ = nullptr; ///< Device property interface. std::vector registrations_; ///< Objects registered in the private bus. - std::map devices_; ///< Compositor device state. + std::map> devices_; ///< Compositor device state. std::string startup_error_; ///< Service startup diagnostic. std::jthread worker_; ///< Compositor service worker. }; @@ -246,7 +279,7 @@ namespace { TEST_F(KWinMappingTest, LeavesEmptyTargetsAndSessionsWithoutInputManagerUnchanged) { EXPECT_TRUE(mapping_.update("/dev/input/event7", "", false).ok()); EXPECT_EQ(device("event7").assignments, 0); - on_service([&] { + on_service([this] { unregister_manager(); }); EXPECT_TRUE(mapping_.update("/dev/input/event7", "DP-1", false).ok()); @@ -260,7 +293,7 @@ namespace { } TEST_F(KWinMappingTest, LeavesOtherCompositorsUnchanged) { - on_service([&] { + on_service([this] { g_autoptr(GVariant) reply = g_dbus_connection_call_sync( connection_, "org.freedesktop.DBus", @@ -280,7 +313,7 @@ namespace { } TEST_F(KWinMappingTest, RejectsInvalidDeviceNodesAndDisplayNames) { - for (const auto *node : {"", "/dev/input/event", "/dev/input/event7/other", "/tmp/event7", "/dev/input/event-1", "/dev/input/eventz"}) { + for (const auto *node : {"", "/dev/input/event", "/dev/input/event7/other", "/not-an-input-device/event7", "/dev/input/event-1", "/dev/input/eventz"}) { EXPECT_EQ(mapping_.update(node, "DP-1", false).code(), lvh::ErrorCode::invalid_argument) << node; } EXPECT_EQ(mapping_.update("/dev/input/event7", std::string(1, '\xff'), false).code(), lvh::ErrorCode::invalid_argument); @@ -289,14 +322,14 @@ namespace { } TEST_F(KWinMappingTest, RetriesFailedAssignments) { - on_service([&] { + on_service([this] { reject_property_ = "outputName"; }); auto status = mapping_.update("/dev/input/event7", "DP-1", false); EXPECT_EQ(status.code(), lvh::ErrorCode::backend_failure); EXPECT_NE(status.message().find("mapping denied"), std::string::npos); EXPECT_EQ(device("event7").output, "HDMI-A-1"); - on_service([&] { + on_service([this] { reject_property_.clear(); }); EXPECT_TRUE(mapping_.update("/dev/input/event7", "DP-1", false).ok()); @@ -304,12 +337,12 @@ namespace { } TEST_F(KWinMappingTest, ReportsTabletWorkspaceMappingFailureAndRetries) { - on_service([&] { + on_service([this] { reject_property_ = "mapToWorkspace"; }); EXPECT_EQ(mapping_.update("/dev/input/event7", "DP-1", true).code(), lvh::ErrorCode::backend_failure); EXPECT_TRUE(device("event7").workspace); - on_service([&] { + on_service([this] { reject_property_.clear(); }); EXPECT_TRUE(mapping_.update("/dev/input/event7", "DP-1", true).ok()); @@ -318,7 +351,7 @@ namespace { TEST_F(KWinMappingTest, WaitsForNewDeviceRegistration) { GSource *source = g_timeout_source_new(15); - g_source_set_callback(source, [](gpointer data) -> gboolean { + g_source_set_callback(source, [](gpointer data) { static_cast(data)->register_device("event9"); return G_SOURCE_REMOVE; }, @@ -331,7 +364,7 @@ namespace { } TEST_F(KWinMappingTest, BoundsTheWaitWhenCompositorReportsUnknownObject) { - on_service([&] { + on_service([this] { reject_property_ = "outputName"; reject_code_ = G_DBUS_ERROR_UNKNOWN_OBJECT; }); @@ -376,7 +409,7 @@ namespace { } TEST_F(KWinMappingTest, FailedAssignmentEmitsNoTouchOrPenInput) { - on_service([&] { + on_service([this] { reject_property_ = "outputName"; }); lvh::TouchContact contact; From 13d71ca97aa6f9a261a11e9cbe774a2c470e5023 Mon Sep 17 00:00:00 2001 From: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:55:52 -0500 Subject: [PATCH 3/4] refactor(linux): encapsulate output mapping test state Keep fixture state private behind explicit controls and isolate optional touchscreen assignment from contact emission to satisfy the remaining Sonar findings. --- src/platform/linux/uhid_backend.cpp | 16 +++- tests/unit/test_linux_output_mapping.cpp | 108 ++++++++++++++--------- 2 files changed, 82 insertions(+), 42 deletions(-) diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp index 925e4c6..117938e 100644 --- a/src/platform/linux/uhid_backend.cpp +++ b/src/platform/linux/uhid_backend.cpp @@ -1966,7 +1966,7 @@ namespace lvh::detail { if (!is_open()) { return OperationStatus::failure(ErrorCode::device_closed, "uinput touch device is closed"); } - if (const auto status = update_trackpad_buttons ? OperationStatus::success() : map_output(contact.viewport, false); !status.ok()) { + if (const auto status = map_touch_output(contact.viewport, update_trackpad_buttons); !status.ok()) { return status; } if (contact.id < 0) { @@ -2067,6 +2067,20 @@ namespace lvh::detail { } private: + /** + * @brief Assign touchscreen contacts while preserving relative trackpad mapping. + * + * @param viewport Contact output metadata. + * @param trackpad Whether the device uses relative trackpad behavior. + * @return Mapping status, or success when a trackpad needs no assignment. + */ + OperationStatus map_touch_output(const PointerViewport &viewport, bool trackpad) { + if (trackpad) { + return OperationStatus::success(); + } + return map_output(viewport, false); + } + std::optional slot_for_contact(std::int32_t contact_id) { if (const auto it = contacts_.find(contact_id); it != contacts_.end()) { new_slot_ = false; diff --git a/tests/unit/test_linux_output_mapping.cpp b/tests/unit/test_linux_output_mapping.cpp index 4c9f3b2..d26519f 100644 --- a/tests/unit/test_linux_output_mapping.cpp +++ b/tests/unit/test_linux_output_mapping.cpp @@ -136,6 +136,46 @@ namespace { return result; } + /** + * @brief Access the output assignment cache under test. + * + * @return Mutable mapping instance owned by this fixture. + */ + lvh::detail::OutputMapping &mapping() { + return mapping_; + } + + /** + * @brief Access the service context when scheduling device registration. + * + * @return Borrowed service event context. + */ + GMainContext *service_context() const { + return context_; + } + + /** + * @brief Access the service bus connection from a service-thread action. + * + * @return Borrowed compositor connection. + */ + GDBusConnection *service_connection() const { + return connection_; + } + + /** + * @brief Configure property write rejection on the service thread. + * + * @param property Property to reject, or empty to accept all writes. + * @param code D-Bus error returned for the rejected property. + */ + void set_rejection(std::string property = {}, GDBusError code = G_DBUS_ERROR_ACCESS_DENIED) { + on_service([this, rejected_property = std::move(property), code] { + reject_property_ = rejected_property; + reject_code_ = code; + }); + } + private: /** * @brief Serve compositor requests on the worker's thread-default context. @@ -238,7 +278,6 @@ namespace { return true; } - protected: guint manager_id_ = 0; ///< Wayland input manager registration. lvh::detail::OutputMapping mapping_; ///< Mapping under test. GDBusError reject_code_ = G_DBUS_ERROR_ACCESS_DENIED; ///< Failure returned for a rejected property. @@ -256,46 +295,46 @@ namespace { }; TEST_F(KWinMappingTest, MapsOnlyTheOwnedTouchscreenAndCachesTheAssignment) { - ASSERT_TRUE(mapping_.update("/dev/input/event7", "DP-1", false).ok()); + ASSERT_TRUE(mapping().update("/dev/input/event7", "DP-1", false).ok()); EXPECT_EQ(device("event7").output, "DP-1"); EXPECT_EQ(device("event8").output, "HDMI-A-1"); EXPECT_TRUE(device("event7").workspace); - ASSERT_TRUE(mapping_.update("/dev/input/event7", "DP-1", false).ok()); + ASSERT_TRUE(mapping().update("/dev/input/event7", "DP-1", false).ok()); EXPECT_EQ(device("event7").assignments, 1); - ASSERT_TRUE(mapping_.update("/dev/input/event7", "HDMI-A-1", false).ok()); + ASSERT_TRUE(mapping().update("/dev/input/event7", "HDMI-A-1", false).ok()); EXPECT_EQ(device("event7").output, "HDMI-A-1"); EXPECT_EQ(device("event7").assignments, 2); - ASSERT_TRUE(mapping_.update("/dev/input/event8", "HDMI-A-1", false).ok()); + ASSERT_TRUE(mapping().update("/dev/input/event8", "HDMI-A-1", false).ok()); EXPECT_EQ(device("event8").assignments, 1); } TEST_F(KWinMappingTest, MapsPenToOneOutputAndDisablesWorkspaceMapping) { - ASSERT_TRUE(mapping_.update("/dev/input/event7", "DP-1", true).ok()); + ASSERT_TRUE(mapping().update("/dev/input/event7", "DP-1", true).ok()); EXPECT_EQ(device("event7").output, "DP-1"); EXPECT_FALSE(device("event7").workspace); EXPECT_TRUE(device("event8").workspace); } TEST_F(KWinMappingTest, LeavesEmptyTargetsAndSessionsWithoutInputManagerUnchanged) { - EXPECT_TRUE(mapping_.update("/dev/input/event7", "", false).ok()); + EXPECT_TRUE(mapping().update("/dev/input/event7", "", false).ok()); EXPECT_EQ(device("event7").assignments, 0); on_service([this] { unregister_manager(); }); - EXPECT_TRUE(mapping_.update("/dev/input/event7", "DP-1", false).ok()); + EXPECT_TRUE(mapping().update("/dev/input/event7", "DP-1", false).ok()); EXPECT_EQ(device("event7").assignments, 0); } TEST_F(KWinMappingTest, MapsWaylandDeviceWithoutWaylandDisplayEnvironment) { g_unsetenv("WAYLAND_DISPLAY"); - EXPECT_TRUE(mapping_.update("/dev/input/event7", "DP-1", false).ok()); + EXPECT_TRUE(mapping().update("/dev/input/event7", "DP-1", false).ok()); EXPECT_EQ(device("event7").output, "DP-1"); } TEST_F(KWinMappingTest, LeavesOtherCompositorsUnchanged) { on_service([this] { g_autoptr(GVariant) reply = g_dbus_connection_call_sync( - connection_, + service_connection(), "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", @@ -308,44 +347,36 @@ namespace { nullptr ); }); - EXPECT_TRUE(mapping_.update("/dev/input/event7", "DP-1", false).ok()); + EXPECT_TRUE(mapping().update("/dev/input/event7", "DP-1", false).ok()); EXPECT_EQ(device("event7").assignments, 0); } TEST_F(KWinMappingTest, RejectsInvalidDeviceNodesAndDisplayNames) { for (const auto *node : {"", "/dev/input/event", "/dev/input/event7/other", "/not-an-input-device/event7", "/dev/input/event-1", "/dev/input/eventz"}) { - EXPECT_EQ(mapping_.update(node, "DP-1", false).code(), lvh::ErrorCode::invalid_argument) << node; + EXPECT_EQ(mapping().update(node, "DP-1", false).code(), lvh::ErrorCode::invalid_argument) << node; } - EXPECT_EQ(mapping_.update("/dev/input/event7", std::string(1, '\xff'), false).code(), lvh::ErrorCode::invalid_argument); - EXPECT_EQ(mapping_.update("/dev/input/event7", std::string {"DP-1\0x", 6}, false).code(), lvh::ErrorCode::invalid_argument); + EXPECT_EQ(mapping().update("/dev/input/event7", std::string(1, '\xff'), false).code(), lvh::ErrorCode::invalid_argument); + EXPECT_EQ(mapping().update("/dev/input/event7", std::string {"DP-1\0x", 6}, false).code(), lvh::ErrorCode::invalid_argument); EXPECT_EQ(device("event7").assignments, 0); } TEST_F(KWinMappingTest, RetriesFailedAssignments) { - on_service([this] { - reject_property_ = "outputName"; - }); - auto status = mapping_.update("/dev/input/event7", "DP-1", false); + set_rejection("outputName"); + auto status = mapping().update("/dev/input/event7", "DP-1", false); EXPECT_EQ(status.code(), lvh::ErrorCode::backend_failure); EXPECT_NE(status.message().find("mapping denied"), std::string::npos); EXPECT_EQ(device("event7").output, "HDMI-A-1"); - on_service([this] { - reject_property_.clear(); - }); - EXPECT_TRUE(mapping_.update("/dev/input/event7", "DP-1", false).ok()); + set_rejection(); + EXPECT_TRUE(mapping().update("/dev/input/event7", "DP-1", false).ok()); EXPECT_EQ(device("event7").output, "DP-1"); } TEST_F(KWinMappingTest, ReportsTabletWorkspaceMappingFailureAndRetries) { - on_service([this] { - reject_property_ = "mapToWorkspace"; - }); - EXPECT_EQ(mapping_.update("/dev/input/event7", "DP-1", true).code(), lvh::ErrorCode::backend_failure); + set_rejection("mapToWorkspace"); + EXPECT_EQ(mapping().update("/dev/input/event7", "DP-1", true).code(), lvh::ErrorCode::backend_failure); EXPECT_TRUE(device("event7").workspace); - on_service([this] { - reject_property_.clear(); - }); - EXPECT_TRUE(mapping_.update("/dev/input/event7", "DP-1", true).ok()); + set_rejection(); + EXPECT_TRUE(mapping().update("/dev/input/event7", "DP-1", true).ok()); EXPECT_FALSE(device("event7").workspace); } @@ -357,23 +388,20 @@ namespace { }, this, nullptr); - g_source_attach(source, context_); + g_source_attach(source, service_context()); g_source_unref(source); - EXPECT_TRUE(mapping_.update("/dev/input/event9", "DP-1", false).ok()); + EXPECT_TRUE(mapping().update("/dev/input/event9", "DP-1", false).ok()); EXPECT_EQ(device("event9").output, "DP-1"); } TEST_F(KWinMappingTest, BoundsTheWaitWhenCompositorReportsUnknownObject) { - on_service([this] { - reject_property_ = "outputName"; - reject_code_ = G_DBUS_ERROR_UNKNOWN_OBJECT; - }); - EXPECT_EQ(mapping_.update("/dev/input/event7", "DP-1", false).code(), lvh::ErrorCode::backend_failure); + set_rejection("outputName", G_DBUS_ERROR_UNKNOWN_OBJECT); + EXPECT_EQ(mapping().update("/dev/input/event7", "DP-1", false).code(), lvh::ErrorCode::backend_failure); EXPECT_EQ(device("event7").assignments, 0); } TEST_F(KWinMappingTest, BoundsTheWaitForAnUnregisteredDevice) { - EXPECT_EQ(mapping_.update("/dev/input/event404", "DP-1", false).code(), lvh::ErrorCode::backend_failure); + EXPECT_EQ(mapping().update("/dev/input/event404", "DP-1", false).code(), lvh::ErrorCode::backend_failure); EXPECT_EQ(device("event7").assignments, 0); } @@ -409,9 +437,7 @@ namespace { } TEST_F(KWinMappingTest, FailedAssignmentEmitsNoTouchOrPenInput) { - on_service([this] { - reject_property_ = "outputName"; - }); + set_rejection("outputName"); lvh::TouchContact contact; contact.viewport.output_name = "DP-1"; const auto touch = lvh::detail::test::linux_uinput_mapped_contact(contact); From 86da2c2d32af6a36146f2ad406578bca5a23643c Mon Sep 17 00:00:00 2001 From: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:03:45 -0500 Subject: [PATCH 4/4] fix(linux): retry unconfirmed output mapping probes --- src/platform/linux/output_mapping.cpp | 21 +++++---- src/platform/linux/output_mapping.hpp | 3 +- tests/unit/test_linux_output_mapping.cpp | 58 +++++++++++++++++------- 3 files changed, 56 insertions(+), 26 deletions(-) diff --git a/src/platform/linux/output_mapping.cpp b/src/platform/linux/output_mapping.cpp index 8d80f9d..a759ddb 100644 --- a/src/platform/linux/output_mapping.cpp +++ b/src/platform/linux/output_mapping.cpp @@ -6,6 +6,7 @@ // standard includes #include #include +#include #include #include @@ -77,18 +78,18 @@ namespace lvh::detail { * @param devnode Owned event node. * @param output_name Native display connector. * @param tablet Whether workspace-wide tablet mapping must be disabled. - * @return Success for unsupported sessions, or the KDE mapping result. + * @return The KDE mapping result, or no result when capability is unconfirmed. */ - OperationStatus assign_if_supported(std::string_view devnode, const std::string &output_name, bool tablet) { + std::optional assign_if_supported(std::string_view devnode, const std::string &output_name, bool tablet) { g_autoptr(GError) error = nullptr; g_autoptr(GDBusConnection) connection = g_bus_get_sync(G_BUS_TYPE_SESSION, nullptr, &error); if (!connection) { - return OperationStatus::success(); + return std::nullopt; } // This manager is exposed by KWin's Wayland input backend. Detect it // directly because a user service may not inherit WAYLAND_DISPLAY. if (g_autoptr(GVariant) manager = g_dbus_connection_call_sync(connection, kwin_service, "/org/kde/KWin/InputDevice", "org.freedesktop.DBus.Properties", "Get", g_variant_new("(ss)", "org.kde.KWin.InputDeviceManager", "devicesSysNames"), G_VARIANT_TYPE("(v)"), G_DBUS_CALL_FLAGS_NO_AUTO_START, call_timeout_ms, nullptr, nullptr); !manager) { - return OperationStatus::success(); + return std::nullopt; } if (constexpr std::string_view prefix = "/dev/input/event"; !devnode.starts_with(prefix) || devnode.size() == prefix.size() || !std::ranges::all_of(devnode.substr(prefix.size()), [](char c) { return c >= '0' && c <= '9'; @@ -106,14 +107,18 @@ namespace lvh::detail { return OperationStatus::success(); } #if defined(LIBVIRTUALHID_HAVE_GIO) - if (const auto status = assign_if_supported(devnode, output_name, tablet); !status.ok()) { - return status; + const auto status = assign_if_supported(devnode, output_name, tablet); + if (!status) { + return OperationStatus::success(); + } + if (!status->ok()) { + return *status; } + devnode_ = devnode; + output_name_ = output_name; #else static_cast(tablet); #endif - devnode_ = devnode; - output_name_ = output_name; return OperationStatus::success(); } } // namespace lvh::detail diff --git a/src/platform/linux/output_mapping.hpp b/src/platform/linux/output_mapping.hpp index 980da82..1425f9f 100644 --- a/src/platform/linux/output_mapping.hpp +++ b/src/platform/linux/output_mapping.hpp @@ -22,7 +22,8 @@ namespace lvh::detail { * * KDE Wayland exposes output assignment through its session D-Bus service. * Other compositors and builds without GIO preserve their existing mapping. - * Successful assignments are cached; failed assignments can be retried. + * Only confirmed assignments are cached. Unavailable capability probes and + * failed assignments are retried on later input events. * * @param devnode Event device owned by the caller, such as `/dev/input/event7`. * @param output_name Native connector name, or empty to preserve defaults. diff --git a/tests/unit/test_linux_output_mapping.cpp b/tests/unit/test_linux_output_mapping.cpp index d26519f..8e1be6b 100644 --- a/tests/unit/test_linux_output_mapping.cpp +++ b/tests/unit/test_linux_output_mapping.cpp @@ -99,12 +99,34 @@ namespace { registrations_.push_back(g_dbus_connection_register_object(connection_, path.c_str(), introspection_->interfaces[0], &vtable, this, nullptr, nullptr)); } + /** + * @brief Expose the Wayland input manager on the compositor service thread. + */ + void register_manager() { + g_autoptr(GDBusNodeInfo) manager_info = g_dbus_node_info_new_for_xml( + "" + "", + nullptr + ); + static const GDBusInterfaceVTable manager_vtable { + .method_call = nullptr, + .get_property = [](GDBusConnection *, const gchar *, const gchar *, const gchar *, const gchar *, GError **, gpointer) { + const std::array nodes {"event7", "event8"}; + return g_variant_new_strv(nodes.data(), static_cast(nodes.size())); + }, + .set_property = nullptr, + .padding = {}, + }; + manager_id_ = g_dbus_connection_register_object(connection_, "/org/kde/KWin/InputDevice", manager_info->interfaces[0], &manager_vtable, this, nullptr, nullptr); + registrations_.push_back(manager_id_); + } + /** * @brief Remove the Wayland manager while keeping the compositor service alive. */ void unregister_manager() { g_dbus_connection_unregister_object(connection_, manager_id_); - registrations_.erase(registrations_.begin()); + std::erase(registrations_, manager_id_); } /** @@ -223,22 +245,7 @@ namespace { "", nullptr ); - g_autoptr(GDBusNodeInfo) manager_info = g_dbus_node_info_new_for_xml( - "" - "", - nullptr - ); - static const GDBusInterfaceVTable manager_vtable { - .method_call = nullptr, - .get_property = [](GDBusConnection *, const gchar *, const gchar *, const gchar *, const gchar *, GError **, gpointer) { - const std::array nodes {"event7", "event8"}; - return g_variant_new_strv(nodes.data(), static_cast(nodes.size())); - }, - .set_property = nullptr, - .padding = {}, - }; - manager_id_ = g_dbus_connection_register_object(connection_, "/org/kde/KWin/InputDevice", manager_info->interfaces[0], &manager_vtable, this, nullptr, nullptr); - registrations_.push_back(manager_id_); + register_manager(); register_device("event7"); register_device("event8"); ready.set_value(); @@ -325,6 +332,23 @@ namespace { EXPECT_EQ(device("event7").assignments, 0); } + TEST_F(KWinMappingTest, RetriesCapabilityProbeAfterManagerBecomesAvailable) { + on_service([this] { + unregister_manager(); + }); + ASSERT_TRUE(mapping().update("/dev/input/event7", "DP-1", false).ok()); + EXPECT_EQ(device("event7").assignments, 0); + EXPECT_EQ(device("event7").output, "HDMI-A-1"); + on_service([this] { + register_manager(); + }); + ASSERT_TRUE(mapping().update("/dev/input/event7", "DP-1", false).ok()); + EXPECT_EQ(device("event7").output, "DP-1"); + EXPECT_EQ(device("event7").assignments, 1); + ASSERT_TRUE(mapping().update("/dev/input/event7", "DP-1", false).ok()); + EXPECT_EQ(device("event7").assignments, 1); + } + TEST_F(KWinMappingTest, MapsWaylandDeviceWithoutWaylandDisplayEnvironment) { g_unsetenv("WAYLAND_DISPLAY"); EXPECT_TRUE(mapping().update("/dev/input/event7", "DP-1", false).ok());