From 4fd0c7a4692da8fd5d8c392b83dcb2fa6db2a9fa Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:51:52 -0400 Subject: [PATCH 1/2] fix(input): target configured mouse viewport Replace macOS main-display hardcoding with caller-provided virtual desktop and target viewport geometry. Apply the same mapping contract to Windows and Linux/FreeBSD, including negative origins and the XTest fallback. Expose exception-safe runtime diagnostics for host log integration, document consumer setup, and add runtime and backend tests. --- docs/streaming-host-integration.md | 5 + docs/usage.md | 42 +++++ src/core/runtime.cpp | 166 +++++++++++++++++- src/include/libvirtualhid/types.hpp | 89 +++++++--- src/platform/linux/uhid_backend.cpp | 110 ++++++++++-- src/platform/macos/macos_backend.cpp | 69 ++++---- src/platform/windows/windows_backend.cpp | 91 ++++++++-- .../fixtures/linux_backend_test_hooks.hpp | 32 ++++ .../fixtures/macos_backend_test_hooks.hpp | 18 ++ .../fixtures/windows_backend_test_hooks.hpp | 20 +++ tests/fixtures/linux_backend_test_hooks.cpp | 42 +++++ tests/fixtures/macos_backend_test_hooks.cpp | 10 ++ tests/fixtures/windows_backend_test_hooks.cpp | 18 ++ tests/unit/test_linux_backend.cpp | 28 +++ tests/unit/test_macos_backend.cpp | 14 ++ tests/unit/test_runtime.cpp | 84 +++++++++ tests/unit/test_windows_backend.cpp | 11 ++ 17 files changed, 753 insertions(+), 96 deletions(-) diff --git a/docs/streaming-host-integration.md b/docs/streaming-host-integration.md index bccaa8a..bcf812f 100644 --- a/docs/streaming-host-integration.md +++ b/docs/streaming-host-integration.md @@ -44,6 +44,11 @@ intended shape: the virtual controller before the first client input packet. - Forward output callbacks back to the physical client controller or feedback queue. +- Route `RuntimeOptions::log_callback` into the host logger so backend lifecycle, + failures, and debug-level input coordinates appear in the host log. +- When streaming one monitor from a multi-monitor desktop, provide both the full + virtual-desktop bounds and the selected monitor viewport in + `CreateMouseOptions` so absolute mouse input reaches the captured output. This keeps one public code path for Linux, Windows, and future platforms while still letting each backend report real capability limits. diff --git a/docs/usage.md b/docs/usage.md index 9ee760a..0abc56b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -171,6 +171,48 @@ The API centers on portable device concepts: - `BackendCapabilities`: reports runtime/backend limits such as virtual HID, output report, keyboard, mouse, XTest fallback, and installed-driver support. +## Diagnostics + +Hosts can route libvirtualhid diagnostics into their own logging system by +installing `RuntimeOptions::log_callback` before creating the runtime: + +```cpp +lvh::RuntimeOptions runtime_options; +runtime_options.backend = lvh::BackendKind::platform_default; +runtime_options.log_callback = [](lvh::LogLevel level, const std::string &message) { + host_log(level, message); +}; +auto runtime = lvh::Runtime::create(runtime_options); +``` + +The callback receives runtime and device lifecycle messages, operation failures, +and debug-level mouse coordinate diagnostics. It runs synchronously on the +calling thread. libvirtualhid discards callback exceptions so a consumer logger +cannot interrupt input delivery. + +## Absolute Mouse Viewports + +Absolute mouse coordinates can target one monitor inside a larger virtual +desktop. Supply both the desktop bounds and the selected viewport in native +desktop pixels when creating the mouse: + +```cpp +lvh::CreateMouseOptions mouse_options; +mouse_options.profile = lvh::profiles::mouse(); +mouse_options.desktop = {.offset_x = -1920, .offset_y = 0, .width = 3840, .height = 1080}; +mouse_options.viewport = {.offset_x = 0, .offset_y = 0, .width = 1920, .height = 1080}; +auto created = runtime->create_mouse(mouse_options); +``` + +`Mouse::move_absolute()` coordinates are scaled from their supplied source +dimensions into the target viewport, then normalized against the virtual +desktop where the platform input API requires it. This contract covers +CoreGraphics on macOS, `SendInput` on Windows, and the XTest or `uinput` path on +Linux and FreeBSD, including virtual desktops whose origin is negative. Leave +both viewport dimensions at zero to retain the platform-default pointer area +(the main display on macOS and the virtual desktop on other current backends). +A configured target viewport must be fully contained by its desktop. + ## Gamepad Example ```cpp diff --git a/src/core/runtime.cpp b/src/core/runtime.cpp index 0f39df1..429da7d 100644 --- a/src/core/runtime.cpp +++ b/src/core/runtime.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include // local includes @@ -19,6 +20,49 @@ namespace lvh::detail { + /** + * @brief Exception-safe dispatcher for a consumer diagnostic callback. + */ + class Logger { + public: + /** + * @brief Construct a dispatcher for an optional callback. + * + * @param callback Consumer diagnostic callback. + */ + explicit Logger(LogCallback callback): + callback_ {std::move(callback)} {} + + /** + * @brief Report whether a callback is installed. + * + * @return `true` when messages have a destination. + */ + bool enabled() const noexcept { + return static_cast(callback_); + } + + /** + * @brief Emit one diagnostic message without allowing consumer exceptions to escape. + * + * @param level Message severity. + * @param message Diagnostic text. + */ + void emit(LogLevel level, const std::string &message) const noexcept { + if (!callback_) { + return; + } + + try { + callback_(level, message); + } catch (...) { + } + } + + private: + LogCallback callback_; + }; + class SynchronizedState { public: template @@ -71,14 +115,21 @@ namespace lvh::detail { }; struct MouseDevice: SynchronizedState { - explicit MouseDevice(DeviceId device_id, CreateMouseOptions create_options, std::unique_ptr backend_mouse): + explicit MouseDevice( + DeviceId device_id, + CreateMouseOptions create_options, + std::unique_ptr backend_mouse, + std::shared_ptr device_logger + ): id {device_id}, options {std::move(create_options)}, - backend {std::move(backend_mouse)} {} + backend {std::move(backend_mouse)}, + logger {std::move(device_logger)} {} DeviceId id; CreateMouseOptions options; std::unique_ptr backend; + std::shared_ptr logger; bool open = true; MouseEvent last_event; std::size_t submitted_events = 0; @@ -141,11 +192,15 @@ namespace lvh::detail { class RuntimeState: public SynchronizedState { public: explicit RuntimeState(RuntimeOptions runtime_options): - options {runtime_options}, - backend {create_backend(runtime_options.backend)}, - caps {backend->capabilities()} {} + options {std::move(runtime_options)}, + logger {std::make_shared(options.log_callback)}, + backend {create_backend(options.backend)}, + caps {backend->capabilities()} { + logger->emit(LogLevel::info, "initialized " + caps.backend_name + " backend"); + } RuntimeOptions options; + std::shared_ptr logger; std::unique_ptr backend; BackendCapabilities caps; DeviceId next_device_id = 1; @@ -199,10 +254,92 @@ namespace lvh { if (options.profile.name.empty()) { return OperationStatus::failure(ErrorCode::invalid_argument, "device profile name must not be empty"); } + const auto valid_dimensions = [](const PointerViewport &viewport) { + return viewport.width >= 0 && viewport.height >= 0 && + ((viewport.width == 0 && viewport.height == 0) || (viewport.width > 0 && viewport.height > 0)); + }; + if (!valid_dimensions(options.desktop) || !valid_dimensions(options.viewport)) { + return OperationStatus::failure( + ErrorCode::invalid_argument, + "mouse viewport dimensions must both be positive or both be zero" + ); + } + const auto has_desktop = options.desktop.width > 0; + const auto has_viewport = options.viewport.width > 0; + if (has_desktop != has_viewport) { + return OperationStatus::failure( + ErrorCode::invalid_argument, + "mouse desktop and target viewport must be configured together" + ); + } + if (has_desktop) { + const auto desktop_right = static_cast(options.desktop.offset_x) + options.desktop.width; + const auto desktop_bottom = static_cast(options.desktop.offset_y) + options.desktop.height; + const auto viewport_right = static_cast(options.viewport.offset_x) + options.viewport.width; + const auto viewport_bottom = static_cast(options.viewport.offset_y) + options.viewport.height; + if (options.viewport.offset_x < options.desktop.offset_x || options.viewport.offset_y < options.desktop.offset_y || viewport_right > desktop_right || viewport_bottom > desktop_bottom) { + return OperationStatus::failure( + ErrorCode::invalid_argument, + "mouse target viewport must be contained by the virtual desktop" + ); + } + } return OperationStatus::success(); } + /** + * @brief Format a mouse event for the consumer diagnostic callback. + * + * @param id Runtime mouse identifier. + * @param event Mouse event being submitted. + * @param desktop Configured virtual desktop bounds. + * @param viewport Configured target viewport. + * @return Human-readable diagnostic message. + */ + std::string mouse_event_description( + DeviceId id, + const MouseEvent &event, + const PointerViewport &desktop, + const PointerViewport &viewport + ) { + std::ostringstream message; + message << "mouse " << id << ' '; + switch (event.kind) { + using enum MouseEventKind; + + case relative_motion: + message << "relative motion x=" << event.x << " y=" << event.y; + break; + case absolute_motion: + message << "absolute motion x="; + if (event.has_fractional_absolute_coordinates) { + message << event.absolute_x << " y=" << event.absolute_y; + } else { + message << event.x << " y=" << event.y; + } + message << " source=" << event.width << 'x' << event.height; + if (viewport.width > 0 && viewport.height > 0) { + message << " viewport=" << viewport.offset_x << ',' << viewport.offset_y << ' ' + << viewport.width << 'x' << viewport.height << " desktop=" << desktop.offset_x << ',' + << desktop.offset_y << ' ' << desktop.width << 'x' << desktop.height; + } else { + message << " viewport=platform-default"; + } + break; + case button: + message << "button " << static_cast(event.button) << (event.pressed ? " pressed" : " released"); + break; + case vertical_scroll: + message << "vertical scroll distance=" << event.high_resolution_scroll; + break; + case horizontal_scroll: + message << "horizontal scroll distance=" << event.high_resolution_scroll; + break; + } + return message.str(); + } + OperationStatus validate_touchscreen_options(const CreateTouchscreenOptions &options) { if (options.profile.device_type != DeviceType::touchscreen) { return OperationStatus::failure(ErrorCode::unsupported_profile, "device profile is not a touchscreen"); @@ -612,10 +749,18 @@ namespace lvh { OperationStatus Mouse::submit(const MouseEvent &event) { if (const auto validation = validate_mouse_event(event); !validation.ok()) { + device_->logger->emit(LogLevel::warning, "rejected mouse event: " + validation.message()); return validation; } - return with_device(device_, [&event](auto &device) { + if (device_->logger->enabled()) { + device_->logger->emit( + LogLevel::debug, + mouse_event_description(device_->id, event, device_->options.desktop, device_->options.viewport) + ); + } + + const auto status = with_device(device_, [&event](auto &device) { if (!device.open) { return OperationStatus::failure(ErrorCode::device_closed, "mouse is closed"); } @@ -630,6 +775,10 @@ namespace lvh { ++device.submitted_events; return OperationStatus::success(); }); + if (!status.ok()) { + device_->logger->emit(LogLevel::error, "mouse input failed: " + status.message()); + } + return status; } OperationStatus Mouse::move_relative(std::int32_t delta_x, std::int32_t delta_y) { @@ -1129,6 +1278,7 @@ namespace lvh { MouseCreationResult Runtime::create_mouse(const CreateMouseOptions &options) { if (const auto validation = validate_mouse_options(options); !validation.ok()) { + state_->logger->emit(LogLevel::warning, "rejected mouse creation: " + validation.message()); return {validation, nullptr}; } @@ -1138,15 +1288,17 @@ namespace lvh { auto backend_result = state_->backend->create_mouse(id, options); if (!backend_result) { + state_->logger->emit(LogLevel::error, "mouse creation failed: " + backend_result.status.message()); return {std::move(backend_result.status), nullptr}; } - auto device = std::make_shared(id, options, std::move(backend_result.mouse)); + auto device = std::make_shared(id, options, std::move(backend_result.mouse), state_->logger); state_->with_lock([this, &device]() { state_->mice.emplace_back(device); }); auto mouse = std::make_unique(detail::RuntimeConstructionToken {}, std::move(device)); + state_->logger->emit(LogLevel::info, "created mouse " + std::to_string(id)); return {OperationStatus::success(), std::move(mouse)}; } diff --git a/src/include/libvirtualhid/types.hpp b/src/include/libvirtualhid/types.hpp index 7f3bc1c..83d18c3 100644 --- a/src/include/libvirtualhid/types.hpp +++ b/src/include/libvirtualhid/types.hpp @@ -107,6 +107,24 @@ namespace lvh { platform_default, ///< Native backend for the current platform. }; + /** + * @brief Diagnostic message severity. + */ + enum class LogLevel : std::uint8_t { + debug, ///< Detailed diagnostic information. + info, ///< Normal runtime lifecycle information. + warning, ///< Recoverable problem or fallback. + error, ///< Operation failure. + }; + + /** + * @brief Consumer callback that receives libvirtualhid diagnostic messages. + * + * The callback is invoked synchronously from the thread performing the + * operation. Exceptions thrown by the callback are discarded. + */ + using LogCallback = std::function; + /** * @brief Runtime creation options. */ @@ -115,6 +133,11 @@ namespace lvh { * @brief Backend implementation requested by the caller. */ BackendKind backend = BackendKind::fake; + + /** + * @brief Optional callback for diagnostic messages. + */ + LogCallback log_callback; }; /** @@ -442,6 +465,31 @@ namespace lvh { std::string stable_id; }; + /** + * @brief Pixel viewport used by backends that need screen-local pointer coordinates. + */ + struct PointerViewport { + /** + * @brief Horizontal viewport offset in native desktop pixels. + */ + std::int32_t offset_x = 0; + + /** + * @brief Vertical viewport offset in native desktop pixels. + */ + std::int32_t offset_y = 0; + + /** + * @brief Viewport width in native desktop pixels, or `0` to use the platform default. + */ + std::int32_t width = 0; + + /** + * @brief Viewport height in native desktop pixels, or `0` to use the platform default. + */ + std::int32_t height = 0; + }; + /** * @brief Full mouse creation request. */ @@ -455,6 +503,22 @@ namespace lvh { * @brief Consumer-defined stable identity string. */ std::string stable_id; + + /** + * @brief Native virtual-desktop bounds used to normalize the target viewport. + * + * Set this together with `viewport`; leave both dimensions at zero to use + * the platform-default pointer area. + */ + PointerViewport desktop; + + /** + * @brief Native desktop viewport that receives absolute mouse input. + * + * Set this together with `desktop`; leave both dimensions at zero to use + * the platform-default pointer area. + */ + PointerViewport viewport; }; /** @@ -760,31 +824,6 @@ namespace lvh { std::string text; }; - /** - * @brief Pixel viewport used by backends that need screen-local pointer coordinates. - */ - struct PointerViewport { - /** - * @brief Horizontal viewport offset in pixels. - */ - std::int32_t offset_x = 0; - - /** - * @brief Vertical viewport offset in pixels. - */ - std::int32_t offset_y = 0; - - /** - * @brief Viewport width in pixels, or `0` to use the platform default. - */ - std::int32_t width = 0; - - /** - * @brief Viewport height in pixels, or `0` to use the platform default. - */ - std::int32_t height = 0; - }; - /** * @brief Pointer state transition requested for contact-capable devices. */ diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp index b2e0a2c..578c56f 100644 --- a/src/platform/linux/uhid_backend.cpp +++ b/src/platform/linux/uhid_backend.cpp @@ -1171,6 +1171,37 @@ namespace lvh::detail { return static_cast(numerator / limit); } + /** + * @brief Scale a source coordinate through a target viewport into an absolute input axis. + * + * @param value Source coordinate. + * @param source_dimension Source coordinate-space dimension. + * @param viewport_offset Native target viewport offset. + * @param viewport_dimension Native target viewport dimension. + * @param desktop_offset Native virtual-desktop origin. + * @param desktop_dimension Native virtual-desktop dimension. + * @return Normalized evdev absolute-axis value. + */ + int scale_absolute_axis_to_viewport( + float value, + std::int32_t source_dimension, + std::int32_t viewport_offset, + std::int32_t viewport_dimension, + std::int32_t desktop_offset, + std::int32_t desktop_dimension + ) { + if (source_dimension <= 0 || desktop_dimension <= 0 || viewport_dimension <= 0) { + return 0; + } + + const auto clamped = std::clamp(value, 0.0F, static_cast(source_dimension)); + const auto viewport_span = static_cast(std::max(viewport_dimension - 1, 0)); + const auto target = static_cast(viewport_offset - desktop_offset) + + clamped * viewport_span / static_cast(source_dimension); + const auto normalized = std::clamp(target / static_cast(desktop_dimension), 0.0F, 1.0F); + return static_cast(std::lround(normalized * static_cast(absolute_axis_max))); + } + int scale_normalized_axis(float value, int maximum) { return static_cast(std::lround(std::clamp(value, 0.0F, 1.0F) * static_cast(maximum))); } @@ -2043,15 +2074,24 @@ namespace lvh::detail { */ class UinputMouse final: public BackendMouse { public: - UinputMouse(int relative_file_descriptor, int absolute_file_descriptor): + UinputMouse( + int relative_file_descriptor, + int absolute_file_descriptor, + PointerViewport desktop = {}, + PointerViewport viewport = {} + ): relative_device_ {relative_file_descriptor}, - absolute_device_ {absolute_file_descriptor} {} + absolute_device_ {absolute_file_descriptor}, + desktop_ {desktop}, + viewport_ {viewport} {} ~UinputMouse() override { static_cast(close()); } OperationStatus create(DeviceId id, const CreateMouseOptions &options) { + desktop_ = options.desktop; + viewport_ = options.viewport; if (const auto status = relative_device_.create(id, options.profile, UinputMouseDeviceKind::relative); !status.ok()) { return status; } @@ -2100,6 +2140,8 @@ namespace lvh::detail { private: UinputMouseDevice relative_device_; UinputMouseDevice absolute_device_; + PointerViewport desktop_; ///< Native virtual-desktop bounds used for absolute input. + PointerViewport viewport_; ///< Native target viewport used for absolute input. UinputMouseDeviceKind last_motion_device_ = UinputMouseDeviceKind::relative; std::byte relative_buttons_down_ {}; std::byte absolute_buttons_down_ {}; @@ -2156,10 +2198,32 @@ namespace lvh::detail { OperationStatus submit_absolute_motion(const MouseEvent &event) { auto &absolute = device(UinputMouseDeviceKind::absolute); - if (const auto status = absolute.emit(EV_ABS, ABS_X, scale_absolute_axis(event.x, event.width)); !status.ok()) { + const auto x = event.has_fractional_absolute_coordinates ? event.absolute_x : static_cast(event.x); + const auto y = event.has_fractional_absolute_coordinates ? event.absolute_y : static_cast(event.y); + const auto absolute_x = viewport_.width > 0 ? + scale_absolute_axis_to_viewport( + x, + event.width, + viewport_.offset_x, + viewport_.width, + desktop_.offset_x, + desktop_.width + ) : + scale_absolute_axis(event.x, event.width); + const auto absolute_y = viewport_.height > 0 ? + scale_absolute_axis_to_viewport( + y, + event.height, + viewport_.offset_y, + viewport_.height, + desktop_.offset_y, + desktop_.height + ) : + scale_absolute_axis(event.y, event.height); + if (const auto status = absolute.emit(EV_ABS, ABS_X, absolute_x); !status.ok()) { return status; } - if (const auto status = absolute.emit(EV_ABS, ABS_Y, scale_absolute_axis(event.y, event.height)); !status.ok()) { + if (const auto status = absolute.emit(EV_ABS, ABS_Y, absolute_y); !status.ok()) { return status; } if (const auto status = absolute.synchronize(); !status.ok()) { @@ -2825,7 +2889,9 @@ namespace lvh::detail { static_cast(close()); } - OperationStatus create() { + OperationStatus create(const CreateMouseOptions &options = {}) { + desktop_ = options.desktop; + viewport_ = options.viewport; display_ = XOpenDisplay(nullptr); if (display_ == nullptr) { return OperationStatus::failure(ErrorCode::backend_unavailable, "failed to open X display for XTest mouse fallback"); @@ -2877,11 +2943,23 @@ namespace lvh::detail { private: void submit_absolute_motion(const MouseEvent &event) { const auto screen = DefaultScreen(display_); - const auto screen_width = DisplayWidth(display_, screen); - const auto screen_height = DisplayHeight(display_, screen); - const auto x = scale_absolute_axis(event.x, event.width) * std::max(screen_width - 1, 0) / absolute_axis_max; - const auto y = scale_absolute_axis(event.y, event.height) * std::max(screen_height - 1, 0) / absolute_axis_max; - XTestFakeMotionEvent(display_, screen, x, y, CurrentTime); + const auto screen_width = std::max(DisplayWidth(display_, screen) - 1, 0); + const auto screen_height = std::max(DisplayHeight(display_, screen) - 1, 0); + const auto event_x = event.has_fractional_absolute_coordinates ? event.absolute_x : static_cast(event.x); + const auto event_y = event.has_fractional_absolute_coordinates ? event.absolute_y : static_cast(event.y); + const auto x = viewport_.width > 0 ? + viewport_.offset_x - desktop_.offset_x + static_cast(std::lround(std::clamp(event_x, 0.0F, static_cast(event.width)) * static_cast(std::max(viewport_.width - 1, 0)) / static_cast(event.width))) : + scale_absolute_axis(event.x, event.width) * screen_width / absolute_axis_max; + const auto y = viewport_.height > 0 ? + viewport_.offset_y - desktop_.offset_y + static_cast(std::lround(std::clamp(event_y, 0.0F, static_cast(event.height)) * static_cast(std::max(viewport_.height - 1, 0)) / static_cast(event.height))) : + scale_absolute_axis(event.y, event.height) * screen_height / absolute_axis_max; + XTestFakeMotionEvent( + display_, + screen, + std::clamp(x, 0, screen_width), + std::clamp(y, 0, screen_height), + CurrentTime + ); } void submit_scroll(std::int32_t distance, int positive_button, int negative_button) { @@ -2894,6 +2972,8 @@ namespace lvh::detail { } Display *display_ = nullptr; + PointerViewport desktop_; ///< Native virtual-desktop bounds represented by the X root window. + PointerViewport viewport_; ///< Native target viewport used for absolute input. }; #else bool can_use_xtest() { @@ -3958,19 +4038,19 @@ namespace lvh::detail { BackendMouseCreationResult create_mouse(DeviceId id, const CreateMouseOptions &options) override { const auto relative_fd = open_uinput(O_RDWR | O_CLOEXEC | O_NONBLOCK); if (relative_fd < 0) { - return create_xtest_mouse(); + return create_xtest_mouse(options); } const auto absolute_fd = open_uinput(O_RDWR | O_CLOEXEC | O_NONBLOCK); if (absolute_fd < 0) { static_cast(system_close(relative_fd)); - return create_xtest_mouse(); + return create_xtest_mouse(options); } auto mouse = std::make_unique(relative_fd, absolute_fd); if (const auto status = mouse->create(id, options); !status.ok()) { static_cast(mouse->close()); - auto fallback = create_xtest_mouse(); + auto fallback = create_xtest_mouse(options); if (fallback) { return fallback; } @@ -4041,10 +4121,10 @@ namespace lvh::detail { #endif } - BackendMouseCreationResult create_xtest_mouse() { + BackendMouseCreationResult create_xtest_mouse(const CreateMouseOptions &options) { #if defined(LIBVIRTUALHID_HAVE_XTEST) auto mouse = std::make_unique(); - if (const auto status = mouse->create(); !status.ok()) { + if (const auto status = mouse->create(options); !status.ok()) { return {status, nullptr}; } return {OperationStatus::success(), std::move(mouse)}; diff --git a/src/platform/macos/macos_backend.cpp b/src/platform/macos/macos_backend.cpp index c67ca89..6fced9b 100644 --- a/src/platform/macos/macos_backend.cpp +++ b/src/platform/macos/macos_backend.cpp @@ -378,14 +378,35 @@ namespace lvh::detail { }; } + /** + * @brief Resolve a portable pointer viewport to CoreGraphics desktop bounds. + * + * @param viewport Consumer-selected viewport, or zero dimensions for the main display. + * @return CoreGraphics bounds used for mouse mapping and confinement. + */ + inline CGRect mouse_viewport_bounds(const PointerViewport &viewport) { + if (viewport.width > 0 && viewport.height > 0) { + return CGRect { + .origin = CGPoint { + static_cast(viewport.offset_x), + static_cast(viewport.offset_y), + }, + .size = CGSize { + static_cast(viewport.width), + static_cast(viewport.height), + }, + }; + } + + return CGDisplayBounds(CGMainDisplayID()); + } + /** * @brief Shared macOS backend state. */ class MacosInputState { public: MacosInputState(): - display {CGMainDisplayID()}, - display_scaling {display_scaling_for(display)}, source {CGEventSourceCreate(kCGEventSourceStateHIDSystemState)}, keyboard_source {CGEventSourceCreate(kCGEventSourceStatePrivate)}, mouse_event {source ? CGEventCreate(source) : nullptr}, @@ -408,31 +429,6 @@ namespace lvh::detail { } } - /** - * @brief Compute the coordinate scaling factor for a display. - * - * @param display_id CoreGraphics display identifier. - * @return Coordinate scaling factor. - */ - static CGFloat display_scaling_for(CGDirectDisplayID display_id) { - const auto mode = CGDisplayCopyDisplayMode(display_id); - if (!mode) { - return 1.0; - } - - const auto logical_width = CGDisplayModeGetPixelWidth(mode); - if (logical_width == 0) { - CFRelease(mode); - return 1.0; - } - - const auto scaling = static_cast(CGDisplayPixelsWide(display_id)) / static_cast(logical_width); - CFRelease(mode); - return scaling; - } - - CGDirectDisplayID display {}; ///< CoreGraphics identifier for the target display. - CGFloat display_scaling = 1.0; ///< Scaling factor from logical to physical display pixels. CGEventSourceRef source {}; ///< CoreGraphics event source for mouse and scroll events. CGEventSourceRef keyboard_source {}; ///< CoreGraphics event source for keyboard events. CGEventRef mouse_event {}; ///< Reusable CoreGraphics mouse event. @@ -628,8 +624,9 @@ namespace lvh::detail { */ class MacosMouse final: public BackendMouse { public: - explicit MacosMouse(std::shared_ptr state): - state_ {std::move(state)} {} + MacosMouse(std::shared_ptr state, const PointerViewport &viewport): + state_ {std::move(state)}, + viewport_bounds_ {mouse_viewport_bounds(viewport)} {} ~MacosMouse() override { static_cast(close()); @@ -672,8 +669,7 @@ namespace lvh::detail { CGPoint current_location() const { const auto snapshot_event = CGEventCreate(state_->source); if (!snapshot_event) { - const auto display_bounds = CGDisplayBounds(state_->display); - return display_bounds.origin; + return viewport_bounds_.origin; } const auto current = CGEventGetLocation(snapshot_event); @@ -688,10 +684,9 @@ namespace lvh::detail { CGPoint previous_location, int click_count ) const { - const auto display_bounds = CGDisplayBounds(state_->display); const auto location = CGPoint { - std::clamp(raw_location.x, display_bounds.origin.x, display_bounds.origin.x + display_bounds.size.width - 1), - std::clamp(raw_location.y, display_bounds.origin.y, display_bounds.origin.y + display_bounds.size.height - 1) + std::clamp(raw_location.x, viewport_bounds_.origin.x, viewport_bounds_.origin.x + viewport_bounds_.size.width - 1), + std::clamp(raw_location.y, viewport_bounds_.origin.y, viewport_bounds_.origin.y + viewport_bounds_.size.height - 1) }; const auto event = state_->mouse_event; @@ -720,8 +715,7 @@ namespace lvh::detail { } OperationStatus submit_absolute_motion(const MouseEvent &event) { - const auto display_bounds = CGDisplayBounds(state_->display); - const auto location = absolute_mouse_location(event, display_bounds); + const auto location = absolute_mouse_location(event, viewport_bounds_); const auto motion = macos_mouse_motion(mouse_down_); return post_mouse(motion.button, motion.event_type, location, current_location(), 0); } @@ -765,6 +759,7 @@ namespace lvh::detail { } std::shared_ptr state_; + CGRect viewport_bounds_ {}; ///< Native desktop bounds receiving mouse input. std::array mouse_down_ {}; std::array, 3> last_mouse_event_ {}; std::mutex mutex_; @@ -809,7 +804,7 @@ namespace lvh::detail { return {OperationStatus::failure(ErrorCode::backend_failure, "macOS mouse event source is unavailable"), nullptr}; } - return {OperationStatus::success(), std::make_unique(state_)}; + return {OperationStatus::success(), std::make_unique(state_, options.viewport)}; } BackendTouchscreenCreationResult create_touchscreen( diff --git a/src/platform/windows/windows_backend.cpp b/src/platform/windows/windows_backend.cpp index 0d4dfbe..c3730ff 100644 --- a/src/platform/windows/windows_backend.cpp +++ b/src/platform/windows/windows_backend.cpp @@ -432,6 +432,36 @@ namespace lvh::detail { return static_cast(std::lround(scaled)); } + /** + * @brief Scale an absolute source coordinate through a target viewport into a virtual desktop axis. + * + * @param value Absolute source coordinate. + * @param source_dimension Source coordinate-space dimension. + * @param viewport_offset Target viewport offset in native desktop pixels. + * @param viewport_dimension Target viewport dimension in native desktop pixels. + * @param desktop_offset Virtual desktop origin in native desktop pixels. + * @param desktop_dimension Virtual desktop dimension in native desktop pixels. + * @return Win32 normalized absolute coordinate. + */ + LONG scale_absolute_axis_to_viewport( + float value, + std::int32_t source_dimension, + std::int32_t viewport_offset, + std::int32_t viewport_dimension, + std::int32_t desktop_offset, + std::int32_t desktop_dimension + ) { + if (source_dimension <= 0 || viewport_dimension <= 0 || desktop_dimension <= 0) { + return 0; + } + + const auto clamped = std::clamp(value, 0.0F, static_cast(source_dimension)); + const auto viewport_span = static_cast(std::max(viewport_dimension - 1, 0)); + const auto target = static_cast(viewport_offset - desktop_offset) + + clamped * viewport_span / static_cast(source_dimension); + return scale_absolute_axis(target, desktop_dimension); + } + PointerViewport resolve_pointer_viewport(PointerViewport viewport) { if (viewport.width > 0 && viewport.height > 0) { return viewport; @@ -2144,6 +2174,16 @@ namespace lvh::detail { class WindowsMouse final: public BackendMouse { public: + /** + * @brief Construct a Win32 mouse injection backend. + * + * @param desktop Native virtual-desktop bounds. + * @param viewport Native desktop viewport receiving absolute input. + */ + WindowsMouse(PointerViewport desktop = {}, PointerViewport viewport = {}): + desktop_ {desktop}, + viewport_ {viewport} {} + OperationStatus submit(const MouseEvent &event) override { using enum ErrorCode; @@ -2165,14 +2205,33 @@ namespace lvh::detail { break; case absolute_motion: mouse.dwFlags = MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK; - mouse.dx = scale_absolute_axis( - event.has_fractional_absolute_coordinates ? event.absolute_x : static_cast(event.x), - event.width - ); - mouse.dy = scale_absolute_axis( - event.has_fractional_absolute_coordinates ? event.absolute_y : static_cast(event.y), - event.height - ); + if (viewport_.width > 0 && viewport_.height > 0) { + mouse.dx = scale_absolute_axis_to_viewport( + event.has_fractional_absolute_coordinates ? event.absolute_x : static_cast(event.x), + event.width, + viewport_.offset_x, + viewport_.width, + desktop_.offset_x, + desktop_.width + ); + mouse.dy = scale_absolute_axis_to_viewport( + event.has_fractional_absolute_coordinates ? event.absolute_y : static_cast(event.y), + event.height, + viewport_.offset_y, + viewport_.height, + desktop_.offset_y, + desktop_.height + ); + } else { + mouse.dx = scale_absolute_axis( + event.has_fractional_absolute_coordinates ? event.absolute_x : static_cast(event.x), + event.width + ); + mouse.dy = scale_absolute_axis( + event.has_fractional_absolute_coordinates ? event.absolute_y : static_cast(event.y), + event.height + ); + } break; case button: mouse.dwFlags = mouse_button_flags(event.button, event.pressed); @@ -2197,6 +2256,8 @@ namespace lvh::detail { } private: + PointerViewport desktop_; ///< Native virtual-desktop bounds. + PointerViewport viewport_; ///< Native desktop viewport receiving absolute input. bool open_ = true; }; @@ -2276,10 +2337,13 @@ namespace lvh::detail { public: WindowsHidMouse( std::shared_ptr context, - std::shared_ptr state + std::shared_ptr state, + const PointerViewport &desktop, + const PointerViewport &viewport ): context_ {std::move(context)}, - state_ {std::move(state)} {} + state_ {std::move(state)}, + fallback_ {desktop, viewport} {} OperationStatus submit(const MouseEvent &event) override { using enum ErrorCode; @@ -2437,7 +2501,10 @@ namespace lvh::detail { if (!status.ok()) { return {status, nullptr}; } - return {std::move(status), std::make_unique(shared_from_this(), std::move(state))}; + return { + std::move(status), + std::make_unique(shared_from_this(), std::move(state), options.desktop, options.viewport), + }; } /** @@ -3077,7 +3144,7 @@ namespace lvh::detail { } } - return {OperationStatus::success(), std::make_unique()}; + return {OperationStatus::success(), std::make_unique(options.desktop, options.viewport)}; } BackendTouchscreenCreationResult create_touchscreen( diff --git a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp index 84c2cee..ffc2e17 100644 --- a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp +++ b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp @@ -644,6 +644,26 @@ namespace lvh::detail::test { */ int linux_absolute_axis(std::int32_t value, std::int32_t limit); + /** + * @brief Scale an absolute pointer coordinate through a target viewport. + * + * @param value Source coordinate. + * @param source_dimension Source coordinate-space dimension. + * @param viewport_offset Target viewport offset. + * @param viewport_dimension Target viewport dimension. + * @param desktop_offset Virtual desktop origin. + * @param desktop_dimension Virtual desktop dimension. + * @return Linux absolute axis value. + */ + int linux_absolute_axis_to_viewport( + float value, + std::int32_t source_dimension, + std::int32_t viewport_offset, + std::int32_t viewport_dimension, + std::int32_t desktop_offset, + std::int32_t desktop_dimension + ); + /** * @brief Decode UTF-8 into Unicode code points using the Linux backend decoder. * @@ -927,6 +947,18 @@ namespace lvh::detail::test { */ LinuxInputSubmissionResult linux_uinput_mouse_submit_pipe(const MouseEvent &event); + /** + * @brief Submit a mouse event to a pipe-backed uinput mouse with explicit desktop mapping. + * + * @param event Mouse event. + * @param options Mouse creation options containing desktop and target viewports. + * @return Submission status and captured input events. + */ + LinuxInputSubmissionResult linux_uinput_mouse_submit_pipe( + const MouseEvent &event, + const CreateMouseOptions &options + ); + /** * @brief Submit mouse events to one pipe-backed uinput mouse. * diff --git a/tests/fixtures/include/fixtures/macos_backend_test_hooks.hpp b/tests/fixtures/include/fixtures/macos_backend_test_hooks.hpp index a67966b..0f8ee77 100644 --- a/tests/fixtures/include/fixtures/macos_backend_test_hooks.hpp +++ b/tests/fixtures/include/fixtures/macos_backend_test_hooks.hpp @@ -21,6 +21,16 @@ namespace lvh::detail::test { double y {}; ///< Vertical coordinate. }; + /** + * @brief Portable representation of CoreGraphics viewport bounds for tests. + */ + struct MacosViewportBounds { + double origin_x {}; ///< Horizontal viewport origin. + double origin_y {}; ///< Vertical viewport origin. + double width {}; ///< Viewport width. + double height {}; ///< Viewport height. + }; + /** * @brief Portable representation of CoreGraphics mouse motion metadata for tests. */ @@ -104,6 +114,14 @@ namespace lvh::detail::test { double height ); + /** + * @brief Resolve an explicit portable mouse viewport to CoreGraphics bounds. + * + * @param viewport Portable pointer viewport. + * @return CoreGraphics bounds represented with portable scalar fields. + */ + MacosViewportBounds macos_backend_mouse_viewport_bounds(const PointerViewport &viewport); + /** * @brief Select CoreGraphics motion metadata for a mouse button state. * diff --git a/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp b/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp index 55a1dd8..6582cc1 100644 --- a/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp +++ b/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp @@ -270,4 +270,24 @@ namespace lvh::detail::test { WindowsOverlappedIoResult windows_backend_overlapped_device_io(); WindowsBackendSendInputResult windows_backend_send_input_devices(); + /** + * @brief Scale a source coordinate through a target viewport into a virtual desktop axis. + * + * @param value Source coordinate. + * @param source_dimension Source coordinate-space dimension. + * @param viewport_offset Target viewport offset. + * @param viewport_dimension Target viewport dimension. + * @param desktop_offset Virtual desktop origin. + * @param desktop_dimension Virtual desktop dimension. + * @return Win32 normalized absolute coordinate. + */ + std::int32_t windows_backend_scale_absolute_axis_to_viewport( + float value, + std::int32_t source_dimension, + std::int32_t viewport_offset, + std::int32_t viewport_dimension, + std::int32_t desktop_offset, + std::int32_t desktop_dimension + ); + } // namespace lvh::detail::test diff --git a/tests/fixtures/linux_backend_test_hooks.cpp b/tests/fixtures/linux_backend_test_hooks.cpp index 0111bb2..bbb7e8e 100644 --- a/tests/fixtures/linux_backend_test_hooks.cpp +++ b/tests/fixtures/linux_backend_test_hooks.cpp @@ -996,6 +996,24 @@ namespace lvh::detail::test { return scale_absolute_axis(value, limit); } + int linux_absolute_axis_to_viewport( + float value, + std::int32_t source_dimension, + std::int32_t viewport_offset, + std::int32_t viewport_dimension, + std::int32_t desktop_offset, + std::int32_t desktop_dimension + ) { + return scale_absolute_axis_to_viewport( + value, + source_dimension, + viewport_offset, + viewport_dimension, + desktop_offset, + desktop_dimension + ); + } + std::vector linux_decode_utf8(const std::string &text) { return decode_utf8(text); } @@ -1325,6 +1343,30 @@ namespace lvh::detail::test { return linux_uinput_mouse_submit_pipe_sequence(std::vector {event}); } + LinuxInputSubmissionResult linux_uinput_mouse_submit_pipe( + const MouseEvent &event, + const CreateMouseOptions &options + ) { + std::array descriptors {-1, -1}; + if (::pipe(descriptors.data()) != 0) { + return {system_error_status(ErrorCode::backend_failure, "failed to create pipe", errno), {}}; + } + + const auto absolute_descriptor = ::dup(descriptors[1]); + if (absolute_descriptor < 0) { + static_cast(::close(descriptors[0])); + static_cast(::close(descriptors[1])); + return {system_error_status(ErrorCode::backend_failure, "failed to duplicate pipe", errno), {}}; + } + + UinputMouse mouse {descriptors[1], absolute_descriptor, options.desktop, options.viewport}; + const auto status = mouse.submit(event); + static_cast(mouse.close()); + auto records = read_input_events_until_eof(descriptors[0]); + static_cast(::close(descriptors[0])); + return {status, std::move(records)}; + } + LinuxInputSubmissionResult linux_uinput_mouse_submit_pipe_sequence(const std::vector &events) { std::array descriptors {-1, -1}; if (::pipe(descriptors.data()) != 0) { diff --git a/tests/fixtures/macos_backend_test_hooks.cpp b/tests/fixtures/macos_backend_test_hooks.cpp index 709e346..3c5f8ea 100644 --- a/tests/fixtures/macos_backend_test_hooks.cpp +++ b/tests/fixtures/macos_backend_test_hooks.cpp @@ -56,6 +56,16 @@ namespace lvh::detail::test { return {.x = location.x, .y = location.y}; } + MacosViewportBounds macos_backend_mouse_viewport_bounds(const PointerViewport &viewport) { + const auto bounds = macos::mouse_viewport_bounds(viewport); + return { + .origin_x = bounds.origin.x, + .origin_y = bounds.origin.y, + .width = bounds.size.width, + .height = bounds.size.height, + }; + } + MacosMouseMotionResult macos_backend_mouse_motion(bool left_down, bool right_down, bool middle_down) { const auto motion = macos::macos_mouse_motion({left_down, right_down, middle_down}); return { diff --git a/tests/fixtures/windows_backend_test_hooks.cpp b/tests/fixtures/windows_backend_test_hooks.cpp index 8ab75eb..9f43d0d 100644 --- a/tests/fixtures/windows_backend_test_hooks.cpp +++ b/tests/fixtures/windows_backend_test_hooks.cpp @@ -486,6 +486,24 @@ namespace lvh::detail { namespace test { + std::int32_t windows_backend_scale_absolute_axis_to_viewport( + float value, + std::int32_t source_dimension, + std::int32_t viewport_offset, + std::int32_t viewport_dimension, + std::int32_t desktop_offset, + std::int32_t desktop_dimension + ) { + return scale_absolute_axis_to_viewport( + value, + source_dimension, + viewport_offset, + viewport_dimension, + desktop_offset, + desktop_dimension + ); + } + WindowsBackendLifecycleResult windows_backend_fake_channel_lifecycle() { WindowsBackendLifecycleResult result; auto command_state = std::make_shared(); diff --git a/tests/unit/test_linux_backend.cpp b/tests/unit/test_linux_backend.cpp index 5f39285..17fe8a0 100644 --- a/tests/unit/test_linux_backend.cpp +++ b/tests/unit/test_linux_backend.cpp @@ -148,6 +148,13 @@ TEST_F(LinuxBackendTest, ScalesAbsoluteAxesAndScrollSteps) { EXPECT_EQ(lvh::detail::test::linux_absolute_axis(101, 100), 65535); EXPECT_EQ(lvh::detail::test::linux_absolute_axis(1, 0), 0); + EXPECT_EQ(lvh::detail::test::linux_absolute_axis_to_viewport(0.0F, 1920, 0, 1920, -1920, 3840), 32768); + EXPECT_EQ(lvh::detail::test::linux_absolute_axis_to_viewport(960.0F, 1920, 0, 1920, -1920, 3840), 49143); + EXPECT_EQ(lvh::detail::test::linux_absolute_axis_to_viewport(1920.0F, 1920, 0, 1920, -1920, 3840), 65518); + EXPECT_EQ(lvh::detail::test::linux_absolute_axis_to_viewport(1.0F, 0, 0, 1920, -1920, 3840), 0); + EXPECT_EQ(lvh::detail::test::linux_absolute_axis_to_viewport(1.0F, 1, 0, 0, 0, 1), 0); + EXPECT_EQ(lvh::detail::test::linux_absolute_axis_to_viewport(1.0F, 1, 0, 1, 0, 0), 0); + EXPECT_EQ(lvh::detail::test::linux_legacy_scroll_steps(0), 0); EXPECT_EQ(lvh::detail::test::linux_legacy_scroll_steps(1), 1); EXPECT_EQ(lvh::detail::test::linux_legacy_scroll_steps(-1), -1); @@ -647,6 +654,27 @@ TEST_F(LinuxBackendTest, PipeBackedUinputMouseEmitsEvents) { EXPECT_EQ(result.events.back().type, EV_SYN); } +TEST_F(LinuxBackendTest, PipeBackedUinputMouseMapsConfiguredViewport) { + lvh::MouseEvent event { + .kind = lvh::MouseEventKind::absolute_motion, + .x = 960, + .y = 540, + .width = 1920, + .height = 1080, + }; + lvh::CreateMouseOptions options; + options.desktop = {.offset_x = -1920, .offset_y = 0, .width = 3840, .height = 1080}; + options.viewport = {.offset_x = 0, .offset_y = 0, .width = 1920, .height = 1080}; + + const auto result = lvh::detail::test::linux_uinput_mouse_submit_pipe(event, options); + ASSERT_TRUE(result.status.ok()) << result.status.message(); + ASSERT_EQ(result.events.size(), 3U); + EXPECT_EQ(result.events[0].code, ABS_X); + EXPECT_EQ(result.events[0].value, 49143); + EXPECT_EQ(result.events[1].code, ABS_Y); + EXPECT_EQ(result.events[1].value, 32737); +} + TEST_F(LinuxBackendTest, PipeBackedUinputMouseRoutesMotionAndButtonsAcrossSplitDevices) { const std::vector events { {.kind = lvh::MouseEventKind::absolute_motion, .x = 50, .y = 25, .width = 100, .height = 100}, diff --git a/tests/unit/test_macos_backend.cpp b/tests/unit/test_macos_backend.cpp index 19a37ac..3421b6f 100644 --- a/tests/unit/test_macos_backend.cpp +++ b/tests/unit/test_macos_backend.cpp @@ -104,6 +104,20 @@ TEST_F(MacosBackendTest, ConvertsAbsoluteMouseCoordinates) { EXPECT_DOUBLE_EQ(location.y, 170.0); } +TEST_F(MacosBackendTest, UsesConfiguredMouseViewport) { + const auto bounds = lvh::detail::test::macos_backend_mouse_viewport_bounds({ + .offset_x = -1920, + .offset_y = 120, + .width = 1920, + .height = 1080, + }); + + EXPECT_DOUBLE_EQ(bounds.origin_x, -1920.0); + EXPECT_DOUBLE_EQ(bounds.origin_y, 120.0); + EXPECT_DOUBLE_EQ(bounds.width, 1920.0); + EXPECT_DOUBLE_EQ(bounds.height, 1080.0); +} + TEST_F(MacosBackendTest, SelectsMouseMotionMetadataForHeldButtons) { using lvh::detail::test::macos_backend_mouse_motion; diff --git a/tests/unit/test_runtime.cpp b/tests/unit/test_runtime.cpp index 631276a..94d08e7 100644 --- a/tests/unit/test_runtime.cpp +++ b/tests/unit/test_runtime.cpp @@ -6,6 +6,13 @@ // lib includes #include +// standard includes +#include +#include +#include +#include +#include + // local includes #include "fixtures/fixtures.hpp" #if defined(_WIN32) @@ -32,6 +39,60 @@ TEST(RuntimeTest, FakeBackendReportsCapabilities) { EXPECT_FALSE(runtime->capabilities().requires_installed_driver); } +TEST(RuntimeTest, RoutesDiagnosticsToConsumerCallback) { + struct Diagnostic { + lvh::LogLevel level; + std::string message; + }; + + std::vector diagnostics; + + lvh::RuntimeOptions runtime_options; + runtime_options.log_callback = [&diagnostics](lvh::LogLevel level, const std::string &message) { + diagnostics.push_back({level, message}); + }; + auto runtime = lvh::Runtime::create(runtime_options); + + lvh::CreateMouseOptions mouse_options; + mouse_options.profile = lvh::profiles::mouse(); + mouse_options.desktop = {.offset_x = -1920, .offset_y = 0, .width = 3840, .height = 1080}; + mouse_options.viewport = {.offset_x = -1920, .offset_y = 0, .width = 1920, .height = 1080}; + auto created = runtime->create_mouse(mouse_options); + ASSERT_TRUE(created) << created.status.message(); + EXPECT_TRUE(created.mouse->move_relative(1, -1).ok()); + EXPECT_TRUE(created.mouse->move_absolute(960, 540, 1920, 1080).ok()); + EXPECT_TRUE(created.mouse->move_absolute(0.5F, 0.25F, 1, 1).ok()); + EXPECT_TRUE(created.mouse->button(lvh::MouseButton::left, true).ok()); + EXPECT_TRUE(created.mouse->vertical_scroll(120).ok()); + EXPECT_TRUE(created.mouse->horizontal_scroll(-120).ok()); + EXPECT_TRUE(created.mouse->close().ok()); + EXPECT_EQ(created.mouse->move_relative(1, 1).code(), lvh::ErrorCode::device_closed); + + const auto has_message = [&diagnostics](lvh::LogLevel level, std::string_view text) { + return std::ranges::any_of(diagnostics, [level, text](const auto &diagnostic) { + return diagnostic.level == level && diagnostic.message.contains(text); + }); + }; + EXPECT_TRUE(has_message(lvh::LogLevel::info, "initialized fake backend")); + EXPECT_TRUE(has_message(lvh::LogLevel::info, "created mouse 1")); + EXPECT_TRUE(has_message(lvh::LogLevel::debug, "viewport=-1920,0 1920x1080")); + EXPECT_TRUE(has_message(lvh::LogLevel::error, "mouse input failed: mouse is closed")); +} + +TEST(RuntimeTest, DiscardsConsumerDiagnosticExceptions) { + lvh::RuntimeOptions options; + options.log_callback = [](lvh::LogLevel, const std::string &) { + throw std::runtime_error {"consumer logger failed"}; + }; + + EXPECT_NO_THROW({ + auto runtime = lvh::Runtime::create(options); + auto created = runtime->create_mouse(); + ASSERT_TRUE(created); + EXPECT_TRUE(created.mouse->move_relative(1, 1).ok()); + }); +} + TEST(RuntimeTest, PlatformDefaultReportsCurrentPlatformCapabilities) { lvh::RuntimeOptions options; options.backend = lvh::BackendKind::platform_default; @@ -274,6 +335,29 @@ TEST(RuntimeTest, CreatesSubmitsAndClosesMouse) { EXPECT_EQ(created.mouse->move_relative(1, 1).code(), lvh::ErrorCode::device_closed); } +TEST(RuntimeTest, RejectsIncompleteMouseViewport) { + auto runtime = lvh::Runtime::create(); + const auto expect_invalid = [&runtime](lvh::PointerViewport desktop, lvh::PointerViewport viewport) { + lvh::CreateMouseOptions options; + options.profile = lvh::profiles::mouse(); + options.desktop = desktop; + options.viewport = viewport; + + const auto created = runtime->create_mouse(options); + EXPECT_FALSE(created); + EXPECT_EQ(created.status.code(), lvh::ErrorCode::invalid_argument); + }; + + expect_invalid({}, {.width = 1920}); + expect_invalid({}, {.width = 1920, .height = 1080}); + expect_invalid({.width = 3840, .height = 1080}, {}); + expect_invalid({.width = -1, .height = 1080}, {}); + expect_invalid( + {.offset_x = -1920, .width = 3840, .height = 1080}, + {.offset_x = 0, .offset_y = 1080, .width = 1920, .height = 1080} + ); +} + TEST(RuntimeTest, CreatesSubmitsAndClosesTouchDevices) { auto runtime = lvh::Runtime::create(); diff --git a/tests/unit/test_windows_backend.cpp b/tests/unit/test_windows_backend.cpp index 64000d1..4372e96 100644 --- a/tests/unit/test_windows_backend.cpp +++ b/tests/unit/test_windows_backend.cpp @@ -433,3 +433,14 @@ TEST_F(WindowsBackendTest, SendInputDevicesTranslateKeyboardMouseFailuresAndUnsu EXPECT_EQ(result.sent_inputs[17].mouse_x, 1); EXPECT_EQ(result.sent_inputs[17].mouse_y, 1); } + +TEST_F(WindowsBackendTest, MapsAbsoluteMouseInputIntoConfiguredViewport) { + using lvh::detail::test::windows_backend_scale_absolute_axis_to_viewport; + + EXPECT_EQ(windows_backend_scale_absolute_axis_to_viewport(0.0F, 1920, 0, 1920, -1920, 3840), 32768); + EXPECT_EQ(windows_backend_scale_absolute_axis_to_viewport(960.0F, 1920, 0, 1920, -1920, 3840), 49143); + EXPECT_EQ(windows_backend_scale_absolute_axis_to_viewport(1920.0F, 1920, 0, 1920, -1920, 3840), 65518); + EXPECT_EQ(windows_backend_scale_absolute_axis_to_viewport(1.0F, 0, 0, 1920, -1920, 3840), 0); + EXPECT_EQ(windows_backend_scale_absolute_axis_to_viewport(1.0F, 1, 0, 0, 0, 1), 0); + EXPECT_EQ(windows_backend_scale_absolute_axis_to_viewport(1.0F, 1, 0, 1, 0, 0), 0); +} From 36a1976a300bdf283c51755f856d06e215cc1792 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:26:39 -0400 Subject: [PATCH 2/2] refactor: sonar fixes --- docs/usage.md | 4 +- src/core/runtime.cpp | 25 +++--- src/include/libvirtualhid/types.hpp | 2 +- src/platform/windows/windows_backend.cpp | 2 +- tests/fixtures/linux_backend_test_hooks.cpp | 85 ++++++++++----------- tests/unit/test_runtime.cpp | 14 ++-- 6 files changed, 69 insertions(+), 63 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index 0abc56b..277df2e 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -187,8 +187,8 @@ auto runtime = lvh::Runtime::create(runtime_options); The callback receives runtime and device lifecycle messages, operation failures, and debug-level mouse coordinate diagnostics. It runs synchronously on the -calling thread. libvirtualhid discards callback exceptions so a consumer logger -cannot interrupt input delivery. +calling thread. If a consumer callback throws, libvirtualhid disables it for +subsequent messages so it cannot interrupt input delivery. ## Absolute Mouse Viewports diff --git a/src/core/runtime.cpp b/src/core/runtime.cpp index 429da7d..3bf362f 100644 --- a/src/core/runtime.cpp +++ b/src/core/runtime.cpp @@ -5,7 +5,9 @@ // standard includes #include +#include #include +#include #include #include #include @@ -39,7 +41,7 @@ namespace lvh::detail { * @return `true` when messages have a destination. */ bool enabled() const noexcept { - return static_cast(callback_); + return static_cast(callback_) && !callback_failed_.load(std::memory_order_relaxed); } /** @@ -49,18 +51,20 @@ namespace lvh::detail { * @param message Diagnostic text. */ void emit(LogLevel level, const std::string &message) const noexcept { - if (!callback_) { + if (!enabled()) { return; } try { callback_(level, message); } catch (...) { + callback_failed_.store(true, std::memory_order_relaxed); } } private: LogCallback callback_; + mutable std::atomic_bool callback_failed_ = false; ///< Whether the consumer callback threw an exception. }; class SynchronizedState { @@ -254,19 +258,18 @@ namespace lvh { if (options.profile.name.empty()) { return OperationStatus::failure(ErrorCode::invalid_argument, "device profile name must not be empty"); } - const auto valid_dimensions = [](const PointerViewport &viewport) { - return viewport.width >= 0 && viewport.height >= 0 && - ((viewport.width == 0 && viewport.height == 0) || (viewport.width > 0 && viewport.height > 0)); - }; - if (!valid_dimensions(options.desktop) || !valid_dimensions(options.viewport)) { + if (const auto valid_dimensions = [](const PointerViewport &viewport) { + return viewport.width >= 0 && viewport.height >= 0 && + ((viewport.width == 0 && viewport.height == 0) || (viewport.width > 0 && viewport.height > 0)); + }; + !valid_dimensions(options.desktop) || !valid_dimensions(options.viewport)) { return OperationStatus::failure( ErrorCode::invalid_argument, "mouse viewport dimensions must both be positive or both be zero" ); } const auto has_desktop = options.desktop.width > 0; - const auto has_viewport = options.viewport.width > 0; - if (has_desktop != has_viewport) { + if (const auto has_viewport = options.viewport.width > 0; has_desktop != has_viewport) { return OperationStatus::failure( ErrorCode::invalid_argument, "mouse desktop and target viewport must be configured together" @@ -328,7 +331,7 @@ namespace lvh { } break; case button: - message << "button " << static_cast(event.button) << (event.pressed ? " pressed" : " released"); + message << "button " << static_cast(std::to_underlying(event.button)) << (event.pressed ? " pressed" : " released"); break; case vertical_scroll: message << "vertical scroll distance=" << event.high_resolution_scroll; @@ -1298,7 +1301,7 @@ namespace lvh { }); auto mouse = std::make_unique(detail::RuntimeConstructionToken {}, std::move(device)); - state_->logger->emit(LogLevel::info, "created mouse " + std::to_string(id)); + state_->logger->emit(LogLevel::info, std::format("created mouse {}", id)); return {OperationStatus::success(), std::move(mouse)}; } diff --git a/src/include/libvirtualhid/types.hpp b/src/include/libvirtualhid/types.hpp index 83d18c3..050ec1d 100644 --- a/src/include/libvirtualhid/types.hpp +++ b/src/include/libvirtualhid/types.hpp @@ -121,7 +121,7 @@ namespace lvh { * @brief Consumer callback that receives libvirtualhid diagnostic messages. * * The callback is invoked synchronously from the thread performing the - * operation. Exceptions thrown by the callback are discarded. + * operation. If the callback throws, it is disabled for subsequent messages. */ using LogCallback = std::function; diff --git a/src/platform/windows/windows_backend.cpp b/src/platform/windows/windows_backend.cpp index c3730ff..d6c989b 100644 --- a/src/platform/windows/windows_backend.cpp +++ b/src/platform/windows/windows_backend.cpp @@ -2180,7 +2180,7 @@ namespace lvh::detail { * @param desktop Native virtual-desktop bounds. * @param viewport Native desktop viewport receiving absolute input. */ - WindowsMouse(PointerViewport desktop = {}, PointerViewport viewport = {}): + explicit WindowsMouse(PointerViewport desktop = {}, PointerViewport viewport = {}): desktop_ {desktop}, viewport_ {viewport} {} diff --git a/tests/fixtures/linux_backend_test_hooks.cpp b/tests/fixtures/linux_backend_test_hooks.cpp index bbb7e8e..deb75a5 100644 --- a/tests/fixtures/linux_backend_test_hooks.cpp +++ b/tests/fixtures/linux_backend_test_hooks.cpp @@ -1339,59 +1339,58 @@ namespace lvh::detail::test { return mouse.submit({.kind = MouseEventKind::relative_motion, .x = 1, .y = 1}); } + namespace { + /** + * @brief Submit mouse events through pipe-backed uinput devices. + * + * @param events Mouse events to submit. + * @param options Mouse creation options containing optional viewport bounds. + * @return Submission status and captured input events. + */ + LinuxInputSubmissionResult submit_uinput_mouse_pipe_sequence( + const std::vector &events, + const CreateMouseOptions &options + ) { + std::array descriptors {-1, -1}; + if (::pipe(descriptors.data()) != 0) { + return {system_error_status(ErrorCode::backend_failure, "failed to create pipe", errno), {}}; + } + + const auto absolute_descriptor = ::dup(descriptors[1]); + if (absolute_descriptor < 0) { + static_cast(::close(descriptors[0])); + static_cast(::close(descriptors[1])); + return {system_error_status(ErrorCode::backend_failure, "failed to duplicate pipe", errno), {}}; + } + + UinputMouse mouse {descriptors[1], absolute_descriptor, options.desktop, options.viewport}; + auto status = OperationStatus::success(); + for (const auto &event : events) { + status = mouse.submit(event); + if (!status.ok()) { + break; + } + } + static_cast(mouse.close()); + auto records = read_input_events_until_eof(descriptors[0]); + static_cast(::close(descriptors[0])); + return {std::move(status), std::move(records)}; + } + } // namespace + LinuxInputSubmissionResult linux_uinput_mouse_submit_pipe(const MouseEvent &event) { - return linux_uinput_mouse_submit_pipe_sequence(std::vector {event}); + return submit_uinput_mouse_pipe_sequence(std::vector {event}, {}); } LinuxInputSubmissionResult linux_uinput_mouse_submit_pipe( const MouseEvent &event, const CreateMouseOptions &options ) { - std::array descriptors {-1, -1}; - if (::pipe(descriptors.data()) != 0) { - return {system_error_status(ErrorCode::backend_failure, "failed to create pipe", errno), {}}; - } - - const auto absolute_descriptor = ::dup(descriptors[1]); - if (absolute_descriptor < 0) { - static_cast(::close(descriptors[0])); - static_cast(::close(descriptors[1])); - return {system_error_status(ErrorCode::backend_failure, "failed to duplicate pipe", errno), {}}; - } - - UinputMouse mouse {descriptors[1], absolute_descriptor, options.desktop, options.viewport}; - const auto status = mouse.submit(event); - static_cast(mouse.close()); - auto records = read_input_events_until_eof(descriptors[0]); - static_cast(::close(descriptors[0])); - return {status, std::move(records)}; + return submit_uinput_mouse_pipe_sequence(std::vector {event}, options); } LinuxInputSubmissionResult linux_uinput_mouse_submit_pipe_sequence(const std::vector &events) { - std::array descriptors {-1, -1}; - if (::pipe(descriptors.data()) != 0) { - return {system_error_status(ErrorCode::backend_failure, "failed to create pipe", errno), {}}; - } - - const auto absolute_descriptor = ::dup(descriptors[1]); - if (absolute_descriptor < 0) { - static_cast(::close(descriptors[0])); - static_cast(::close(descriptors[1])); - return {system_error_status(ErrorCode::backend_failure, "failed to duplicate pipe", errno), {}}; - } - - UinputMouse mouse {descriptors[1], absolute_descriptor}; - auto status = OperationStatus::success(); - for (const auto &event : events) { - status = mouse.submit(event); - if (!status.ok()) { - break; - } - } - static_cast(mouse.close()); - auto records = read_input_events_until_eof(descriptors[0]); - static_cast(::close(descriptors[0])); - return {std::move(status), std::move(records)}; + return submit_uinput_mouse_pipe_sequence(events, {}); } LinuxMouseInputSubmissionResult linux_uinput_mouse_submit_split_pipe_sequence(const std::vector &events) { diff --git a/tests/unit/test_runtime.cpp b/tests/unit/test_runtime.cpp index 94d08e7..a00a18f 100644 --- a/tests/unit/test_runtime.cpp +++ b/tests/unit/test_runtime.cpp @@ -8,7 +8,6 @@ // standard includes #include -#include #include #include #include @@ -49,7 +48,7 @@ TEST(RuntimeTest, RoutesDiagnosticsToConsumerCallback) { lvh::RuntimeOptions runtime_options; runtime_options.log_callback = [&diagnostics](lvh::LogLevel level, const std::string &message) { - diagnostics.push_back({level, message}); + diagnostics.emplace_back(level, message); }; auto runtime = lvh::Runtime::create(runtime_options); @@ -79,10 +78,14 @@ TEST(RuntimeTest, RoutesDiagnosticsToConsumerCallback) { EXPECT_TRUE(has_message(lvh::LogLevel::error, "mouse input failed: mouse is closed")); } -TEST(RuntimeTest, DiscardsConsumerDiagnosticExceptions) { +TEST(RuntimeTest, DisablesConsumerDiagnosticsAfterException) { + struct ConsumerLoggerFailure {}; + + std::size_t callback_count = 0; lvh::RuntimeOptions options; - options.log_callback = [](lvh::LogLevel, const std::string &) { - throw std::runtime_error {"consumer logger failed"}; + options.log_callback = [&callback_count](lvh::LogLevel, const std::string &) { + ++callback_count; + throw ConsumerLoggerFailure {}; }; EXPECT_NO_THROW({ @@ -91,6 +94,7 @@ TEST(RuntimeTest, DiscardsConsumerDiagnosticExceptions) { ASSERT_TRUE(created); EXPECT_TRUE(created.mouse->move_relative(1, 1).ok()); }); + EXPECT_EQ(callback_count, 1U); } TEST(RuntimeTest, PlatformDefaultReportsCurrentPlatformCapabilities) {