Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 154 additions & 26 deletions MiddleDrag/Core/MouseEventGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ 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).
// 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
// (force-click conversion + gesture tap) from firing within a short window.
Expand All @@ -78,6 +85,21 @@ 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 }
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
}
}

// MARK: - Initialization

init() {
Expand All @@ -90,9 +112,18 @@ 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 {
// 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
Expand All @@ -105,6 +136,25 @@ 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 {
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 {
source.localEventsSuppressionInterval = 0
}
}

// Record activity time for watchdog
activityLock.lock()
lastActivityTime = CACurrentMediaTime()
Expand Down Expand Up @@ -162,12 +212,22 @@ final class MouseEventGenerator: @unchecked Sendable {
return
}

let currentPos = currentMouseLocationQuartz
let targetPos = CGPoint(
x: currentPos.x + smoothedDeltaX,
y: currentPos.y + smoothedDeltaY
// Advance accumulated position by smoothed deltas
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 }
guard
let event = CGEvent(
Expand All @@ -178,27 +238,36 @@ final class MouseEventGenerator: @unchecked Sendable {
)
else { return }

// Set deltas for macOS cursor movement; position field for apps that read it
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()))

event.setIntegerValueField(.mouseEventButtonNumber, value: 2)
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()

// 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 }
Expand Down Expand Up @@ -289,10 +358,14 @@ final class MouseEventGenerator: @unchecked Sendable {
// Stop watchdog since drag is being cancelled
stopWatchdog()

// 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
Expand All @@ -314,9 +387,12 @@ final class MouseEventGenerator: @unchecked Sendable {

// Stop watchdog if running
stopWatchdog()

// 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
}
Expand All @@ -342,6 +418,53 @@ final class MouseEventGenerator: @unchecked Sendable {
}

// MARK: - Coordinate Conversion

/// Union of all online display rects in Quartz global coordinates.
/// 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)

var union = CGRect.null
for i in 0..<Int(displayCount) {
union = union.union(CGDisplayBounds(displayIDs[i]))
}
let result = union == .null ? CGRect(x: 0, y: 0, width: 1920, height: 1080) : union

displayBoundsLock.lock()
_cachedDisplayBounds = result
displayBoundsLock.unlock()

return result
}

/// Reads mouse location via AppKit on the main thread and converts to Quartz coordinates.
/// AppKit APIs like NSEvent/NSScreen are not thread-safe and must not be read off-main.
Expand Down Expand Up @@ -520,15 +643,20 @@ final class MouseEventGenerator: @unchecked Sendable {
private func forceReleaseDrag(forGeneration expectedGeneration: UInt64) {
stopWatchdogLocked()

// CRITICAL: Verify generation still matches before clearing state
// This prevents race where a new drag started between checkForStuckDrag() and now
// CRITICAL: Verify generation, clear state, AND re-associate cursor atomically.
// If we release the lock between setting _isMiddleMouseDown = false and calling
// reassociateCursor(), a concurrent startDrag on the gesture queue could
// disassociate the cursor for a new drag, then our reassociateCursor() would
// undo it — causing double-speed movement in the new drag.
// CGAssociateMouseAndMouseCursorPosition is a fast syscall (~microseconds),
// safe to call under lock.
let releasedGeneration: UInt64? = stateLock.withLock {
guard _dragGeneration == expectedGeneration else {
// A new drag has started - don't interfere with it!
Log.info("forceReleaseDrag aborted - new drag session started (expected gen \(expectedGeneration), current \(_dragGeneration))", category: .gesture)
return nil
}
_isMiddleMouseDown = false
reassociateCursor()
return _dragGeneration
}

Expand Down
41 changes: 16 additions & 25 deletions MiddleDrag/Managers/MultitouchManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
nonisolated(unsafe) 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..<touchCount).map { unsafe touchArray[$0] }
let byteCount = touchCount * MemoryLayout<MTTouch>.stride
let buffer = UnsafeMutableRawPointer.allocate(
byteCount: byteCount, alignment: MemoryLayout<MTTouch>.alignment)
unsafe buffer.copyMemory(from: touches, byteCount: byteCount)
unsafe touchesPtr = unsafe buffer
} else {
touchDataCopy = []
unsafe 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 = unsafe touchesPtr {
defer { unsafe 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)
}
}
}
}
Expand Down
Loading
Loading