From 379322dc19a5737d2c00567919ac7db923fb09cb Mon Sep 17 00:00:00 2001 From: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:17:58 -0500 Subject: [PATCH 1/4] fix(linux): target absolute input at the captured monitor Forward the streamed connector to libvirtualhid for KDE Wayland touch and pen assignment. Preserve negative desktop origins across capture backends and exclude client letterboxing from native pointer normalization. Pin the preceding libvirtualhid output-mapping fix and cover desktop geometry, monitor retargeting, and normalized input with regression tests. --- docs/troubleshooting.md | 5 + .../flatpak/dev.lizardbyte.app.Sunshine.yml | 1 + src/input.cpp | 15 +- src/input.h | 8 + src/platform/common.h | 2 + src/platform/linux/display_geometry.h | 79 ++++++++++ src/platform/linux/kmsgrab.cpp | 51 +++---- src/platform/linux/kwingrab.cpp | 1 + src/platform/linux/pipewire.cpp | 46 ++---- src/platform/linux/portalgrab.cpp | 1 + src/platform/linux/wlgrab.cpp | 26 +--- src/platform/virtualhid_input.cpp | 1 + src/video.cpp | 3 +- src/video.h | 9 ++ .../platform/linux/test_display_geometry.cpp | 140 ++++++++++++++++++ tests/unit/platform/test_virtualhid_input.cpp | 15 +- tests/unit/test_input.cpp | 115 +++++++++++++- third-party/libvirtualhid | 2 +- 18 files changed, 424 insertions(+), 96 deletions(-) create mode 100644 src/platform/linux/display_geometry.h create mode 100644 tests/unit/platform/linux/test_display_geometry.cpp diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 3c3d604ff01..873e3439926 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -31,6 +31,11 @@ For absolute-positioning problems, set **Configuration > Advanced > Log Level** Sunshine forwards libvirtualhid diagnostics with a `[libvirtualhid]` prefix, including the streamed display viewport and full desktop bounds used for mouse coordinate mapping. +For native touch or pen input on KDE Wayland, Sunshine maps its virtual devices to the streamed display. +Use Plasma 6.5.1 or later, which fixes changing a device's assigned output. Display layouts with monitors +left of or above the desktop origin are supported. Other Wayland compositors retain their device mapping +settings; assign Sunshine's virtual touchscreen and pen tablet through the compositor's input configuration. + ### Web UI Access Can't access the web UI? diff --git a/packaging/linux/flatpak/dev.lizardbyte.app.Sunshine.yml b/packaging/linux/flatpak/dev.lizardbyte.app.Sunshine.yml index c6eba43e68e..7e03d69bff5 100644 --- a/packaging/linux/flatpak/dev.lizardbyte.app.Sunshine.yml +++ b/packaging/linux/flatpak/dev.lizardbyte.app.Sunshine.yml @@ -24,6 +24,7 @@ finish-args: - --socket=fallback-x11 # show windows using X11 - --system-talk-name=org.freedesktop.Avahi # talk to avahi on the system bus - --talk-name=org.freedesktop.Flatpak # talk to flatpak on the session bus + - --talk-name=org.kde.KWin # assign virtual touch and pen devices to the streamed output - --talk-name=org.kde.StatusNotifierWatcher # talk to system tray on the session bus cleanup: diff --git a/src/input.cpp b/src/input.cpp index 1facd6afd23..4c7917cd5bc 100644 --- a/src/input.cpp +++ b/src/input.cpp @@ -759,6 +759,7 @@ namespace input { touch_port.logical_height, touch_port.env_offset_x, touch_port.env_offset_y, + touch_port.output_name, }; } @@ -1378,14 +1379,15 @@ namespace input { } /** - * @brief Normalizes coordinates to monitor-local logical touch dimensions. + * @brief Normalize coordinates within the streamed monitor, excluding client letterboxing. + * * @param touch_port The current touch port metadata. * @param coords The in/out coordinate pair to normalize. * @return The monitor-local touch port, or std::nullopt if dimensions are invalid. */ std::optional monitor_touch_port(const input::touch_port_t &touch_port, std::pair &coords) { - const float monitor_logical_w = (touch_port.width * touch_port.scalar_inv) / touch_port.scalar_tpcoords; - const float monitor_logical_h = (touch_port.height * touch_port.scalar_inv) / touch_port.scalar_tpcoords; + const float monitor_logical_w = ((touch_port.width - 2.0f * touch_port.client_offsetX) * touch_port.scalar_inv) / touch_port.scalar_tpcoords; + const float monitor_logical_h = ((touch_port.height - 2.0f * touch_port.client_offsetY) * touch_port.scalar_inv) / touch_port.scalar_tpcoords; if (monitor_logical_w <= 0.0f || monitor_logical_h <= 0.0f) { BOOST_LOG(warning) << "Ignoring touch/pen input due to invalid logical touch dimensions"sv; return std::nullopt; @@ -1401,6 +1403,9 @@ namespace input { static_cast(monitor_logical_h), static_cast(monitor_logical_w), static_cast(monitor_logical_h), + touch_port.env_offset_x, + touch_port.env_offset_y, + touch_port.output_name, }; } @@ -2472,6 +2477,10 @@ namespace input { gamepadMask.reset(); } + platf::client_input_t *client_context(const std::shared_ptr &input) { + return input->client_context.get(); + } + int alloc_gamepad(std::shared_ptr &input, std::uint8_t client_index, const platf::gamepad_arrival_t &metadata) { return ::input::alloc_gamepad(input, client_index, metadata); } diff --git a/src/input.h b/src/input.h index f181f633611..41e0439aa31 100644 --- a/src/input.h +++ b/src/input.h @@ -93,6 +93,14 @@ namespace input { */ void set_platform_input(platf::input_t input); + /** + * @brief Access a retained stream's platform client devices in a unit test. + * + * @param input Retained input state. + * @return Borrowed platform client input context. + */ + platf::client_input_t *client_context(const std::shared_ptr &input); + /** * @brief Allocate a gamepad directly in retained input state for a unit test. * diff --git a/src/platform/common.h b/src/platform/common.h index 62961d7a310..7351e64cae1 100644 --- a/src/platform/common.h +++ b/src/platform/common.h @@ -395,6 +395,7 @@ namespace platf { int logical_height; ///< Logical height after display scaling. int env_offset_x {}; ///< Horizontal origin of the full input environment in native desktop coordinates. int env_offset_y {}; ///< Vertical origin of the full input environment in native desktop coordinates. + std::string output_name {}; ///< Native connector name used to assign compositor-managed touch and pen devices. }; // These values must match Limelight-internal.h's SS_FF_* constants! @@ -802,6 +803,7 @@ namespace platf { virtual ~display_t() = default; // Offsets for when streaming a specific monitor. By default, they are 0. + std::string output_name; ///< Native connector name of the captured display for absolute input assignment. int offset_x {0}; ///< Horizontal capture offset in physical pixels. int offset_y {0}; ///< Vertical capture offset in physical pixels. int env_offset_x {0}; ///< Horizontal origin of the full capture environment in native desktop coordinates. diff --git a/src/platform/linux/display_geometry.h b/src/platform/linux/display_geometry.h new file mode 100644 index 00000000000..ab38dae2520 --- /dev/null +++ b/src/platform/linux/display_geometry.h @@ -0,0 +1,79 @@ +/** + * @file src/platform/linux/display_geometry.h + * @brief Desktop bounds for absolute input across Linux capture backends. + */ +#pragma once + +// standard includes +#include + +// local includes +#include "src/platform/common.h" + +namespace platf { + /** + * @brief Accumulate monitor extents while preserving a negative desktop origin. + * + * Output offsets are compositor coordinates. Logical dimensions define the + * absolute input desktop; physical dimensions retain capture's pixel extents. + */ + class desktop_geometry_t { + public: + /** + * @brief Include an active monitor in the desktop bounds. + * + * @param viewport Monitor position and physical/logical dimensions. + */ + void add(const touch_port_t &viewport) { + if (viewport.width <= 0 || viewport.height <= 0) { + return; + } + const bool has_logical_size = viewport.logical_width > 0 && viewport.logical_height > 0; + const int logical_width = has_logical_size ? viewport.logical_width : viewport.width; + const int logical_height = has_logical_size ? viewport.logical_height : viewport.height; + if (!initialized_) { + bounds_ = viewport; + bounds_.logical_width = logical_width; + bounds_.logical_height = logical_height; + bounds_.output_name.clear(); + initialized_ = true; + return; + } + const int left = std::min(bounds_.offset_x, viewport.offset_x); + const int top = std::min(bounds_.offset_y, viewport.offset_y); + bounds_.width = std::max(bounds_.offset_x + bounds_.width, viewport.offset_x + viewport.width) - left; + bounds_.height = std::max(bounds_.offset_y + bounds_.height, viewport.offset_y + viewport.height) - top; + bounds_.logical_width = std::max(bounds_.offset_x + bounds_.logical_width, viewport.offset_x + logical_width) - left; + bounds_.logical_height = std::max(bounds_.offset_y + bounds_.logical_height, viewport.offset_y + logical_height) - top; + bounds_.offset_x = left; + bounds_.offset_y = top; + } + + /** + * @brief Get the union of included monitors. + * + * @return Desktop origin and extents, or zero dimensions if no valid monitor was included. + */ + const touch_port_t &bounds() const { + return bounds_; + } + + /** + * @brief Publish the accumulated input desktop on a capture display. + * + * @param display Capture display receiving physical and logical desktop geometry. + */ + void apply(display_t &display) const { + display.env_offset_x = bounds_.offset_x; + display.env_offset_y = bounds_.offset_y; + display.env_width = bounds_.width; + display.env_height = bounds_.height; + display.env_logical_width = bounds_.logical_width; + display.env_logical_height = bounds_.logical_height; + } + + private: + touch_port_t bounds_ {}; ///< Union of all valid monitor extents. + bool initialized_ = false; ///< Whether a valid monitor has established the origin. + }; +} // namespace platf diff --git a/src/platform/linux/kmsgrab.cpp b/src/platform/linux/kmsgrab.cpp index 0274f0b755a..bd5011645bf 100644 --- a/src/platform/linux/kmsgrab.cpp +++ b/src/platform/linux/kmsgrab.cpp @@ -24,6 +24,7 @@ // local includes #include "cuda.h" +#include "display_geometry.h" #include "graphics.h" #include "kms_plane.h" #include "src/config.h" @@ -343,11 +344,7 @@ namespace platf { */ using conn_type_count_t = std::map; - static int env_width; - static int env_height; - - static int env_logical_width; - static int env_logical_height; + static platf::touch_port_t env_bounds {}; ///< Full desktop origin and extents for absolute input. /** * @brief Convert a DRM plane type value to a diagnostic string. @@ -1259,11 +1256,13 @@ namespace platf { img_offset_x = crtc->x; img_offset_y = crtc->y; - this->env_width = ::platf::kms::env_width; - this->env_height = ::platf::kms::env_height; + this->env_offset_x = ::platf::kms::env_bounds.offset_x; + this->env_offset_y = ::platf::kms::env_bounds.offset_y; + this->env_width = ::platf::kms::env_bounds.width; + this->env_height = ::platf::kms::env_bounds.height; - this->env_logical_width = ::platf::kms::env_logical_width; - this->env_logical_height = ::platf::kms::env_logical_height; + this->env_logical_width = ::platf::kms::env_bounds.logical_width; + this->env_logical_height = ::platf::kms::env_bounds.logical_height; auto monitor_pos = pos->crtc_to_monitor.find(plane->crtc_id); if (monitor_pos != std::end(pos->crtc_to_monitor)) { @@ -1289,6 +1288,7 @@ namespace platf { offset_x = viewport.offset_x; offset_y = viewport.offset_y; + output_name = viewport.output_name; } // This code path shouldn't happen, but it's there just in case. @@ -2264,6 +2264,7 @@ namespace platf { for (auto &card_descriptor : cds) { for (auto &[_, monitor_descriptor] : card_descriptor.crtc_to_monitor) { if (monitor_descriptor.index == index && monitor_descriptor.type == type) { + monitor_descriptor.viewport.output_name = monitor->name; monitor_descriptor.viewport.offset_x = monitor->viewport.offset_x; monitor_descriptor.viewport.offset_y = monitor->viewport.offset_y; monitor_descriptor.viewport.logical_width = monitor->viewport.logical_width; @@ -2398,11 +2399,9 @@ namespace platf { it->second.monitor_index = count; } - kms::env_width = std::max(kms::env_width, (int) (crtc->x + crtc->width)); - kms::env_height = std::max(kms::env_height, (int) (crtc->y + crtc->height)); - kms::print(plane.get(), fb.get(), crtc.get()); - display_names.emplace_back(std::format("{}-{}", drmModeGetConnectorTypeName(it->second.type), it->second.index)); + it->second.viewport.output_name = std::format("{}-{}", drmModeGetConnectorTypeName(it->second.type), it->second.index); + display_names.emplace_back(it->second.viewport.output_name); count++; } @@ -2416,28 +2415,14 @@ namespace platf { correlate_to_wayland(cds); } - // Deduce the full virtual desktop size - kms::env_width = 0; - kms::env_height = 0; - - kms::env_logical_width = 0; - kms::env_logical_height = 0; - - for (auto &card_descriptor : cds) { - for (auto &[_, monitor_descriptor] : card_descriptor.crtc_to_monitor) { - BOOST_LOG(debug) << "Monitor description"sv; - BOOST_LOG(debug) << "Resolution: "sv << monitor_descriptor.viewport.width << 'x' << monitor_descriptor.viewport.height; - BOOST_LOG(debug) << "Offset: "sv << monitor_descriptor.viewport.offset_x << 'x' << monitor_descriptor.viewport.offset_y; - - kms::env_width = std::max(kms::env_width, monitor_descriptor.viewport.offset_x + monitor_descriptor.viewport.width); - kms::env_height = std::max(kms::env_height, monitor_descriptor.viewport.offset_y + monitor_descriptor.viewport.height); - - kms::env_logical_height = std::max(kms::env_logical_height, monitor_descriptor.viewport.offset_y + monitor_descriptor.viewport.logical_height); - kms::env_logical_width = std::max(kms::env_logical_width, monitor_descriptor.viewport.offset_x + monitor_descriptor.viewport.logical_width); + platf::desktop_geometry_t desktop; + for (const auto &card_descriptor : cds) { + for (const auto &[_, monitor_descriptor] : card_descriptor.crtc_to_monitor) { + desktop.add(monitor_descriptor.viewport); } } - - BOOST_LOG(debug) << "Desktop resolution: "sv << kms::env_width << 'x' << kms::env_height; + kms::env_bounds = desktop.bounds(); + BOOST_LOG(debug) << "Desktop resolution: "sv << kms::env_bounds.width << 'x' << kms::env_bounds.height; kms::card_descriptors = std::move(cds); diff --git a/src/platform/linux/kwingrab.cpp b/src/platform/linux/kwingrab.cpp index 0fcfb507d10..d0f3cbd0786 100644 --- a/src/platform/linux/kwingrab.cpp +++ b/src/platform/linux/kwingrab.cpp @@ -689,6 +689,7 @@ namespace kwin { out_pipewire_node = screencast->out_node_id; out_pipewire_objectserial = screencast->out_objectserial; // Set/update basic stream parameters on display_t + this->output_name = screencast->out_params->name; this->offset_x = screencast->out_params->pos_x; this->offset_y = screencast->out_params->pos_y; this->width = screencast->out_params->width; diff --git a/src/platform/linux/pipewire.cpp b/src/platform/linux/pipewire.cpp index 0715f971a54..69632cb245d 100644 --- a/src/platform/linux/pipewire.cpp +++ b/src/platform/linux/pipewire.cpp @@ -16,6 +16,7 @@ // local includes #include "cuda.h" +#include "display_geometry.h" #include "graphics.h" #include "src/main.h" #include "src/platform/common.h" @@ -1010,42 +1011,25 @@ namespace pipewire { virtual int configure_stream(const std::string &display_name, int &out_pipewire_fd, uint32_t &out_pipewire_node, uint64_t &out_pipewire_objectserial) = 0; /** - * @brief Verify and update display parameters for logical dimensions, desktop dimensions and logical desktop dimensions (default is adapted from wlgrab) + * @brief Resolve the streamed connector and full desktop bounds for absolute input. */ virtual void verify_and_update_display_parameters() { - // Query outputs directly using wayland wl::monitors() - if (logical_height <= 0 || logical_width <= 0 || env_logical_height <= 0 || env_logical_width <= 0 || env_height <= 0 || env_width <= 0) { - int desktop_width = 0; - int desktop_height = 0; - int desktop_logical_width = 0; - int desktop_logical_height = 0; - for (const auto &monitor : wl::monitors()) { - BOOST_LOG(debug) << "[pipewire] Found output: '"sv << monitor->name << "' offset: "sv << monitor->viewport.offset_x << 'x' << monitor->viewport.offset_y << " resolution: "sv << monitor->viewport.width << 'x' << monitor->viewport.height << " logical resolution: "sv << monitor->viewport.logical_width << 'x' << monitor->viewport.logical_height; - // If logical_width and logical_height are not valid try to update them to correct values by matching to monitor - // position/dimension or position/logical dimensions here since we're iterating for maximum environment size anyway - if ((logical_width <= 0 || logical_height <= 0) && monitor->viewport.offset_x == offset_x && monitor->viewport.offset_y == offset_y && ((monitor->viewport.width == width && monitor->viewport.height == height) || (monitor->viewport.logical_width == width && monitor->viewport.logical_height == height))) { - this->logical_width = monitor->viewport.logical_width; - this->logical_height = monitor->viewport.logical_height; - BOOST_LOG(debug) << "[pipewire] Set logical resolution: "sv << logical_width << 'x' << logical_height; + platf::desktop_geometry_t desktop; + for (const auto &monitor : wl::monitors()) { + desktop.add(monitor->viewport); + if (monitor->viewport.offset_x == offset_x && monitor->viewport.offset_y == offset_y && ((monitor->viewport.width == width && monitor->viewport.height == height) || (monitor->viewport.logical_width == width && monitor->viewport.logical_height == height))) { + if (logical_width <= 0 || logical_height <= 0) { + logical_width = monitor->viewport.logical_width; + logical_height = monitor->viewport.logical_height; + } + if (output_name.empty()) { + output_name = monitor->name; } - // Update desktop dimensions to setup maximum environment size over all screens - desktop_width = std::max(desktop_width, monitor->viewport.offset_x + monitor->viewport.width); - desktop_height = std::max(desktop_height, monitor->viewport.offset_y + monitor->viewport.height); - // Update desktop logical dimensions to setup maximum logical environment size over all screens - desktop_logical_width = std::max(desktop_logical_width, monitor->viewport.offset_x + monitor->viewport.logical_width); - desktop_logical_height = std::max(desktop_logical_height, monitor->viewport.offset_y + monitor->viewport.logical_height); - } - if (env_height <= 0 || env_width <= 0) { - this->env_width = desktop_width; - this->env_height = desktop_height; - BOOST_LOG(debug) << "[pipewire] Set desktop resolution: "sv << env_width << 'x' << env_height; - } - if (env_logical_height <= 0 || env_logical_width <= 0) { - this->env_logical_width = desktop_logical_width; - this->env_logical_height = desktop_logical_height; - BOOST_LOG(debug) << "[pipewire] Set desktop logical resolution: "sv << env_logical_width << 'x' << env_logical_height; } } + if (desktop.bounds().width > 0 && desktop.bounds().height > 0) { + desktop.apply(*this); + } } /** diff --git a/src/platform/linux/portalgrab.cpp b/src/platform/linux/portalgrab.cpp index 6cbc21b9dd2..49ece2b7f11 100644 --- a/src/platform/linux/portalgrab.cpp +++ b/src/platform/linux/portalgrab.cpp @@ -1042,6 +1042,7 @@ namespace portal { out_pipewire_node = stream.pipewire_node; out_pipewire_object_serial = stream.pipewire_object_serial; // Set/update basic stream parameters on display_t + this->output_name = stream.monitor_name; this->offset_x = stream.pos_x; this->offset_y = stream.pos_y; this->width = stream.width; diff --git a/src/platform/linux/wlgrab.cpp b/src/platform/linux/wlgrab.cpp index f6fb66b205e..be23ec493be 100644 --- a/src/platform/linux/wlgrab.cpp +++ b/src/platform/linux/wlgrab.cpp @@ -21,6 +21,7 @@ // local includes #include "cuda.h" +#include "display_geometry.h" #include "src/logging.h" #include "src/platform/common.h" #include "src/video.h" @@ -31,8 +32,6 @@ using namespace std::literals; namespace wl { - static int env_width; - static int env_height; static bool force_ram_capture = false; // NOSONAR(cpp:S5421): Retains SHM selection across VRAM-to-RAM capture backend recreation. /** @@ -422,22 +421,15 @@ namespace wl { width = monitor->viewport.width; height = monitor->viewport.height; - this->env_width = ::wl::env_width; - this->env_height = ::wl::env_height; - + this->output_name = monitor->name; this->logical_width = monitor->viewport.logical_width; this->logical_height = monitor->viewport.logical_height; - int desktop_logical_width = 0; - int desktop_logical_height = 0; - for (auto &monitor_entry : interface.monitors) { - auto output_monitor = monitor_entry.get(); - desktop_logical_width = std::max(desktop_logical_width, output_monitor->viewport.offset_x + output_monitor->viewport.logical_width); - desktop_logical_height = std::max(desktop_logical_height, output_monitor->viewport.offset_y + output_monitor->viewport.logical_height); + platf::desktop_geometry_t desktop; + for (const auto &monitor_entry : interface.monitors) { + desktop.add(monitor_entry->viewport); } - - this->env_logical_width = desktop_logical_width; - this->env_logical_height = desktop_logical_height; + desktop.apply(*this); BOOST_LOG(info) << "[wlgrab] Selected monitor ["sv << monitor->description << "] for streaming"sv; BOOST_LOG(debug) << "[wlgrab] Offset: "sv << offset_x << 'x' << offset_y; @@ -953,9 +945,6 @@ namespace platf { return {}; } - wl::env_width = 0; - wl::env_height = 0; - for (auto &monitor : interface.monitors) { monitor->listen(interface.output_manager); } @@ -967,9 +956,6 @@ namespace platf { for (int x = 0; x < interface.monitors.size(); ++x) { auto monitor = interface.monitors[x].get(); - wl::env_width = std::max(wl::env_width, monitor->viewport.offset_x + monitor->viewport.width); - wl::env_height = std::max(wl::env_height, monitor->viewport.offset_y + monitor->viewport.height); - BOOST_LOG(info) << "[wlgrab] Monitor " << x << " is "sv << monitor->name << ": "sv << monitor->description; display_names.emplace_back(monitor->name.empty() ? std::to_string(x) : monitor->name); diff --git a/src/platform/virtualhid_input.cpp b/src/platform/virtualhid_input.cpp index 7e255df5231..5eeb4647f92 100644 --- a/src/platform/virtualhid_input.cpp +++ b/src/platform/virtualhid_input.cpp @@ -367,6 +367,7 @@ namespace platf::virtualhid { .offset_y = touch_port.offset_y, .width = touch_port.width, .height = touch_port.height, + .output_name = touch_port.output_name, }; } diff --git a/src/video.cpp b/src/video.cpp index 13ece17213d..e428d1f89c0 100644 --- a/src/video.cpp +++ b/src/video.cpp @@ -2516,7 +2516,7 @@ namespace video { } /** - * @brief Create a port object or message. + * @brief Describe the streamed display, desktop bounds, and client letterboxing for absolute input. * * @param display Display object or identifier associated with the operation. * @param config Configuration values to apply. @@ -2559,6 +2559,7 @@ namespace video { display->logical_height, display->env_offset_x, display->env_offset_y, + display->output_name, }, display->env_width, display->env_height, diff --git a/src/video.h b/src/video.h index 2092fcacf0c..f40040c9414 100644 --- a/src/video.h +++ b/src/video.h @@ -45,6 +45,15 @@ namespace video { int enableIntraRefresh; ///< Intra refresh setting: 0 = disabled, 1 = enabled. }; + /** + * @brief Describe the streamed display, desktop bounds, and client letterboxing for absolute input. + * + * @param display Capture display supplying native output identity and geometry. + * @param config Client stream dimensions. + * @return Touch port forwarded from the video pipeline to input handling. + */ + input::touch_port_t make_port(platf::display_t *display, const config_t &config); + namespace amf { /** diff --git a/tests/unit/platform/linux/test_display_geometry.cpp b/tests/unit/platform/linux/test_display_geometry.cpp new file mode 100644 index 00000000000..202f5d7a3e2 --- /dev/null +++ b/tests/unit/platform/linux/test_display_geometry.cpp @@ -0,0 +1,140 @@ +/** + * @file tests/unit/platform/linux/test_display_geometry.cpp + * @brief Verify Linux input desktop bounds and streamed output metadata. + */ + +// test includes +#include "../../../tests_common.h" + +// standard includes +#include +#include + +// local includes +#include "src/platform/linux/display_geometry.h" +#include "src/video.h" + +namespace { + /** + * @brief Geometry-only capture display, without installed graphics devices. + */ + class geometry_display_t: public platf::display_t { + public: + /** + * @brief Complete without capturing frames. + * @return Successful capture status. + */ + platf::capture_e capture(const push_captured_image_cb_t &, const pull_free_image_cb_t &, bool *) override { + return platf::capture_e::ok; + } + + /** + * @brief Return no image for a geometry-only display. + * @return Null image. + */ + std::shared_ptr alloc_img() override { + return nullptr; + } + + /** + * @brief Complete without populating an image. + * @return Success. + */ + int dummy_img(platf::img_t *) override { + return 0; + } + }; +} // namespace + +TEST(LinuxDisplayGeometryTest, EmptyAndInvalidMonitorsDoNotCreateAnInputDesktop) { + platf::desktop_geometry_t desktop; + EXPECT_EQ(desktop.bounds().width, 0); + EXPECT_EQ(desktop.bounds().height, 0); + desktop.add({.width = 0, .height = 1080}); + desktop.add({.width = 1920, .height = -1}); + EXPECT_EQ(desktop.bounds().width, 0); + EXPECT_EQ(desktop.bounds().logical_width, 0); +} + +TEST(LinuxDisplayGeometryTest, PreservesTheFirstMonitorOriginAndFallsBackToPhysicalDimensions) { + platf::desktop_geometry_t desktop; + desktop.add({.offset_x = 800, .offset_y = 300, .width = 1920, .height = 1080, .output_name = "DP-1"}); + const auto &bounds = desktop.bounds(); + EXPECT_EQ(bounds.offset_x, 800); + EXPECT_EQ(bounds.offset_y, 300); + EXPECT_EQ(bounds.width, 1920); + EXPECT_EQ(bounds.height, 1080); + EXPECT_EQ(bounds.logical_width, 1920); + EXPECT_EQ(bounds.logical_height, 1080); + EXPECT_TRUE(bounds.output_name.empty()); + desktop.add({.width = -1, .height = 1080}); + EXPECT_EQ(bounds.width, 1920); +} + +TEST(LinuxDisplayGeometryTest, IncludesMonitorsLeftOfAndAboveTheOriginInEitherOrder) { + std::array monitors { + platf::touch_port_t {.offset_x = -1920, .offset_y = -1080, .width = 1920, .height = 1080, .logical_width = 1920, .logical_height = 1080}, + platf::touch_port_t {.offset_x = 0, .offset_y = 0, .width = 1920, .height = 1080, .logical_width = 1920, .logical_height = 1080}, + }; + for (int order = 0; order < 2; ++order) { + platf::desktop_geometry_t desktop; + for (const auto &monitor : monitors) { + desktop.add(monitor); + } + const auto &bounds = desktop.bounds(); + EXPECT_EQ(bounds.offset_x, -1920); + EXPECT_EQ(bounds.offset_y, -1080); + EXPECT_EQ(bounds.width, 3840); + EXPECT_EQ(bounds.height, 2160); + EXPECT_EQ(bounds.logical_width, 3840); + EXPECT_EQ(bounds.logical_height, 2160); + std::ranges::reverse(monitors); + } +} + +TEST(LinuxDisplayGeometryTest, UsesLogicalSizesForMixedScalingAndPreservesGaps) { + platf::desktop_geometry_t desktop; + desktop.add({.offset_x = -1920, .offset_y = 0, .width = 3840, .height = 2160, .logical_width = 1920, .logical_height = 1080}); + desktop.add({.offset_x = 300, .offset_y = -720, .width = 2560, .height = 1440, .logical_width = 1280, .logical_height = 720}); + EXPECT_EQ(desktop.bounds().offset_x, -1920); + EXPECT_EQ(desktop.bounds().offset_y, -720); + EXPECT_EQ(desktop.bounds().logical_width, 3500); + EXPECT_EQ(desktop.bounds().logical_height, 1800); + desktop.add({.offset_x = 1580, .offset_y = 0, .width = 640, .height = 480, .logical_width = 640, .logical_height = 0}); + EXPECT_EQ(desktop.bounds().logical_width, 4140); +} + +TEST(LinuxDisplayGeometryTest, PublishesDesktopOriginAndStreamedConnectorThroughTheTouchPort) { + geometry_display_t display; + display.width = 3840; + display.height = 2160; + display.logical_width = 1920; + display.logical_height = 1080; + display.offset_x = -1920; + display.offset_y = -1080; + display.output_name = "DP-1"; + platf::desktop_geometry_t desktop; + desktop.add({.offset_x = -1920, .offset_y = -1080, .width = 3840, .height = 2160, .logical_width = 1920, .logical_height = 1080}); + desktop.add({.width = 1920, .height = 1080, .logical_width = 1920, .logical_height = 1080}); + desktop.apply(display); + EXPECT_EQ(display.env_offset_x, -1920); + EXPECT_EQ(display.env_offset_y, -1080); + EXPECT_EQ(display.env_width, 3840); + EXPECT_EQ(display.env_height, 2160); + EXPECT_EQ(display.env_logical_width, 3840); + EXPECT_EQ(display.env_logical_height, 2160); + video::config_t config {}; + config.width = 2560; + config.height = 1080; + const auto port = video::make_port(&display, config); + EXPECT_EQ(port.output_name, "DP-1"); + EXPECT_EQ(port.offset_x, -1920); + EXPECT_EQ(port.offset_y, -1080); + EXPECT_EQ(port.env_offset_x, -1920); + EXPECT_EQ(port.env_offset_y, -1080); + EXPECT_EQ(port.env_logical_width, 3840); + EXPECT_EQ(port.env_logical_height, 2160); + EXPECT_FLOAT_EQ(port.scalar_inv, 2.0F); + EXPECT_FLOAT_EQ(port.scalar_tpcoords, 2.0F); + EXPECT_FLOAT_EQ(port.client_offsetX, 320.0F); +} diff --git a/tests/unit/platform/test_virtualhid_input.cpp b/tests/unit/platform/test_virtualhid_input.cpp index d143a7e926e..b1ec943563f 100644 --- a/tests/unit/platform/test_virtualhid_input.cpp +++ b/tests/unit/platform/test_virtualhid_input.cpp @@ -18,15 +18,16 @@ #include #include +#if defined(_WIN32) || defined(__APPLE__) + // lib includes + #include +#endif + // local includes #include "src/config.h" #include "src/globals.h" #include "src/platform/virtualhid_input.h" -#if defined(_WIN32) || defined(__APPLE__) - #include -#endif - using namespace std::chrono_literals; using namespace std::literals; @@ -885,7 +886,7 @@ TEST_F(VirtualHidDeviceTest, RetargetsRelativeMouseWhenStreamedViewportChanges) } TEST_F(VirtualHidDeviceTest, TranslatesTouchscreenLifecycleAndGeometry) { - const platf::touch_port_t viewport {10, 20, 800, 600, 400, 300}; + const platf::touch_port_t viewport {10, 20, 800, 600, 400, 300, 0, 0, "DP-1"}; platf::touch_input_t touch {LI_TOUCH_EVENT_DOWN, 100, 7, -0.1F, 1.1F, 1.2F, 15.0F, 10.0F}; platf::virtualhid::touch_update(*client(), viewport, touch); auto contact = client()->touch->last_submitted_contact(); @@ -896,6 +897,7 @@ TEST_F(VirtualHidDeviceTest, TranslatesTouchscreenLifecycleAndGeometry) { EXPECT_EQ(contact.orientation, 80); EXPECT_TRUE(contact.touching); EXPECT_EQ(contact.viewport.offset_x, 10); + EXPECT_EQ(contact.viewport.output_name, "DP-1"); EXPECT_EQ(contact.viewport.offset_y, 20); EXPECT_EQ(contact.viewport.width, 800); EXPECT_EQ(contact.viewport.height, 600); @@ -953,7 +955,7 @@ TEST_F(VirtualHidDeviceTest, TranslatesTouchscreenLifecycleAndGeometry) { } TEST_F(VirtualHidDeviceTest, TranslatesPenButtonsToolsAndTransitions) { - const platf::touch_port_t viewport {1, 2, 1000, 500, 1000, 500}; + const platf::touch_port_t viewport {1, 2, 1000, 500, 1000, 500, 0, 0, "HDMI-A-1"}; platf::pen_input_t pen { LI_TOUCH_EVENT_DOWN, LI_TOOL_TYPE_PEN, @@ -978,6 +980,7 @@ TEST_F(VirtualHidDeviceTest, TranslatesPenButtonsToolsAndTransitions) { EXPECT_EQ(state.transition, lvh::PointerTransition::update); EXPECT_EQ(state.viewport.offset_x, 1); EXPECT_EQ(state.viewport.offset_y, 2); + EXPECT_EQ(state.viewport.output_name, "HDMI-A-1"); EXPECT_TRUE(client()->pressed_pen_buttons.contains(lvh::PenButton::primary)); EXPECT_TRUE(client()->pressed_pen_buttons.contains(lvh::PenButton::secondary)); diff --git a/tests/unit/test_input.cpp b/tests/unit/test_input.cpp index 4913dd7b198..8888d0e0660 100644 --- a/tests/unit/test_input.cpp +++ b/tests/unit/test_input.cpp @@ -9,6 +9,7 @@ // standard includes #include #include +#include #include #include #include @@ -19,7 +20,7 @@ #include #include -// moonlight-common-c includes +// lib includes extern "C" { #include } @@ -642,3 +643,115 @@ TEST_F(InputGamepadSessionTest, RetargetsRelativeMouseFromLatestStreamGeometry) EXPECT_EQ(mouse_event.y, 9); EXPECT_EQ(context().mouse_viewport.offset_x, 0); } + +TEST_F(InputGamepadSessionTest, RetargetsNativeTouchAndPenWithoutApplyingTheMonitorOffsetTwice) { + auto session_mail = std::make_shared(); + auto stream_input = input::alloc(session_mail, "absolute-input-client"); + ASSERT_NE(stream_input, nullptr); + auto &client = platf::virtualhid::get_client_context(input::testing::client_context(stream_input)); + ASSERT_NE(client.touch, nullptr); + ASSERT_NE(client.pen, nullptr); + input::testing::set_input_task_sink([](std::shared_ptr) { + }); + input::touch_port_t viewport { + {.offset_x = -1920, .offset_y = -1080, .width = 2560, .height = 1080, .logical_width = 1920, .logical_height = 1080, .env_offset_x = -1920, .env_offset_y = -1080, .output_name = "DP-1"}, + 7680, + 4320, + 320.0F, + 0.0F, + 2.0F, + 2.0F, + 3840, + 2160, + }; + const auto write_float = [](netfloat &target, float value) { + const auto bits = util::endian::little(std::bit_cast(value)); + std::memcpy(target, &bits, sizeof(bits)); + }; + const auto send_packet = [&](Packet &packet, std::uint32_t magic) { + packet.header.size = util::endian::big(sizeof(packet) - sizeof(packet.header.size)); + packet.header.magic = util::endian::little(magic); + std::vector bytes(sizeof(packet)); + std::memcpy(bytes.data(), &packet, sizeof(packet)); + input::passthrough(stream_input, std::move(bytes)); + input::testing::process_queued_messages(stream_input); + }; + SS_TOUCH_PACKET touch {}; + touch.eventType = LI_TOUCH_EVENT_DOWN; + touch.pointerId = util::endian::little(42); + write_float(touch.x, 0.5F); + write_float(touch.y, 0.25F); + write_float(touch.pressureOrDistance, 0.75F); + SS_PEN_PACKET pen {}; + pen.eventType = LI_TOUCH_EVENT_DOWN; + pen.toolType = LI_TOOL_TYPE_PEN; + write_float(pen.x, 0.5F); + write_float(pen.y, 0.25F); + write_float(pen.pressureOrDistance, 0.75F); + for (const auto *name : {"DP-1", "HDMI-A-1"}) { + viewport.output_name = name; + session_mail->event(mail::touch_port)->raise(viewport); + send_packet(touch, SS_TOUCH_MAGIC); + const auto contact = client.touch->last_submitted_contact(); + EXPECT_EQ(contact.viewport.output_name, name); + EXPECT_EQ(contact.viewport.offset_x, viewport.offset_x); + EXPECT_EQ(contact.viewport.offset_y, viewport.offset_y); + EXPECT_EQ(contact.viewport.width, 1920); + EXPECT_EQ(contact.viewport.height, 1080); + EXPECT_FLOAT_EQ(contact.x, 0.5F); + EXPECT_FLOAT_EQ(contact.y, 0.25F); + send_packet(pen, SS_PEN_MAGIC); + const auto tool = client.pen->last_submitted_tool(); + EXPECT_EQ(tool.viewport.output_name, name); + EXPECT_EQ(tool.viewport.offset_x, viewport.offset_x); + EXPECT_EQ(tool.viewport.offset_y, viewport.offset_y); + EXPECT_FLOAT_EQ(tool.x, 0.5F); + EXPECT_FLOAT_EQ(tool.y, 0.25F); + viewport.offset_x = 0; + viewport.offset_y = 0; + touch.eventType = LI_TOUCH_EVENT_MOVE; + pen.eventType = LI_TOUCH_EVENT_MOVE; + } + // A capture backend can report monitor scaling before the desktop bounds are available. + viewport.scalar_tpcoords = 1.0F; + viewport.env_logical_width = 0; + viewport.env_logical_height = 0; + session_mail->event(mail::touch_port)->raise(viewport); + send_packet(touch, SS_TOUCH_MAGIC); + send_packet(pen, SS_PEN_MAGIC); + EXPECT_FLOAT_EQ(client.touch->last_submitted_contact().x, 0.5F); + EXPECT_FLOAT_EQ(client.touch->last_submitted_contact().y, 0.25F); + EXPECT_FLOAT_EQ(client.pen->last_submitted_tool().x, 0.5F); + EXPECT_FLOAT_EQ(client.pen->last_submitted_tool().y, 0.25F); + viewport.scalar_tpcoords = 2.0F; + viewport.logical_width = 0; + viewport.logical_height = 0; + session_mail->event(mail::touch_port)->raise(viewport); + send_packet(touch, SS_TOUCH_MAGIC); + send_packet(pen, SS_PEN_MAGIC); + EXPECT_EQ(client.touch->last_submitted_contact().viewport.width, 1920); + EXPECT_EQ(client.pen->last_submitted_tool().viewport.width, 1920); + EXPECT_FLOAT_EQ(client.touch->last_submitted_contact().x, 0.5F); + EXPECT_FLOAT_EQ(client.pen->last_submitted_tool().x, 0.5F); + viewport.logical_width = 1920; + session_mail->event(mail::touch_port)->raise(viewport); + send_packet(touch, SS_TOUCH_MAGIC); + EXPECT_EQ(client.touch->last_submitted_contact().viewport.width, 1920); + + const auto touch_count = client.touch->submit_count(); + const auto pen_count = client.pen->submit_count(); + viewport.logical_width = 0; + viewport.client_offsetX = viewport.width / 2.0F; + session_mail->event(mail::touch_port)->raise(viewport); + send_packet(touch, SS_TOUCH_MAGIC); + send_packet(pen, SS_PEN_MAGIC); + EXPECT_EQ(client.touch->submit_count(), touch_count); + EXPECT_EQ(client.pen->submit_count(), pen_count); + viewport.client_offsetX = 0; + viewport.client_offsetY = viewport.height / 2.0F; + session_mail->event(mail::touch_port)->raise(viewport); + send_packet(touch, SS_TOUCH_MAGIC); + send_packet(pen, SS_PEN_MAGIC); + EXPECT_EQ(client.touch->submit_count(), touch_count); + EXPECT_EQ(client.pen->submit_count(), pen_count); +} diff --git a/third-party/libvirtualhid b/third-party/libvirtualhid index 9f4cac3fff7..12aef072418 160000 --- a/third-party/libvirtualhid +++ b/third-party/libvirtualhid @@ -1 +1 @@ -Subproject commit 9f4cac3fff75430c028ef8bb8ddc4459968949f8 +Subproject commit 12aef07241813a1f933e52199371e50bffc64256 From 5e0aa0a133a60e3c3ab3e33fdcff55334332f9e8 Mon Sep 17 00:00:00 2001 From: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:45:00 -0500 Subject: [PATCH 2/4] fix(docs): avoid duplicate input port parameter documentation Keep make_port documentation on its header declaration so strict Doxygen builds do not merge duplicate parameter blocks. Refresh the libvirtualhid pin to its preceding Sonar cleanup. --- src/video.cpp | 7 ------- third-party/libvirtualhid | 2 +- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/src/video.cpp b/src/video.cpp index e428d1f89c0..5c7d324c53c 100644 --- a/src/video.cpp +++ b/src/video.cpp @@ -2515,13 +2515,6 @@ namespace video { } } - /** - * @brief Describe the streamed display, desktop bounds, and client letterboxing for absolute input. - * - * @param display Display object or identifier associated with the operation. - * @param config Configuration values to apply. - * @return Constructed port object. - */ input::touch_port_t make_port(platf::display_t *display, const config_t &config) { float wd = display->width; float hd = display->height; diff --git a/third-party/libvirtualhid b/third-party/libvirtualhid index 12aef072418..5757d99f70f 160000 --- a/third-party/libvirtualhid +++ b/third-party/libvirtualhid @@ -1 +1 @@ -Subproject commit 12aef07241813a1f933e52199371e50bffc64256 +Subproject commit 5757d99f70f4bce2bf244154711e3a3b0c234c19 From 89fd2b0b8ef562cf3b4c6ec52e87967fe43269d9 Mon Sep 17 00:00:00 2001 From: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:57:00 -0500 Subject: [PATCH 3/4] refactor(linux): address input mapping PR Sonar findings Use scoped initialization and const framebuffer metadata in KMS helpers, make the capture callback generic, and force expression parsing for the test dispatch call. Pin libvirtualhid's final fixture and complexity cleanup. --- src/platform/linux/kmsgrab.cpp | 24 +++++++++++------------- tests/unit/test_input.cpp | 2 +- third-party/libvirtualhid | 2 +- 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/src/platform/linux/kmsgrab.cpp b/src/platform/linux/kmsgrab.cpp index bd5011645bf..a2f6eb50558 100644 --- a/src/platform/linux/kmsgrab.cpp +++ b/src/platform/linux/kmsgrab.cpp @@ -749,17 +749,15 @@ namespace platf { /** * @brief Return framebuffer metadata for the current CRTC. * - * @param plane DRM or EGL plane index being described. + * @param plane DRM plane whose framebuffer metadata should be read. * @return Framebuffer metadata wrapper, or nullptr when the framebuffer cannot be read. */ - fb_t fb(plane_t::pointer plane) { - auto fb2 = platf::kms::privileged_drm_worker::drmModeGetFB2_privileged(fd.el, plane->fb_id); - if (fb2) { + fb_t fb(const drmModePlane *plane) { + if (auto fb2 = platf::kms::privileged_drm_worker::drmModeGetFB2_privileged(fd.el, plane->fb_id); fb2) { return std::make_unique(fd.el, fb2); } - auto fb = platf::kms::privileged_drm_worker::drmModeGetFB_privileged(fd.el, plane->fb_id); - if (fb) { + if (auto fb = platf::kms::privileged_drm_worker::drmModeGetFB_privileged(fd.el, plane->fb_id); fb) { return std::make_unique(fd.el, fb); } @@ -934,8 +932,7 @@ namespace platf { file_t handleFD(std::uint32_t handle) { file_t fb_fd; - auto status = drmPrimeHandleToFD(fd.el, handle, 0 /* flags */, &fb_fd.el); - if (status) { + if (const auto status = drmPrimeHandleToFD(fd.el, handle, 0 /* flags */, &fb_fd.el); status) { return {}; } @@ -1069,7 +1066,7 @@ namespace platf { /** * @brief Write a debug log representation of the input packet. * - * @param plane DRM or EGL plane index being described. + * @param plane DRM plane whose framebuffer metadata should be read. * @param fb Framebuffer object to bind or update. * @param crtc DRM CRTC identifier to map. */ @@ -2119,12 +2116,14 @@ namespace platf { /** * @brief Capture a display frame into the provided image object. * - * @param pull_free_image_cb Pull free image cb. + * @tparam PullFreeImage Callback type used to retrieve a free capture image. + * @param pull_free_image_cb Callback retrieving a free capture image. * @param img_out Captured KMS image descriptor returned to the streaming pipeline. * @param cursor Cursor image or visibility state to composite. * @return Capture status reported to the streaming pipeline. */ - capture_e snapshot(const pull_free_image_cb_t &pull_free_image_cb, std::shared_ptr &img_out, std::chrono::milliseconds /* timeout */, bool cursor) { + template + capture_e snapshot(const PullFreeImage &pull_free_image_cb, std::shared_ptr &img_out, std::chrono::milliseconds /* timeout */, bool cursor) { std::array fb_fd; if (!pull_free_image_cb(img_out)) { @@ -2133,8 +2132,7 @@ namespace platf { auto img = (egl::img_descriptor_t *) img_out.get(); img->reset(); - auto status = refresh(fb_fd.data(), &img->sd, img->frame_timestamp); - if (status != capture_e::ok) { + if (const auto status = refresh(fb_fd.data(), &img->sd, img->frame_timestamp); status != capture_e::ok) { return status; } diff --git a/tests/unit/test_input.cpp b/tests/unit/test_input.cpp index 8888d0e0660..c7a170c9b4f 100644 --- a/tests/unit/test_input.cpp +++ b/tests/unit/test_input.cpp @@ -674,7 +674,7 @@ TEST_F(InputGamepadSessionTest, RetargetsNativeTouchAndPenWithoutApplyingTheMoni std::vector bytes(sizeof(packet)); std::memcpy(bytes.data(), &packet, sizeof(packet)); input::passthrough(stream_input, std::move(bytes)); - input::testing::process_queued_messages(stream_input); + static_cast(input::testing::process_queued_messages(stream_input)); }; SS_TOUCH_PACKET touch {}; touch.eventType = LI_TOUCH_EVENT_DOWN; diff --git a/third-party/libvirtualhid b/third-party/libvirtualhid index 5757d99f70f..13d71ca97aa 160000 --- a/third-party/libvirtualhid +++ b/third-party/libvirtualhid @@ -1 +1 @@ -Subproject commit 5757d99f70f4bce2bf244154711e3a3b0c234c19 +Subproject commit 13d71ca97aa6f9a261a11e9cbe774a2c470e5023 From 1bb695d4c73b85d332867d3595eb0254f7d3921d Mon Sep 17 00:00:00 2001 From: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:04:50 -0500 Subject: [PATCH 4/4] build(deps): include output mapping probe retry fix --- third-party/libvirtualhid | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/third-party/libvirtualhid b/third-party/libvirtualhid index 13d71ca97aa..86da2c2d32a 160000 --- a/third-party/libvirtualhid +++ b/third-party/libvirtualhid @@ -1 +1 @@ -Subproject commit 13d71ca97aa6f9a261a11e9cbe774a2c470e5023 +Subproject commit 86da2c2d32af6a36146f2ad406578bca5a23643c