From e5af3010df12fbe69a48cabe3a83f310ebd384f9 Mon Sep 17 00:00:00 2001 From: RodrickOMG Date: Wed, 23 Sep 2026 02:14:23 +0800 Subject: [PATCH] fix(macOS): move the pointer once per relative packet The libvirtualhid macOS backend performed three conflicting pointer updates per relative motion packet: a CGEvent carrying both an absolute location and kCGMouseEventDeltaX/Y (deltas run through pointer acceleration and were computed from the unclamped position), followed by CGWarpMouseCursorPosition(), which shifts the baseline the next packet reads. Relative motion therefore collapsed to near zero with visible jitter, while button and scroll events -- which are never warped -- kept working. Motion was also clamped to a single display. Resolve relative motion against the live cursor position and post exactly one event with no warp on top. Re-base on the server position only on the first packet or after a gap: CGEventGetLocation() reports what the window server has already committed, so re-reading it for every packet in a 500-1000 Hz burst makes each packet overwrite the previous one's movement. Accumulate the chain locally in between instead. Clamp to the full active-display layout, select the event type from the actual button state so drags keep working, and carry the live modifier flags so a drag started while holding Shift/Option/Ctrl keeps them on every motion event that follows the button press. Fall back to the libvirtualhid path when Accessibility is not granted, so behaviour there is unchanged. Windows and Linux are unaffected. --- src/platform/macos/input.cpp | 135 ++++++++++++++++- src/platform/macos/mouse_utils.h | 139 ++++++++++++++++++ src/platform/virtualhid_input.cpp | 20 +++ .../unit/platform/macos/test_mouse_utils.cpp | 117 +++++++++++++++ 4 files changed, 406 insertions(+), 5 deletions(-) create mode 100644 src/platform/macos/mouse_utils.h create mode 100644 tests/unit/platform/macos/test_mouse_utils.cpp diff --git a/src/platform/macos/input.cpp b/src/platform/macos/input.cpp index 8b257a40053..5516a1f8cf2 100644 --- a/src/platform/macos/input.cpp +++ b/src/platform/macos/input.cpp @@ -7,28 +7,153 @@ #include // standard includes +#include +#include #include #include +#include #include #include // local includes #include "src/config.h" +#include "src/logging.h" +#include "src/platform/macos/mouse_utils.h" #include "src/platform/virtualhid_input.h" namespace platf { + namespace { + constexpr uint32_t max_displays = 16; + + /** + * @brief Read the pointer position in global CoreGraphics point space. + * @return The current pointer location, or an empty optional when events are unavailable. + */ + std::optional cursor_point() { + const auto event = CGEventCreate(nullptr); + if (!event) { + return std::nullopt; + } + + const auto point = CGEventGetLocation(event); + CFRelease(event); + return point; + } + + /** + * @brief Collect the bounds of every active display. + * @return The union of the display layout, or an empty optional if it is unavailable. + */ + std::optional active_layout_bounds() { + uint32_t count = 0; + if (CGGetActiveDisplayList(0, nullptr, &count) != kCGErrorSuccess || count == 0) { + return std::nullopt; + } + + CGDirectDisplayID displays[max_displays]; + count = std::min(count, max_displays); + if (CGGetActiveDisplayList(count, displays, &count) != kCGErrorSuccess) { + return std::nullopt; + } + + std::vector rects; + rects.reserve(count); + for (uint32_t i = 0; i < count; ++i) { + const auto bounds = CGDisplayBounds(displays[i]); + rects.push_back({bounds.origin.x, bounds.origin.y, bounds.size.width, bounds.size.height}); + } + + return macos::mouse::layout_bounds(rects); + } + } // namespace std::optional get_mouse_loc(input_t & /*input*/) { - const auto event = CGEventCreate(nullptr); - if (!event) { + const auto current = cursor_point(); + if (!current) { return std::nullopt; } - const auto current = CGEventGetLocation(event); - CFRelease(event); - return util::point_t {current.x, current.y}; + return util::point_t {current->x, current->y}; } + namespace macos { + bool move_mouse_relative(int delta_x, int delta_y) { + // CoreGraphics discards posted events without Accessibility. Report that so the caller + // can use the virtual HID path instead of silently dropping the movement. + if (!AXIsProcessTrusted()) { + return false; + } + + static const bool logged = [] { + BOOST_LOG(info) << "macOS relative mouse backend selected: CoreGraphics"; + return true; + }(); + (void) logged; + + static std::optional layout; // Only reached from the input thread. + static std::optional predicted; + static std::chrono::steady_clock::time_point last_packet; + + // CGEventGetLocation() reports only what the window server has committed so far. Re-basing + // every packet on it makes a burst at 500-1000 Hz read the same stale position repeatedly, + // so each packet overwrites the previous one's movement and the pointer under-travels. + // Accumulate the chain locally instead, and trust the server again on the first packet or + // after a gap long enough for the pointer to have been moved by something else. + const auto now = std::chrono::steady_clock::now(); + auto base = predicted; + if (!base || now - last_packet > std::chrono::milliseconds {100}) { + const auto live = cursor_point(); + if (!live) { + return false; + } + + base = live; + } + + auto target = CGPoint {base->x + delta_x, base->y + delta_y}; + if (!layout || target.x < layout->min_x || target.y < layout->min_y || target.x >= layout->max_x || target.y >= layout->max_y) { + const auto refreshed = active_layout_bounds(); + if (!refreshed) { + return false; + } + + layout = refreshed; + target = mouse::clamp_to_layout(target, *layout); + } + + const auto type = mouse::move_event_type( + CGEventSourceButtonState(kCGEventSourceStateHIDSystemState, kCGMouseButtonLeft), + CGEventSourceButtonState(kCGEventSourceStateHIDSystemState, kCGMouseButtonRight), + CGEventSourceButtonState(kCGEventSourceStateHIDSystemState, kCGMouseButtonCenter) + ); + + const auto event = CGEventCreateMouseEvent(nullptr, type, target, mouse::move_event_button(type)); + if (!event) { + return false; + } + + // Describe the movement once, with the location and the device deltas agreeing: a fresh + // event leaves the deltas at zero, so apps reading them instead of the cursor location + // would see no movement at all. + CGEventSetDoubleValueField(event, kCGMouseEventDeltaX, target.x - base->x); + CGEventSetDoubleValueField(event, kCGMouseEventDeltaY, target.y - base->y); + + // Modifiers are reported per event, so a Shift/Option/Ctrl held during a drag has to travel + // with the motion. The button press comes from the virtual HID backend, which stamps the + // same live state onto its mouse events; without this the press carries the modifier and + // every drag after it loses it. + CGEventSetFlags(event, CGEventSourceFlagsState(kCGEventSourceStateHIDSystemState)); + + // Exactly one pointer update: no CGWarpMouseCursorPosition() on top of this event. + CGEventPost(kCGHIDEventTap, event); + CFRelease(event); + + predicted = target; + last_packet = now; + return true; + } + } // namespace macos + platform_caps::caps_t get_capabilities() { platform_caps::caps_t caps = 0; const auto runtime = virtualhid::create_runtime(); diff --git a/src/platform/macos/mouse_utils.h b/src/platform/macos/mouse_utils.h new file mode 100644 index 00000000000..43d866204a0 --- /dev/null +++ b/src/platform/macos/mouse_utils.h @@ -0,0 +1,139 @@ +/** + * @file src/platform/macos/mouse_utils.h + * @brief Declarations for macOS relative pointer math helpers. + */ +#pragma once + +// system includes +#include + +// standard includes +#include +#include +#include + +namespace platf { + namespace macos { + namespace mouse { + + /** + * @brief A display rectangle in global CoreGraphics point space. + */ + struct rect_t { + double x; + double y; + double width; + double height; + }; + + /** + * @brief The outer bounds of the entire display layout. + */ + struct bounds_t { + double min_x; + double min_y; + double max_x; + double max_y; + }; + + /** + * @brief Compute the union of every active display. + * @param rects The active display rectangles. + * @return The layout bounds, or an empty optional when there are no displays. + */ + inline std::optional + layout_bounds(const std::vector &rects) { + if (rects.empty()) { + return std::nullopt; + } + + bounds_t bounds {rects.front().x, rects.front().y, rects.front().x + rects.front().width, rects.front().y + rects.front().height}; + for (const auto &rect : rects) { + bounds.min_x = std::min(bounds.min_x, rect.x); + bounds.min_y = std::min(bounds.min_y, rect.y); + bounds.max_x = std::max(bounds.max_x, rect.x + rect.width); + bounds.max_y = std::max(bounds.max_y, rect.y + rect.height); + } + + return bounds; + } + + /** + * @brief Clamp a point into the display layout. + * + * Secondary displays commonly have negative origins, so the whole layout is clamped + * instead of the main display rectangle. + * + * @param point The point to clamp. + * @param bounds The bounds to clamp to. + * @return The clamped point. + */ + inline CGPoint + clamp_to_layout(CGPoint point, const bounds_t &bounds) { + // Keep one point inside the layout so a drag near the edge still has a hit target. + const double max_x = bounds.max_x - 1.0; + const double max_y = bounds.max_y - 1.0; + + return { + std::min(std::max(point.x, bounds.min_x), max_x), + std::min(std::max(point.y, bounds.min_y), max_y) + }; + } + + /** + * @brief Resolve the move event type for the currently held mouse buttons. + * @param left True when the left button is held. + * @param right True when the right button is held. + * @param center True when the center button is held. + * @return The CoreGraphics event type describing the move. + */ + inline CGEventType + move_event_type(bool left, bool right, bool center) { + if (left) { + return kCGEventLeftMouseDragged; + } + if (right) { + return kCGEventRightMouseDragged; + } + if (center) { + return kCGEventOtherMouseDragged; + } + + return kCGEventMouseMoved; + } + + /** + * @brief Resolve the button reported alongside a move event. + * @param type The event type returned by `move_event_type()`. + * @return The mouse button for `CGEventCreateMouseEvent()`. + */ + inline CGMouseButton + move_event_button(CGEventType type) { + switch (type) { + case kCGEventRightMouseDragged: + return kCGMouseButtonRight; + case kCGEventOtherMouseDragged: + return kCGMouseButtonCenter; + default: + return kCGMouseButtonLeft; + } + } + + } // namespace mouse + } // namespace macos + + namespace macos { + /** + * @brief Inject a relative pointer movement through CoreGraphics. + * + * A single pointer update is emitted per movement so that the delta cannot be applied + * twice, or fought over by two mechanisms. + * + * @param delta_x The horizontal movement in pointer units. + * @param delta_y The vertical movement in pointer units. + * @return True when the movement was injected, false when CoreGraphics cannot be used. + */ + bool move_mouse_relative(int delta_x, int delta_y); + } // namespace macos + +} // namespace platf diff --git a/src/platform/virtualhid_input.cpp b/src/platform/virtualhid_input.cpp index 362f50af5e0..070862a5749 100644 --- a/src/platform/virtualhid_input.cpp +++ b/src/platform/virtualhid_input.cpp @@ -21,6 +21,9 @@ // local includes #include "src/config.h" #include "src/logging.h" +#ifdef __APPLE__ + #include "src/platform/macos/mouse_utils.h" +#endif #include "virtualhid_input.h" using namespace std::literals; @@ -794,6 +797,23 @@ namespace platf::virtualhid { } void move_mouse(input_context_t &context, int delta_x, int delta_y) { +#if defined(__APPLE__) && !defined(SUNSHINE_TESTS) + // libvirtualhid updates the pointer twice per relative packet: it posts a kCGHIDEventTap + // event carrying an absolute location *and* kCGMouseEventDeltaX/Y, then warps the cursor to + // the clamped location. Those two updates disagree - the delta is fed through the system + // pointer-acceleration curve and is computed from the unclamped position - so the cursor is + // nudged forward and snapped back on every packet. Relative motion therefore reads as the + // pointer barely moving or jittering in place, while button and scroll input, which never + // warps, keep working. Emit a single CoreGraphics update instead, and only fall back to the + // virtual HID report when CoreGraphics cannot inject events at all. + // + // Excluded from the test build: these tests assert the submitted virtual HID report, and + // must not move the pointer of whoever is running the suite. + if (platf::macos::move_mouse_relative(delta_x, delta_y)) { + return; + } +#endif // __APPLE__ && !SUNSHINE_TESTS + if (context.mouse) { log_failure("submit libvirtualhid mouse movement"sv, context.mouse->move_relative(delta_x, delta_y)); } diff --git a/tests/unit/platform/macos/test_mouse_utils.cpp b/tests/unit/platform/macos/test_mouse_utils.cpp new file mode 100644 index 00000000000..2397a3f5117 --- /dev/null +++ b/tests/unit/platform/macos/test_mouse_utils.cpp @@ -0,0 +1,117 @@ +/** + * @file tests/unit/platform/macos/test_mouse_utils.cpp + * @brief Tests for macOS relative pointer math helpers. + */ + +#ifdef __APPLE__ + + // system includes + #include + + // standard includes + #include + #include + #include + + // local includes + #include "src/platform/macos/mouse_utils.h" + +using platf::macos::mouse::bounds_t; +using platf::macos::mouse::clamp_to_layout; +using platf::macos::mouse::layout_bounds; +using platf::macos::mouse::move_event_button; +using platf::macos::mouse::move_event_type; +using platf::macos::mouse::rect_t; + +namespace { + std::optional bounds_of(std::vector rects) { + return layout_bounds(rects); + } +} // namespace + +TEST(MacosMouseUtils, LayoutBoundsSpansTheWholeLayout) { + const auto bounds = bounds_of({{0, 0, 1920, 1080}, {-2560, 40, 2560, 1440}}); + ASSERT_TRUE(bounds.has_value()); + EXPECT_DOUBLE_EQ(-2560.0, bounds->min_x); + EXPECT_DOUBLE_EQ(0.0, bounds->min_y); + EXPECT_DOUBLE_EQ(1920.0, bounds->max_x); + EXPECT_DOUBLE_EQ(1480.0, bounds->max_y); +} + +TEST(MacosMouseUtils, LayoutBoundsIsEmptyWithoutDisplays) { + EXPECT_FALSE(bounds_of({}).has_value()); +} + +TEST(MacosMouseUtils, ClampKeepsSecondaryDisplaysReachable) { + // A secondary display to the left has a negative origin: clamping to the main display only + // would make it unreachable. + const bounds_t bounds {-2560, 0, 1920, 1480}; + + const auto left = clamp_to_layout({-9000, 500}, bounds); + EXPECT_DOUBLE_EQ(-2560.0, left.x); + EXPECT_DOUBLE_EQ(500.0, left.y); + + const auto above = clamp_to_layout({100, -700}, bounds); + EXPECT_DOUBLE_EQ(100.0, above.x); + EXPECT_DOUBLE_EQ(0.0, above.y); +} + +TEST(MacosMouseUtils, ClampStopsJustInsideTheOuterEdge) { + const bounds_t bounds {0, 0, 1920, 1080}; + + const auto right = clamp_to_layout({5000, 500}, bounds); + EXPECT_DOUBLE_EQ(1919.0, right.x); + + const auto below = clamp_to_layout({500, 5000}, bounds); + EXPECT_DOUBLE_EQ(1079.0, below.y); +} + +TEST(MacosMouseUtils, ClampPreservesFractionalPoints) { + const bounds_t bounds {0, 0, 1920, 1080}; + + const auto point = clamp_to_layout({640.5, 360.25}, bounds); + EXPECT_DOUBLE_EQ(640.5, point.x); + EXPECT_DOUBLE_EQ(360.25, point.y); +} + +TEST(MacosMouseUtils, RepeatedDeltaAccumulatesExactlyOnce) { + const bounds_t bounds {0, 0, 1920, 1080}; + CGPoint point {100, 100}; + for (int i = 0; i < 100; ++i) { + point = clamp_to_layout({point.x + 3, point.y + 2}, bounds); + } + + EXPECT_DOUBLE_EQ(400.0, point.x); + EXPECT_DOUBLE_EQ(300.0, point.y); +} + +TEST(MacosMouseUtils, AlternatingDeltaNetsToZero) { + const bounds_t bounds {0, 0, 1920, 1080}; + CGPoint point {900, 500}; + for (int i = 0; i < 200; ++i) { + const double delta = (i % 2 == 0) ? 7 : -7; + point = clamp_to_layout({point.x + delta, point.y}, bounds); + } + + EXPECT_DOUBLE_EQ(900.0, point.x); + EXPECT_DOUBLE_EQ(500.0, point.y); +} + +TEST(MacosMouseUtils, MoveTypeFollowsTheHeldButton) { + EXPECT_EQ(kCGEventMouseMoved, move_event_type(false, false, false)); + EXPECT_EQ(kCGEventLeftMouseDragged, move_event_type(true, false, false)); + EXPECT_EQ(kCGEventRightMouseDragged, move_event_type(false, true, false)); + EXPECT_EQ(kCGEventOtherMouseDragged, move_event_type(false, false, true)); + + // Only one drag type can describe a move, and macOS reports the left button first. + EXPECT_EQ(kCGEventLeftMouseDragged, move_event_type(true, true, true)); +} + +TEST(MacosMouseUtils, MoveButtonMatchesTheMoveType) { + EXPECT_EQ(kCGMouseButtonLeft, move_event_button(kCGEventMouseMoved)); + EXPECT_EQ(kCGMouseButtonLeft, move_event_button(kCGEventLeftMouseDragged)); + EXPECT_EQ(kCGMouseButtonRight, move_event_button(kCGEventRightMouseDragged)); + EXPECT_EQ(kCGMouseButtonCenter, move_event_button(kCGEventOtherMouseDragged)); +} + +#endif // __APPLE__