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..a759ddb --- /dev/null +++ b/src/platform/linux/output_mapping.cpp @@ -0,0 +1,124 @@ +/** + * @file src/platform/linux/output_mapping.cpp + * @brief KDE Wayland output assignment for virtual touch and pen devices. + */ + +// standard includes +#include +#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) && (!tablet || set_property(connection, path, "mapToWorkspace", g_variant_new_boolean(false), &error))) { + break; + } + 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)); + } + 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 The KDE mapping result, or no result when capability is unconfirmed. + */ + 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 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 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'; + }) || !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) + 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 + 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..1425f9f --- /dev/null +++ b/src/platform/linux/output_mapping.hpp @@ -0,0 +1,39 @@ +/** + * @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. + * 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. + * @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..117938e 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_; }; @@ -1701,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}, @@ -1939,10 +1955,20 @@ 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 (const auto status = map_touch_output(contact.viewport, update_trackpad_buttons); !status.ok()) { + return status; + } if (contact.id < 0) { return OperationStatus::failure(ErrorCode::invalid_argument, "touch contact id must not be negative"); } @@ -2041,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; @@ -2230,6 +2270,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..8e1be6b --- /dev/null +++ b/tests/unit/test_linux_output_mapping.cpp @@ -0,0 +1,504 @@ +/** + * @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 +#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: + /** + * @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_); + 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] { + 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) { + 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) { + 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); + }, + // 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 = {}, + }; + 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_); + std::erase(registrations_, manager_id_); + } + + /** + * @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) { + (*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([this, &result, name] { + result = devices_.at(std::string {"/org/kde/KWin/InputDevice/"} + name); + }); + 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. + * + * @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 + ); + register_manager(); + 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; + } + + 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. + + 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([this] { + unregister_manager(); + }); + EXPECT_TRUE(mapping().update("/dev/input/event7", "DP-1", false).ok()); + 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()); + EXPECT_EQ(device("event7").output, "DP-1"); + } + + TEST_F(KWinMappingTest, LeavesOtherCompositorsUnchanged) { + on_service([this] { + g_autoptr(GVariant) reply = g_dbus_connection_call_sync( + service_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", "/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); + 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) { + 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"); + set_rejection(); + EXPECT_TRUE(mapping().update("/dev/input/event7", "DP-1", false).ok()); + EXPECT_EQ(device("event7").output, "DP-1"); + } + + TEST_F(KWinMappingTest, ReportsTabletWorkspaceMappingFailureAndRetries) { + set_rejection("mapToWorkspace"); + EXPECT_EQ(mapping().update("/dev/input/event7", "DP-1", true).code(), lvh::ErrorCode::backend_failure); + EXPECT_TRUE(device("event7").workspace); + set_rejection(); + 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) { + static_cast(data)->register_device("event9"); + return G_SOURCE_REMOVE; + }, + this, + nullptr); + g_source_attach(source, service_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) { + 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(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) { + set_rejection("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