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
225 changes: 224 additions & 1 deletion MiddleDrag/Core/MouseEventGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ final class MouseEventGenerator: @unchecked Sendable {

/// Minimum movement threshold in pixels to prevent jitter
var minimumMovementThreshold: CGFloat = 0.5

/// Timeout in seconds for stuck drag detection (no activity = stuck)
/// After this many seconds without updateDrag calls, the drag is auto-released
var stuckDragTimeout: TimeInterval = 10.0

// State tracking - protected by stateLock for thread safety
// isMiddleMouseDown is read from multiple threads (updateDrag on gesture queue,
Expand All @@ -26,10 +30,20 @@ final class MouseEventGenerator: @unchecked Sendable {
set { stateLock.withLock { _isMiddleMouseDown = newValue } }
}

/// Drag session generation counter - protected by stateLock
/// Must be accessed atomically with isMiddleMouseDown to prevent race conditions
private var _dragGeneration: UInt64 = 0

private var eventSource: CGEventSource?

// Event generation queue for thread safety
private let eventQueue = DispatchQueue(label: "com.middledrag.mouse", qos: .userInitiated)

// Watchdog timer for stuck drag detection
private var watchdogTimer: DispatchSourceTimer?
private let watchdogQueue = DispatchQueue(label: "com.middledrag.watchdog", qos: .utility)
private var lastActivityTime: CFTimeInterval = 0
private let activityLock = NSLock()

// Smoothing state for EMA (exponential moving average)
private var previousDeltaX: CGFloat = 0
Expand All @@ -53,6 +67,15 @@ 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)
// Send mouse up for the existing drag immediately (synchronously)
let currentPos = currentMouseLocationQuartz
sendMiddleMouseUp(at: currentPos)
}

// Initialize position synchronously to prevent race conditions with updateDrag
let quartzPos = currentMouseLocationQuartz
positionLock.lock()
Expand All @@ -62,6 +85,11 @@ final class MouseEventGenerator: @unchecked Sendable {
// Reset smoothing state
previousDeltaX = 0
previousDeltaY = 0

// Record activity time for watchdog
activityLock.lock()
lastActivityTime = CACurrentMediaTime()
activityLock.unlock()

// CRITICAL: Both flag AND mouse-down event must be set/sent SYNCHRONOUSLY.
// This prevents two race conditions:
Expand All @@ -70,8 +98,17 @@ final class MouseEventGenerator: @unchecked Sendable {
//
// sendMiddleMouseDown() just creates and posts a CGEvent, which is thread-safe
// and takes microseconds. No need for async dispatch here.
isMiddleMouseDown = true
//
// CRITICAL: isMiddleMouseDown and dragGeneration must be set ATOMICALLY
// to prevent race with forceReleaseDrag() on watchdogQueue
stateLock.withLock {
_isMiddleMouseDown = true
_dragGeneration &+= 1
}
sendMiddleMouseDown(at: quartzPos)

// Start watchdog timer to detect stuck drags
startWatchdog()
}

/// Magic number to identify our own events (0x4D44 = 'MD')
Expand All @@ -83,6 +120,11 @@ final class MouseEventGenerator: @unchecked Sendable {
/// - deltaY: Vertical movement delta
func updateDrag(deltaX: CGFloat, deltaY: CGFloat) {
guard isMiddleMouseDown else { return }

// Record activity time for watchdog (drag is still active)
activityLock.lock()
lastActivityTime = CACurrentMediaTime()
activityLock.unlock()

// Apply consistent smoothing to both horizontal and vertical movement
// Uses the user's configured smoothing factor for both axes
Expand Down Expand Up @@ -213,6 +255,9 @@ final class MouseEventGenerator: @unchecked Sendable {
/// 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
Expand Down Expand Up @@ -295,6 +340,9 @@ final class MouseEventGenerator: @unchecked Sendable {
/// Cancel any active drag operation
func cancelDrag() {
guard isMiddleMouseDown else { return }

// 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
Expand All @@ -316,6 +364,45 @@ final class MouseEventGenerator: @unchecked Sendable {
self.sendMiddleMouseUp(at: currentPos)
}
}

/// Force send a MIDDLE_UP event regardless of internal state
/// Used for manual recovery when system state may be out of sync with our tracking
/// Unlike cancelDrag(), this ALWAYS sends an UP event even if isMiddleMouseDown is false
func forceMiddleMouseUp() {
Log.warning("Force sending MIDDLE_UP (unconditional)", category: .gesture)

// Stop watchdog if running
stopWatchdog()

// Atomically reset state and capture generation
let currentGeneration: UInt64 = stateLock.withLock {
_isMiddleMouseDown = false
return _dragGeneration
}

// Always send the UP event synchronously
let pos = currentMouseLocationQuartz
sendMiddleMouseUp(at: pos)

// Also send async as fallback (in case sync was swallowed)
eventQueue.async { [weak self] in
guard let self = self else { return }

// Only proceed if no new drag started
let newGeneration = self.stateLock.withLock { self._dragGeneration }
guard newGeneration == currentGeneration else { return }

// Reset state and send another UP
self.previousDeltaX = 0
self.previousDeltaY = 0
self.positionLock.lock()
self.lastSentPosition = nil
self.positionLock.unlock()

let pos = self.currentMouseLocationQuartz
self.sendMiddleMouseUp(at: pos)
}
}

// MARK: - Coordinate Conversion

Expand Down Expand Up @@ -411,4 +498,140 @@ final class MouseEventGenerator: @unchecked Sendable {
event.flags = []
event.post(tap: .cghidEventTap)
}

// MARK: - Watchdog Timer

/// Start the watchdog timer to detect stuck drags
/// All watchdogTimer access is synchronized on watchdogQueue to prevent data races
private func startWatchdog() {
watchdogQueue.async { [weak self] in
guard let self = self else { return }

// Cancel any existing timer first
self.stopWatchdogLocked()

let timer = DispatchSource.makeTimerSource(queue: self.watchdogQueue)
timer.schedule(deadline: .now() + 1.0, repeating: 1.0) // Check every second

timer.setEventHandler { [weak self] in
self?.checkForStuckDrag()
}

self.watchdogTimer = timer
timer.resume()
}
}

/// Stop the watchdog timer (thread-safe wrapper)
private func stopWatchdog() {
watchdogQueue.async { [weak self] in
self?.stopWatchdogLocked()
}
}

/// Stop the watchdog timer - must be called only on watchdogQueue
private func stopWatchdogLocked() {
watchdogTimer?.cancel()
watchdogTimer = nil
}

/// Check if the drag has become stuck (no activity for too long)
/// Called on watchdogQueue
private func checkForStuckDrag() {
// CRITICAL: Atomically read both isMiddleMouseDown AND generation
// This captures which drag session we're checking, preventing race with startDrag()
let (isDragging, capturedGeneration): (Bool, UInt64) = stateLock.withLock {
(_isMiddleMouseDown, _dragGeneration)
}

guard isDragging else {
// Drag already ended, stop checking
stopWatchdogLocked()
return
}

activityLock.lock()
let lastActivity = lastActivityTime
activityLock.unlock()

let timeSinceActivity = CACurrentMediaTime() - lastActivity

if timeSinceActivity > stuckDragTimeout {
// Drag appears to be stuck - auto-release
Log.warning(
"Stuck drag detected - no activity for \(String(format: "%.1f", timeSinceActivity))s, auto-releasing",
category: .gesture
)

// Log to Sentry if telemetry is enabled
if CrashReporter.shared.anyTelemetryEnabled {
let attributes: [String: Any] = unsafe [
"category": "gesture",
"event": "stuck_drag_auto_release",
"time_since_activity": timeSinceActivity,
"timeout_threshold": stuckDragTimeout,
"session_id": Log.sessionID,
]
unsafe SentrySDK.logger.warn("Stuck drag auto-released after timeout", attributes: attributes)
}

// Force release the stuck drag, passing the generation we captured
// This ensures we don't interfere with a new drag that started since our check
forceReleaseDrag(forGeneration: capturedGeneration)
}
}

/// Force release a stuck drag without normal cleanup flow
/// This is called by the watchdog when a drag appears stuck
/// Called on watchdogQueue
/// - Parameter forGeneration: The generation that was captured when deciding to release.
/// If current generation doesn't match, a new drag has started and we abort.
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
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
return _dragGeneration
}

// If generation didn't match, abort without sending any events
guard let releasedGeneration = releasedGeneration else {
return
}

// Send mouse up event synchronously to ensure it gets through
let currentPos = currentMouseLocationQuartz
sendMiddleMouseUp(at: currentPos)

// Also try sending via async queue in case the sync one gets swallowed
// Only proceeds if no new drag has started (same generation)
eventQueue.async { [weak self] in
guard let self = self else { return }

// Check if a new drag started - don't interfere with it
let currentGeneration = self.stateLock.withLock { self._dragGeneration }
guard currentGeneration == releasedGeneration else {
Log.info("Skipping async force-release cleanup - new drag session started", category: .gesture)
return
}

// Reset smoothing state
self.previousDeltaX = 0
self.previousDeltaY = 0
self.positionLock.lock()
self.lastSentPosition = nil
self.positionLock.unlock()

// Send another mouse up as a fallback
let pos = self.currentMouseLocationQuartz
self.sendMiddleMouseUp(at: pos)
}
}
}
26 changes: 26 additions & 0 deletions MiddleDrag/Managers/MultitouchManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,32 @@ final class MultitouchManager: @unchecked Sendable {
configuration = config
applyConfiguration()
}

/// Force release any stuck middle-drag state
/// This can be called manually by the user (e.g., from menu bar) if they notice
/// the middle button is stuck. It sends a MIDDLE_UP event regardless of current state.
func forceReleaseStuckDrag() {
// Dispatch to main thread to avoid data races with gesture state updates
// which are also dispatched to main thread (see GestureRecognizerDelegate methods)
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }

Log.info("Force releasing stuck drag (user triggered)", category: .gesture)

// Reset all internal state
self.isActivelyDragging = false
self.isInThreeFingerGesture = false
self.gestureEndTime = CACurrentMediaTime()
self.lastGestureWasActive = false

// Force send MIDDLE_UP unconditionally
// Unlike cancelDrag(), this always sends UP even if internal state is already false
self.mouseGenerator.forceMiddleMouseUp()

// Also reset the gesture recognizer to ensure clean state
self.gestureRecognizer.reset()
}
}

// MARK: - Event Tap

Expand Down
Loading
Loading