From 2c69c17346ba8708a580cfd6decd5792988a2a6b Mon Sep 17 00:00:00 2001 From: NullPointerDepressiveDisorder <96403086+NullPointerDepressiveDisorder@users.noreply.github.com> Date: Thu, 12 Feb 2026 00:33:19 -0800 Subject: [PATCH 1/9] Refactor mouse event handling to use current position instead of target position; optimize touch data copying in MultitouchManager to reduce GC pressure. --- MiddleDrag/Core/MouseEventGenerator.swift | 8 +--- MiddleDrag/Managers/MultitouchManager.swift | 41 ++++++++------------- 2 files changed, 18 insertions(+), 31 deletions(-) diff --git a/MiddleDrag/Core/MouseEventGenerator.swift b/MiddleDrag/Core/MouseEventGenerator.swift index 8b313e5..70228da 100644 --- a/MiddleDrag/Core/MouseEventGenerator.swift +++ b/MiddleDrag/Core/MouseEventGenerator.swift @@ -163,22 +163,18 @@ final class MouseEventGenerator: @unchecked Sendable { } let currentPos = currentMouseLocationQuartz - let targetPos = CGPoint( - x: currentPos.x + smoothedDeltaX, - y: currentPos.y + smoothedDeltaY - ) guard shouldPostEvents else { return } guard let event = CGEvent( mouseEventSource: eventSource, mouseType: .otherMouseDragged, - mouseCursorPosition: targetPos, + mouseCursorPosition: currentPos, mouseButton: .center ) else { return } - // Set deltas for macOS cursor movement; position field for apps that read it + // Use relative deltas instead of absolute positioning event.setDoubleValueField(.mouseEventDeltaX, value: Double(smoothedDeltaX)) event.setDoubleValueField(.mouseEventDeltaY, value: Double(smoothedDeltaY)) diff --git a/MiddleDrag/Managers/MultitouchManager.swift b/MiddleDrag/Managers/MultitouchManager.swift index 29397ec..92977c6 100644 --- a/MiddleDrag/Managers/MultitouchManager.swift +++ b/MiddleDrag/Managers/MultitouchManager.swift @@ -652,42 +652,33 @@ extension MultitouchManager: DeviceMonitorDelegate { // CGEventSource.flagsState is thread-safe and can be called from any thread let modifierFlags = CGEventSource.flagsState(.hidSystemState) - // CRITICAL: Copy touch data synchronously during this callback. // The touches pointer is only valid for the duration of this callback. - // The MultitouchSupport framework owns this memory and may free/reuse it - // immediately after the callback returns. We MUST copy the data before - // dispatching to the gesture queue. + // Copy touch data via raw memcpy — much cheaper than Swift Array allocation + // + map closure that was causing per-frame GC pressure and jitter at 100Hz+. let touchCount = Int(count) - let touchDataCopy: [MTTouch] + let touchesPtr: UnsafeMutableRawPointer? if touchCount > 0 { - let touchArray = unsafe touches.bindMemory(to: MTTouch.self, capacity: touchCount) - // Create a Swift array copy of the touch data - touchDataCopy = (0...stride + let buffer = UnsafeMutableRawPointer.allocate( + byteCount: byteCount, alignment: MemoryLayout.alignment) + unsafe buffer.copyMemory(from: touches, byteCount: byteCount) + touchesPtr = buffer } else { - touchDataCopy = [] + touchesPtr = nil } - // Gesture recognition and finger counting is done inside processTouches - // State updates happen in delegate callbacks dispatched to main thread - // IMPORTANT: We must call processTouches even with zero touches so the - // gesture recognizer can properly end gestures via stableFrameCount logic. gestureQueue.async { [weak self] in - if touchDataCopy.isEmpty { - // Zero touches - still need to notify gesture recognizer so it can - // properly end active gestures via stableFrameCount mechanism + if let buffer = touchesPtr { + defer { buffer.deallocate() } unsafe self?.gestureRecognizer.processTouches( - UnsafeMutableRawPointer(bitPattern: 1)!, // Non-null placeholder, won't be dereferenced when count=0 + buffer, count: touchCount, timestamp: timestamp, modifierFlags: modifierFlags) + } else { + // Zero touches — still notify so gesture recognizer can end via stableFrameCount + unsafe self?.gestureRecognizer.processTouches( + UnsafeMutableRawPointer(bitPattern: 1)!, count: 0, timestamp: timestamp, modifierFlags: modifierFlags) - } else { - // Use withUnsafeBufferPointer to get a pointer to our copied data - unsafe touchDataCopy.withUnsafeBufferPointer { buffer in - guard let baseAddress = buffer.baseAddress else { return } - let rawPointer = unsafe UnsafeMutableRawPointer(mutating: baseAddress) - unsafe self?.gestureRecognizer.processTouches( - rawPointer, count: touchCount, timestamp: timestamp, modifierFlags: modifierFlags) - } } } } From 677a462ba09076354c4962b98e30245a42677ae5 Mon Sep 17 00:00:00 2001 From: NullPointerDepressiveDisorder <96403086+NullPointerDepressiveDisorder@users.noreply.github.com> Date: Thu, 12 Feb 2026 17:53:58 -0800 Subject: [PATCH 2/9] Add accumulated drag position and cursor reassociation in MouseEventGenerator - Introduced `lastDragPosition` to track the accumulated drag position for improved cursor handling in applications requiring absolute positioning. - Added `reassociateCursor` method to restore normal cursor behavior after drag operations. - Updated drag handling to use accumulated position for mouse events, enhancing compatibility with various applications. - Ensured cursor is properly reassociated after drag ends or is canceled to prevent freezing issues. --- MiddleDrag/Core/MouseEventGenerator.swift | 70 +++++++++++++++++++++-- 1 file changed, 65 insertions(+), 5 deletions(-) diff --git a/MiddleDrag/Core/MouseEventGenerator.swift b/MiddleDrag/Core/MouseEventGenerator.swift index 70228da..60b496c 100644 --- a/MiddleDrag/Core/MouseEventGenerator.swift +++ b/MiddleDrag/Core/MouseEventGenerator.swift @@ -55,6 +55,12 @@ final class MouseEventGenerator: @unchecked Sendable { private var previousDeltaX: CGFloat = 0 private var previousDeltaY: CGFloat = 0 + // Accumulated drag position: seeded at startDrag from the real cursor, + // then advanced purely by adding deltas. Used as the event position field + // (for apps like Fusion 360 that read absolute position) and as the warp + // target (to move the visible cursor while disassociated). + private var lastDragPosition: CGPoint = .zero + // Click deduplication: tracks last click time on the serial eventQueue // to prevent multiple performClick() calls from different code paths // (force-click conversion + gesture tap) from firing within a short window. @@ -78,6 +84,18 @@ final class MouseEventGenerator: @unchecked Sendable { eventQueue.sync { _clickCount = 0 } } + // MARK: - Cursor Association + + /// Re-associate mouse with cursor after drag ends. + /// Restores normal cursor behavior and default suppression interval. + private func reassociateCursor() { + guard shouldPostEvents else { return } + CGAssociateMouseAndMouseCursorPosition(1) // true + if let source = eventSource { + source.localEventsSuppressionInterval = 0.25 + } + } + // MARK: - Initialization init() { @@ -94,6 +112,8 @@ final class MouseEventGenerator: @unchecked Sendable { // This handles the case where a second MIDDLE_DOWN arrives before the first MIDDLE_UP if isMiddleMouseDown { Log.warning("startDrag called while already dragging - canceling existing drag first", category: .gesture) + // Re-associate cursor from previous drag before starting new one + reassociateCursor() // Send mouse up for the existing drag immediately (synchronously) let currentPos = currentMouseLocationQuartz sendMiddleMouseUp(at: currentPos) @@ -105,6 +125,22 @@ final class MouseEventGenerator: @unchecked Sendable { previousDeltaX = 0 previousDeltaY = 0 + // Seed accumulated drag position from actual cursor + lastDragPosition = quartzPos + + // Disassociate mouse from cursor so the window server won't fight with our + // position field. The cursor freezes; we warp it ourselves each frame. + // This lets us set both absolute position (for Fusion 360) and deltas (for + // Blender/Unity) on the same event without causing micro-stutter. + if shouldPostEvents { + CGAssociateMouseAndMouseCursorPosition(0) // false + // Zero the suppression interval so our high-frequency synthetic events + // don't suppress each other (default is 0.25s which eats events) + if let source = eventSource { + source.localEventsSuppressionInterval = 0 + } + } + // Record activity time for watchdog activityLock.lock() lastActivityTime = CACurrentMediaTime() @@ -162,19 +198,28 @@ final class MouseEventGenerator: @unchecked Sendable { return } - let currentPos = currentMouseLocationQuartz + // Advance accumulated position by smoothed deltas + let targetPos = CGPoint( + x: lastDragPosition.x + smoothedDeltaX, + y: lastDragPosition.y + smoothedDeltaY + ) + lastDragPosition = targetPos guard shouldPostEvents else { return } guard let event = CGEvent( mouseEventSource: eventSource, mouseType: .otherMouseDragged, - mouseCursorPosition: currentPos, + mouseCursorPosition: targetPos, mouseButton: .center ) else { return } - // Use relative deltas instead of absolute positioning + // Set both integer and double delta fields — some apps (standard macOS, Blender) + // read double-precision deltas, others (Unity, Fusion 360, game engines) read + // integer deltas. The HID system populates both for real hardware events. + event.setIntegerValueField(.mouseEventDeltaX, value: Int64(smoothedDeltaX)) + event.setIntegerValueField(.mouseEventDeltaY, value: Int64(smoothedDeltaY)) event.setDoubleValueField(.mouseEventDeltaX, value: Double(smoothedDeltaX)) event.setDoubleValueField(.mouseEventDeltaY, value: Double(smoothedDeltaY)) @@ -182,14 +227,20 @@ final class MouseEventGenerator: @unchecked Sendable { event.setIntegerValueField(.eventSourceUserData, value: magicUserData) event.flags = [] event.post(tap: .cghidEventTap) + + // Warp the visible cursor to match the accumulated position. + // The cursor is frozen (disassociated) so the event's position field + // won't move it — we must warp explicitly. + CGWarpMouseCursorPosition(targetPos) } - - /// End the drag operation func endDrag() { guard isMiddleMouseDown else { return } // Stop watchdog since drag is ending normally stopWatchdog() + + // Re-associate cursor so it's no longer frozen + reassociateCursor() // CRITICAL: Set isMiddleMouseDown = false SYNCHRONOUSLY to match startDrag // This prevents race conditions with rapid start/end cycles and ensures @@ -284,6 +335,9 @@ final class MouseEventGenerator: @unchecked Sendable { // Stop watchdog since drag is being cancelled stopWatchdog() + + // Re-associate cursor so it's no longer frozen + reassociateCursor() // CRITICAL: Set isMiddleMouseDown = false SYNCHRONOUSLY to match startDrag // This prevents race conditions with rapid cancel/start cycles and ensures @@ -311,6 +365,9 @@ final class MouseEventGenerator: @unchecked Sendable { // Stop watchdog if running stopWatchdog() + // Re-associate cursor so it's no longer frozen + reassociateCursor() + // Atomically reset state and capture generation let currentGeneration: UInt64 = stateLock.withLock { _isMiddleMouseDown = false @@ -516,6 +573,9 @@ final class MouseEventGenerator: @unchecked Sendable { private func forceReleaseDrag(forGeneration expectedGeneration: UInt64) { stopWatchdogLocked() + // Re-associate cursor so it's no longer frozen + reassociateCursor() + // CRITICAL: Verify generation still matches before clearing state // This prevents race where a new drag started between checkForStuckDrag() and now let releasedGeneration: UInt64? = stateLock.withLock { From 06500447267b473cc84933b8e47b48d58ef4db05 Mon Sep 17 00:00:00 2001 From: NullPointerDepressiveDisorder <96403086+NullPointerDepressiveDisorder@users.noreply.github.com> Date: Thu, 12 Feb 2026 19:09:23 -0800 Subject: [PATCH 3/9] Enhance cursor re-association error handling in MouseEventGenerator; optimize touch data copying in MultitouchManager to reduce GC pressure --- MiddleDrag/Core/MouseEventGenerator.swift | 18 +++++++++++++----- MiddleDrag/Managers/MultitouchManager.swift | 10 +++++----- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/MiddleDrag/Core/MouseEventGenerator.swift b/MiddleDrag/Core/MouseEventGenerator.swift index 60b496c..737b123 100644 --- a/MiddleDrag/Core/MouseEventGenerator.swift +++ b/MiddleDrag/Core/MouseEventGenerator.swift @@ -90,7 +90,10 @@ final class MouseEventGenerator: @unchecked Sendable { /// Restores normal cursor behavior and default suppression interval. private func reassociateCursor() { guard shouldPostEvents else { return } - CGAssociateMouseAndMouseCursorPosition(1) // true + let error = CGAssociateMouseAndMouseCursorPosition(1) + if error != CGError.success { + Log.warning(unsafe "Failed to re-associate cursor: \(error.rawValue)", category: .gesture) + } if let source = eventSource { source.localEventsSuppressionInterval = 0.25 } @@ -133,7 +136,10 @@ final class MouseEventGenerator: @unchecked Sendable { // This lets us set both absolute position (for Fusion 360) and deltas (for // Blender/Unity) on the same event without causing micro-stutter. if shouldPostEvents { - CGAssociateMouseAndMouseCursorPosition(0) // false + let error = CGAssociateMouseAndMouseCursorPosition(0) + if error != CGError.success { + Log.warning(unsafe "Failed to disassociate cursor: \(error.rawValue)", category: .gesture) + } // Zero the suppression interval so our high-frequency synthetic events // don't suppress each other (default is 0.25s which eats events) if let source = eventSource { @@ -573,9 +579,6 @@ final class MouseEventGenerator: @unchecked Sendable { private func forceReleaseDrag(forGeneration expectedGeneration: UInt64) { stopWatchdogLocked() - // Re-associate cursor so it's no longer frozen - reassociateCursor() - // CRITICAL: Verify generation still matches before clearing state // This prevents race where a new drag started between checkForStuckDrag() and now let releasedGeneration: UInt64? = stateLock.withLock { @@ -593,6 +596,11 @@ final class MouseEventGenerator: @unchecked Sendable { return } + // Re-associate cursor AFTER confirming this is still the active drag. + // Doing this before the generation check would break a new drag's + // disassociate-and-warp mechanism. + reassociateCursor() + // Send mouse up event synchronously to ensure it gets through let currentPos = currentMouseLocationQuartz sendMiddleMouseUp(at: currentPos) diff --git a/MiddleDrag/Managers/MultitouchManager.swift b/MiddleDrag/Managers/MultitouchManager.swift index 92977c6..ff95a83 100644 --- a/MiddleDrag/Managers/MultitouchManager.swift +++ b/MiddleDrag/Managers/MultitouchManager.swift @@ -656,20 +656,20 @@ extension MultitouchManager: DeviceMonitorDelegate { // Copy touch data via raw memcpy — much cheaper than Swift Array allocation // + map closure that was causing per-frame GC pressure and jitter at 100Hz+. let touchCount = Int(count) - let touchesPtr: UnsafeMutableRawPointer? + nonisolated(unsafe) let touchesPtr: UnsafeMutableRawPointer? if touchCount > 0 { let byteCount = touchCount * MemoryLayout.stride let buffer = UnsafeMutableRawPointer.allocate( byteCount: byteCount, alignment: MemoryLayout.alignment) unsafe buffer.copyMemory(from: touches, byteCount: byteCount) - touchesPtr = buffer + unsafe touchesPtr = unsafe buffer } else { - touchesPtr = nil + unsafe touchesPtr = nil } gestureQueue.async { [weak self] in - if let buffer = touchesPtr { - defer { buffer.deallocate() } + if let buffer = unsafe touchesPtr { + defer { unsafe buffer.deallocate() } unsafe self?.gestureRecognizer.processTouches( buffer, count: touchCount, timestamp: timestamp, modifierFlags: modifierFlags) } else { From 0a84d3d86ff028390f29977716f224917c73815a Mon Sep 17 00:00:00 2001 From: NullPointerDepressiveDisorder <96403086+NullPointerDepressiveDisorder@users.noreply.github.com> Date: Thu, 12 Feb 2026 20:32:35 -0800 Subject: [PATCH 4/9] Refactor MouseEventGenerator to improve drag position handling and add clamping to display bounds; enhance test coverage for drag position accumulation and clamping behavior --- MiddleDrag/Core/MouseEventGenerator.swift | 35 ++- .../MouseEventGeneratorTests.swift | 244 ++++++++++++++++++ 2 files changed, 275 insertions(+), 4 deletions(-) diff --git a/MiddleDrag/Core/MouseEventGenerator.swift b/MiddleDrag/Core/MouseEventGenerator.swift index 737b123..807d8dc 100644 --- a/MiddleDrag/Core/MouseEventGenerator.swift +++ b/MiddleDrag/Core/MouseEventGenerator.swift @@ -59,7 +59,8 @@ final class MouseEventGenerator: @unchecked Sendable { // then advanced purely by adding deltas. Used as the event position field // (for apps like Fusion 360 that read absolute position) and as the warp // target (to move the visible cursor while disassociated). - private var lastDragPosition: CGPoint = .zero + // Internal access for testability. + internal var lastDragPosition: CGPoint = .zero // Click deduplication: tracks last click time on the serial eventQueue // to prevent multiple performClick() calls from different code paths @@ -205,10 +206,19 @@ final class MouseEventGenerator: @unchecked Sendable { } // Advance accumulated position by smoothed deltas - let targetPos = CGPoint( + var targetPos = CGPoint( x: lastDragPosition.x + smoothedDeltaX, y: lastDragPosition.y + smoothedDeltaY ) + + // Clamp to global display bounds to prevent the accumulated position from drifting + // off-screen. Without this, dragging past a screen edge creates a dead zone + // when reversing direction (the position must travel back the full overshoot + // before the cursor visibly moves). CGWarpMouseCursorPosition does not clamp. + let globalBounds = Self.globalDisplayBounds + targetPos.x = max(globalBounds.minX, min(targetPos.x, globalBounds.maxX - 1)) + targetPos.y = max(globalBounds.minY, min(targetPos.y, globalBounds.maxY - 1)) + lastDragPosition = targetPos guard shouldPostEvents else { return } @@ -224,8 +234,10 @@ final class MouseEventGenerator: @unchecked Sendable { // Set both integer and double delta fields — some apps (standard macOS, Blender) // read double-precision deltas, others (Unity, Fusion 360, game engines) read // integer deltas. The HID system populates both for real hardware events. - event.setIntegerValueField(.mouseEventDeltaX, value: Int64(smoothedDeltaX)) - event.setIntegerValueField(.mouseEventDeltaY, value: Int64(smoothedDeltaY)) + // Use .rounded() for integers to match hardware behavior: sub-pixel deltas + // like 0.7 should produce ±1, not 0 (truncation drops slow movements). + event.setIntegerValueField(.mouseEventDeltaX, value: Int64(smoothedDeltaX.rounded())) + event.setIntegerValueField(.mouseEventDeltaY, value: Int64(smoothedDeltaY.rounded())) event.setDoubleValueField(.mouseEventDeltaX, value: Double(smoothedDeltaX)) event.setDoubleValueField(.mouseEventDeltaY, value: Double(smoothedDeltaY)) @@ -401,6 +413,21 @@ final class MouseEventGenerator: @unchecked Sendable { } // MARK: - Coordinate Conversion + + /// Union of all online display rects in Quartz global coordinates. + /// Used to clamp the accumulated drag position to visible screen area. + /// Internal access for testability. + internal static var globalDisplayBounds: CGRect { + var displayIDs = [CGDirectDisplayID](repeating: 0, count: 16) + var displayCount: UInt32 = 0 + CGGetOnlineDisplayList(16, &displayIDs, &displayCount) + + var union = CGRect.null + for i in 0.. Date: Thu, 12 Feb 2026 20:46:38 -0800 Subject: [PATCH 5/9] Enhance display bounds caching and reconfiguration handling in MouseEventGenerator; ensure atomic cursor reassociation during drag release to prevent race conditions --- MiddleDrag/Core/MouseEventGenerator.swift | 52 ++++++++++++++++++----- 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/MiddleDrag/Core/MouseEventGenerator.swift b/MiddleDrag/Core/MouseEventGenerator.swift index 807d8dc..8ba8da0 100644 --- a/MiddleDrag/Core/MouseEventGenerator.swift +++ b/MiddleDrag/Core/MouseEventGenerator.swift @@ -415,9 +415,35 @@ final class MouseEventGenerator: @unchecked Sendable { // MARK: - Coordinate Conversion /// Union of all online display rects in Quartz global coordinates. - /// Used to clamp the accumulated drag position to visible screen area. + /// Cached to avoid per-frame syscalls and heap allocation at 100Hz+. + /// Invalidated on display reconfiguration via CGDisplayRegisterReconfigurationCallback. /// Internal access for testability. + private static let displayBoundsLock = NSLock() + nonisolated(unsafe) private static var _cachedDisplayBounds: CGRect? + nonisolated(unsafe) private static var _displayReconfigToken: Bool = { + // Register for display changes (resolution, arrangement, connect/disconnect). + // The callback invalidates the cache so the next read picks up the new geometry. + CGDisplayRegisterReconfigurationCallback({ _, flags, _ in + // Only invalidate after the reconfiguration completes + if flags.contains(.beginConfigurationFlag) { return } + MouseEventGenerator.displayBoundsLock.lock() + MouseEventGenerator._cachedDisplayBounds = nil + MouseEventGenerator.displayBoundsLock.unlock() + }, nil) + return true + }() + internal static var globalDisplayBounds: CGRect { + _ = _displayReconfigToken // Ensure callback is registered + + displayBoundsLock.lock() + if let cached = _cachedDisplayBounds { + displayBoundsLock.unlock() + return cached + } + displayBoundsLock.unlock() + + // Compute outside lock (CGGetOnlineDisplayList is thread-safe) var displayIDs = [CGDirectDisplayID](repeating: 0, count: 16) var displayCount: UInt32 = 0 CGGetOnlineDisplayList(16, &displayIDs, &displayCount) @@ -426,7 +452,13 @@ final class MouseEventGenerator: @unchecked Sendable { for i in 0.. Date: Thu, 12 Feb 2026 21:05:23 -0800 Subject: [PATCH 6/9] Refactor delta field setting in MouseEventGenerator to ensure proper order of double and integer values for improved compatibility with various applications --- MiddleDrag/Core/MouseEventGenerator.swift | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/MiddleDrag/Core/MouseEventGenerator.swift b/MiddleDrag/Core/MouseEventGenerator.swift index 8ba8da0..6227186 100644 --- a/MiddleDrag/Core/MouseEventGenerator.swift +++ b/MiddleDrag/Core/MouseEventGenerator.swift @@ -231,15 +231,15 @@ final class MouseEventGenerator: @unchecked Sendable { ) else { return } - // Set both integer and double delta fields — some apps (standard macOS, Blender) - // read double-precision deltas, others (Unity, Fusion 360, game engines) read - // integer deltas. The HID system populates both for real hardware events. - // Use .rounded() for integers to match hardware behavior: sub-pixel deltas - // like 0.7 should produce ±1, not 0 (truncation drops slow movements). - event.setIntegerValueField(.mouseEventDeltaX, value: Int64(smoothedDeltaX.rounded())) - event.setIntegerValueField(.mouseEventDeltaY, value: Int64(smoothedDeltaY.rounded())) + // Set both double and integer delta fields. CGEvent fields use single storage + // per field ID, so the last write wins. We set doubles first (sub-pixel precision + // for apps like Blender that read double deltas), then integers (rounded values + // for apps like Fusion 360/Unity that read integer deltas). This order ensures + // integer-reading apps see the properly rounded value, not a truncated double. event.setDoubleValueField(.mouseEventDeltaX, value: Double(smoothedDeltaX)) event.setDoubleValueField(.mouseEventDeltaY, value: Double(smoothedDeltaY)) + event.setIntegerValueField(.mouseEventDeltaX, value: Int64(smoothedDeltaX.rounded())) + event.setIntegerValueField(.mouseEventDeltaY, value: Int64(smoothedDeltaY.rounded())) event.setIntegerValueField(.mouseEventButtonNumber, value: 2) event.setIntegerValueField(.eventSourceUserData, value: magicUserData) From 9e6381543b3649ccb91225a05b20a6c7639514e4 Mon Sep 17 00:00:00 2001 From: NullPointerDepressiveDisorder <96403086+NullPointerDepressiveDisorder@users.noreply.github.com> Date: Thu, 12 Feb 2026 21:19:00 -0800 Subject: [PATCH 7/9] Refactor MouseEventGenerator to ensure atomic cursor reassociation and state updates during drag cancellation, preventing race conditions and improving drag state management. --- MiddleDrag/Core/MouseEventGenerator.swift | 35 +++++++++++------------ 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/MiddleDrag/Core/MouseEventGenerator.swift b/MiddleDrag/Core/MouseEventGenerator.swift index 6227186..d52426d 100644 --- a/MiddleDrag/Core/MouseEventGenerator.swift +++ b/MiddleDrag/Core/MouseEventGenerator.swift @@ -231,13 +231,9 @@ final class MouseEventGenerator: @unchecked Sendable { ) else { return } - // Set both double and integer delta fields. CGEvent fields use single storage - // per field ID, so the last write wins. We set doubles first (sub-pixel precision - // for apps like Blender that read double deltas), then integers (rounded values - // for apps like Fusion 360/Unity that read integer deltas). This order ensures - // integer-reading apps see the properly rounded value, not a truncated double. - event.setDoubleValueField(.mouseEventDeltaX, value: Double(smoothedDeltaX)) - event.setDoubleValueField(.mouseEventDeltaY, value: Double(smoothedDeltaY)) + // mouseEventDeltaX/Y are effectively integral in Quartz event storage. + // Writing via setDoubleValueField does not preserve fractional precision, so + // emit rounded integer deltas explicitly (better than implicit truncation). event.setIntegerValueField(.mouseEventDeltaX, value: Int64(smoothedDeltaX.rounded())) event.setIntegerValueField(.mouseEventDeltaY, value: Int64(smoothedDeltaY.rounded())) @@ -353,14 +349,15 @@ final class MouseEventGenerator: @unchecked Sendable { // Stop watchdog since drag is being cancelled stopWatchdog() - - // Re-associate cursor so it's no longer frozen - reassociateCursor() - // CRITICAL: Set isMiddleMouseDown = false SYNCHRONOUSLY to match startDrag - // This prevents race conditions with rapid cancel/start cycles and ensures - // updateDrag() stops processing immediately - isMiddleMouseDown = false + // CRITICAL: Re-associate cursor and clear drag state atomically. + // If reassociateCursor() runs outside stateLock, a concurrent startDrag() + // can disassociate for a new drag and then be overwritten by a stale + // reassociation, leaving cursor association out of sync with drag state. + stateLock.withLock { + reassociateCursor() + _isMiddleMouseDown = false + } // Asynchronously send the mouse up event and clean up state // The cleanup will happen on the event queue, ensuring proper sequencing @@ -382,12 +379,12 @@ final class MouseEventGenerator: @unchecked Sendable { // Stop watchdog if running stopWatchdog() - - // Re-associate cursor so it's no longer frozen - reassociateCursor() - - // Atomically reset state and capture generation + + // CRITICAL: Re-associate cursor and clear drag state atomically with generation. + // This mirrors forceReleaseDrag() and prevents interleaving where startDrag() + // disassociates for a new session between reassociation and state update. let currentGeneration: UInt64 = stateLock.withLock { + reassociateCursor() _isMiddleMouseDown = false return _dragGeneration } From 7337838ae514cabbd74e0adbc186ce9e728b162e Mon Sep 17 00:00:00 2001 From: NullPointerDepressiveDisorder <96403086+NullPointerDepressiveDisorder@users.noreply.github.com> Date: Thu, 12 Feb 2026 21:40:57 -0800 Subject: [PATCH 8/9] Refactor MouseEventGenerator to ensure atomic cursor reassociation and drag state updates during normal drag termination, preventing race conditions and enhancing overall drag state management. --- MiddleDrag/Core/MouseEventGenerator.swift | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/MiddleDrag/Core/MouseEventGenerator.swift b/MiddleDrag/Core/MouseEventGenerator.swift index d52426d..c8c177b 100644 --- a/MiddleDrag/Core/MouseEventGenerator.swift +++ b/MiddleDrag/Core/MouseEventGenerator.swift @@ -253,13 +253,14 @@ final class MouseEventGenerator: @unchecked Sendable { // Stop watchdog since drag is ending normally stopWatchdog() - // Re-associate cursor so it's no longer frozen - reassociateCursor() - - // CRITICAL: Set isMiddleMouseDown = false SYNCHRONOUSLY to match startDrag - // This prevents race conditions with rapid start/end cycles and ensures - // updateDrag() stops processing immediately - isMiddleMouseDown = false + // CRITICAL: Re-associate cursor and clear drag state atomically. + // Today endDrag() is called on gestureQueue, but matching the atomic teardown + // used by cancel/force paths prevents future callers from introducing races + // where stale reassociation can desync cursor association from drag state. + stateLock.withLock { + reassociateCursor() + _isMiddleMouseDown = false + } eventQueue.async { [weak self] in guard let self = self else { return } From 19452b60813c25b1bcfbd931284676ab3207bba8 Mon Sep 17 00:00:00 2001 From: NullPointerDepressiveDisorder <96403086+NullPointerDepressiveDisorder@users.noreply.github.com> Date: Thu, 12 Feb 2026 22:03:40 -0800 Subject: [PATCH 9/9] Refactor MouseEventGenerator to enhance drag state management by ensuring atomic cancellation of existing drags, preventing race conditions during new drag initiation. --- MiddleDrag/Core/MouseEventGenerator.swift | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/MiddleDrag/Core/MouseEventGenerator.swift b/MiddleDrag/Core/MouseEventGenerator.swift index c8c177b..91eb4a8 100644 --- a/MiddleDrag/Core/MouseEventGenerator.swift +++ b/MiddleDrag/Core/MouseEventGenerator.swift @@ -112,12 +112,19 @@ final class MouseEventGenerator: @unchecked Sendable { /// Start a middle mouse drag operation /// - Parameter screenPosition: Starting position (used for reference, actual position from current cursor) func startDrag(at screenPosition: CGPoint) { - // CRITICAL: If already in a drag state, cancel it first to prevent stuck drags - // This handles the case where a second MIDDLE_DOWN arrives before the first MIDDLE_UP - if isMiddleMouseDown { - Log.warning("startDrag called while already dragging - canceling existing drag first", category: .gesture) - // Re-associate cursor from previous drag before starting new one + // CRITICAL: If already in a drag state, cancel it first to prevent stuck drags. + // Recovery must re-associate + clear state + invalidate generation atomically + // so watchdog force-release cannot win a race and reassociate after we + // disassociate for the new drag. + let recoveredExistingDrag = stateLock.withLock { + guard _isMiddleMouseDown else { return false } reassociateCursor() + _isMiddleMouseDown = false + _dragGeneration &+= 1 // Invalidate any watchdog release in-flight for old drag + return true + } + if recoveredExistingDrag { + Log.warning("startDrag called while already dragging - canceling existing drag first", category: .gesture) // Send mouse up for the existing drag immediately (synchronously) let currentPos = currentMouseLocationQuartz sendMiddleMouseUp(at: currentPos)