Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ jobs:
clang \
cmake \
libevdev-dev \
libglib2.0-dev \
libinput-dev \
libsdl2-dev \
libx11-dev \
Expand Down
5 changes: 5 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
16 changes: 16 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
13 changes: 12 additions & 1 deletion src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand Down
10 changes: 10 additions & 0 deletions src/include/libvirtualhid/types.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 {};
};

/**
Expand Down
124 changes: 124 additions & 0 deletions src/platform/linux/output_mapping.cpp
Original file line number Diff line number Diff line change
@@ -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 <algorithm>
#include <chrono>
#include <optional>
#include <string>
#include <thread>

// lib includes
#if defined(LIBVIRTUALHID_HAVE_GIO)
#include <gio/gio.h>
#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<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 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<gssize>(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<void>(tablet);
#endif
return OperationStatus::success();
}
} // namespace lvh::detail
39 changes: 39 additions & 0 deletions src/platform/linux/output_mapping.hpp
Original file line number Diff line number Diff line change
@@ -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 <string>
#include <string_view>

// lib includes
#include <libvirtualhid/types.hpp>

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
47 changes: 45 additions & 2 deletions src/platform/linux/uhid_backend.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -1038,6 +1039,20 @@ namespace lvh::detail {

std::vector<DeviceNode> 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);
Expand Down Expand Up @@ -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_;
};
Expand Down Expand Up @@ -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},
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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<int> slot_for_contact(std::int32_t contact_id) {
if (const auto it = contacts_.find(contact_id); it != contacts_.end()) {
new_slot_ = false;
Expand Down Expand Up @@ -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;
}
Expand Down
5 changes: 5 additions & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -981,6 +981,20 @@ namespace lvh::detail::test {
*/
LinuxMouseInputSubmissionResult linux_uinput_mouse_submit_split_pipe_sequence(const std::vector<MouseEvent> &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.
*
Expand Down
Loading
Loading