fix: capture the mouse on macOS while the cursor is on a client
Some checks failed
CodeQL Analysis / codeql (push) Has been cancelled
Continuous Integration / lint-reuse (push) Has been cancelled
SonarCloud Analysis / sonar (push) Has been cancelled
Continuous Integration / ci-passed (push) Has been cancelled
Continuous Integration / test-results (push) Has been cancelled
Continuous Integration / lint-clang (push) Has been cancelled
Continuous Integration / analyze-valgrind (push) Has been cancelled
Continuous Integration / ${{ matrix.target.name }} (map[arch:amd64 config-args:-G Ninja name:windows-2022-x64 qt-version:6.10.3 runs-on:windows-2022 timeout:30 vcpkg-triplet:x64-windows-release]) (push) Has been cancelled
Continuous Integration / ${{ matrix.target.name }} (map[arch:arm64 config-args:-G Ninja name:windows-2022-arm64 qt-version:6.11.1 runs-on:windows-11-arm timeout:30 vcpkg-triplet:arm64-windows]) (push) Has been cancelled
Continuous Integration / ${{ matrix.target.name }} (map[config-args:-DCMAKE_OSX_ARCHITECTURES="arm64" -DCMAKE_OSX_DEPLOYMENT_TARGET=14 name:macos-arm64 qt-version:6.11.1 runs-on:macos-15 timeout:10]) (push) Has been cancelled
Continuous Integration / ${{ matrix.target.name }} (map[config-args:-DCMAKE_OSX_ARCHITECTURES="x86_64" -DCMAKE_OSX_DEPLOYMENT_TARGET=12 -DCMAKE_OSX_SYSROOT=/Applications/Xcode_16.4.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk name:macos-x64 qt-ver… (push) Has been cancelled
Continuous Integration / ${{ matrix.target.name }} (map[config-args:-G Ninja -DCMAKE_INSTALL_PREFIX=/usr -DBUILD_DEV_DOCS=ON container:archlinux:latest like:arch name:archlinux-x86_64 runs-on:ubuntu-latest timeout:20]) (push) Has been cancelled
Continuous Integration / ${{ matrix.target.name }} (map[config-args:-G Ninja -DCMAKE_INSTALL_PREFIX=/usr container:debian:stable-slim like:debian name:debian-arm64 runs-on:ubuntu-24.04-arm timeout:20]) (push) Has been cancelled
Continuous Integration / ${{ matrix.target.name }} (map[config-args:-G Ninja -DCMAKE_INSTALL_PREFIX=/usr container:debian:stable-slim like:debian name:debian-x86_64 runs-on:ubuntu-latest timeout:20]) (push) Has been cancelled
Continuous Integration / ${{ matrix.target.name }} (map[config-args:-G Ninja -DCMAKE_INSTALL_PREFIX=/usr container:debian:testing-slim like:debian name:debian-testing-arm64 runs-on:ubuntu-24.04-arm timeout:20]) (push) Has been cancelled
Continuous Integration / ${{ matrix.target.name }} (map[config-args:-G Ninja -DCMAKE_INSTALL_PREFIX=/usr container:debian:testing-slim like:debian name:debian-testing-x86_64 runs-on:ubuntu-latest timeout:20]) (push) Has been cancelled
Continuous Integration / ${{ matrix.target.name }} (map[config-args:-G Ninja -DCMAKE_INSTALL_PREFIX=/usr container:fedora:43 like:fedora name:fedora-43-arm64 runs-on:ubuntu-24.04-arm timeout:20]) (push) Has been cancelled
Continuous Integration / ${{ matrix.target.name }} (map[config-args:-G Ninja -DCMAKE_INSTALL_PREFIX=/usr container:fedora:43 like:fedora name:fedora-43-x86_64 runs-on:ubuntu-latest timeout:20]) (push) Has been cancelled
Continuous Integration / ${{ matrix.target.name }} (map[config-args:-G Ninja -DCMAKE_INSTALL_PREFIX=/usr container:fedora:44 like:fedora name:fedora-44-arm64 runs-on:ubuntu-24.04-arm timeout:20]) (push) Has been cancelled
Continuous Integration / ${{ matrix.target.name }} (map[config-args:-G Ninja -DCMAKE_INSTALL_PREFIX=/usr container:fedora:44 like:fedora name:fedora-44-x86_64 runs-on:ubuntu-latest timeout:20]) (push) Has been cancelled
Continuous Integration / ${{ matrix.target.name }} (map[config-args:-G Ninja -DCMAKE_INSTALL_PREFIX=/usr container:opensuse/tumbleweed:latest like:suse name:opensuse-arm64 runs-on:ubuntu-24.04-arm timeout:20]) (push) Has been cancelled
Continuous Integration / ${{ matrix.target.name }} (map[config-args:-G Ninja -DCMAKE_INSTALL_PREFIX=/usr container:opensuse/tumbleweed:latest like:suse name:opensuse-x86_64 runs-on:ubuntu-latest timeout:20]) (push) Has been cancelled
Continuous Integration / ${{ matrix.target.name }} (map[config-args:-G Ninja -DCMAKE_INSTALL_PREFIX=/usr container:ubuntu:26.04 like:debian name:ubuntu-26.04-arm64 runs-on:ubuntu-24.04-arm timeout:20]) (push) Has been cancelled
Continuous Integration / ${{ matrix.target.name }} (map[config-args:-G Ninja -DCMAKE_INSTALL_PREFIX=/usr container:ubuntu:26.04 like:debian name:ubuntu-26.04-x86_64 runs-on:ubuntu-latest timeout:20]) (push) Has been cancelled
Continuous Integration / unix-${{ matrix.distro.name }} (map[name:freebsd]) (push) Has been cancelled
Continuous Integration / flatpak-${{matrix.flatpak.arch}} (map[arch:aarch64 runs-on:ubuntu-24.04-arm]) (push) Has been cancelled
Continuous Integration / flatpak-${{matrix.flatpak.arch}} (map[arch:x86_64 runs-on:ubuntu-latest]) (push) Has been cancelled
Continuous Integration / release (push) Has been cancelled

On the server, while the cursor is on a client, every physical mouse move
warped the local cursor back to screen center and the tap callback returned
the mouse-move event instead of consuming it. That leaked continuous motion
to whatever local app sat under the centered cursor (e.g. a fullscreen game
or a centered video reacting to the phantom movement).

Capture the mouse the way macOS games do instead: dissociate the hardware
mouse from the cursor in leave() so the cursor freezes and hides, read raw
deltas from the event in onMouseMove(), consume off-screen moves in the tap
callback, and re-associate in enter() (disable() already does via
showCursor()). This retires the center-warp and the "bogus motion" edge
heuristic that only existed to paper over the warp.

fixes: #5871
This commit is contained in:
tehreet 2026-07-11 20:42:28 -05:00 committed by Chris Rizzitello
parent 89a6c7f2ed
commit 96f544de6d
2 changed files with 39 additions and 64 deletions

View File

@ -112,7 +112,7 @@ private:
void sendClipboardEvent(EventTypes type, ClipboardID id) const;
// message handlers
bool onMouseMove();
bool onMouseMove(CGEventRef event);
// mouse button handler. pressed is true if this is a mousedown
// event, false if it is a mouseup event. macButton is the index
// of the button pressed using the mac button mapping.

View File

@ -769,6 +769,8 @@ void OSXScreen::enter()
showCursor();
if (m_isPrimary) {
// re-couple the mouse to the cursor, undoing the capture from leave()
CGAssociateMouseAndMouseCursorPosition(true);
setZeroSuppressionInterval();
} else {
// reset buttons
@ -798,8 +800,11 @@ void OSXScreen::leave()
if (m_isPrimary) {
avoidHesitatingCursor();
LOG_VERBOSE("centering cursor on leave: %+d, %+d", m_xCenter, m_yCenter);
warpCursor(m_xCenter, m_yCenter);
// capture the mouse: freeze the cursor so no local app sees motion while on
// a client (onMouseMove reads raw deltas instead). must follow hideCursor(),
// which re-associates. re-coupled in enter()/disable().
CGAssociateMouseAndMouseCursorPosition(false);
}
// now off screen
@ -958,64 +963,40 @@ void OSXScreen::handleSystemEvent(const Event &event)
}
}
bool OSXScreen::onMouseMove()
bool OSXScreen::onMouseMove(CGEventRef event)
{
// when we receive a mouse-move event, it is possible it was queued for a period
// and that the mouse has already moved again since then. to handle this, we need
// to query the current mouse position rather than using the position in the event.
CGEventRef event = CGEventCreate(NULL);
CGPoint pos = CGEventGetLocation(event);
CFRelease(event);
CGFloat mx = pos.x;
CGFloat my = pos.y;
LOG_VERBOSE("mouse move %+f,%+f", mx, my);
CGFloat x = mx - m_xCursor;
CGFloat y = my - m_yCursor;
if ((x == 0 && y == 0) || (mx == m_xCenter && mx == m_yCenter)) {
return true;
}
// save position to compute delta of next motion
m_xCursor = (int32_t)mx;
m_yCursor = (int32_t)my;
if (m_isOnScreen) {
// motion on primary screen
// motion on primary screen. the event may have been queued a while, so
// query the live cursor position rather than the stale event position.
CGEventRef posEvent = CGEventCreate(NULL);
CGPoint pos = CGEventGetLocation(posEvent);
CFRelease(posEvent);
CGFloat mx = pos.x;
CGFloat my = pos.y;
LOG_VERBOSE("mouse move %+f,%+f", mx, my);
CGFloat x = mx - m_xCursor;
CGFloat y = my - m_yCursor;
if ((x == 0 && y == 0) || (mx == m_xCenter && mx == m_yCenter)) {
return true;
}
m_xCursor = (int32_t)mx;
m_yCursor = (int32_t)my;
sendEvent(EventTypes::PrimaryScreenMotionOnPrimary, MotionInfo::alloc(m_xCursor, m_yCursor));
} else {
// motion on secondary screen. warp mouse back to
// center.
warpCursor(m_xCenter, m_yCenter);
// motion on secondary screen. the cursor is frozen (see leave()), so read
// raw deltas from the event instead of diffing position.
int32_t dx = (int32_t)CGEventGetIntegerValueField(event, kCGMouseEventDeltaX);
int32_t dy = (int32_t)CGEventGetIntegerValueField(event, kCGMouseEventDeltaY);
// examine the motion. if it's about the distance
// from the center of the screen to an edge then
// it's probably a bogus motion that we want to
// ignore (see warpCursorNoFlush() for a further
// description).
static int32_t bogusZoneSize = 10;
if (-x + bogusZoneSize > m_xCenter - m_x || x + bogusZoneSize > m_x + m_w - m_xCenter ||
-y + bogusZoneSize > m_yCenter - m_y || y + bogusZoneSize > m_y + m_h - m_yCenter) {
LOG_DEBUG("dropped bogus motion %+d,%+d", x, y);
} else {
// send motion
// Accumulate together the move into the running total
static CGFloat m_xFractionalMove = 0;
static CGFloat m_yFractionalMove = 0;
LOG_VERBOSE("mouse delta %+d,%+d", dx, dy);
m_xFractionalMove += x;
m_yFractionalMove += y;
// Return the integer part
int32_t intX = (int32_t)m_xFractionalMove;
int32_t intY = (int32_t)m_yFractionalMove;
// And keep only the fractional part
m_xFractionalMove -= intX;
m_yFractionalMove -= intY;
sendEvent(EventTypes::PrimaryScreenMotionOnSecondary, MotionInfo::alloc(intX, intY));
if (dx != 0 || dy != 0) {
sendEvent(EventTypes::PrimaryScreenMotionOnSecondary, MotionInfo::alloc(dx, dy));
}
}
@ -1690,15 +1671,9 @@ CGEventRef OSXScreen::handleCGInputEvent(CGEventTapProxy proxy, CGEventType type
case kCGEventRightMouseDragged:
case kCGEventOtherMouseDragged:
case kCGEventMouseMoved:
// we intentionally ignore the position in the event here as the events are
// queued and will no longer be accurate when we process them.
screen->onMouseMove();
// The system ignores our cursor-centering calls if
// we don't return the event. This should be harmless,
// but might register as slight movement to other apps
// on the system. It hasn't been a problem before, though.
return event;
// off-screen the cursor is frozen (see leave()), so fall through to consume
// the move below instead of returning (leaking) it to local apps.
screen->onMouseMove(event);
break;
case kCGEventScrollWheel:
screen->onMouseWheel(