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
4 changes: 4 additions & 0 deletions MiddleDrag/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
multitouchManager.start()
if multitouchManager.isMonitoring {
Log.info("Multitouch manager started", category: .app)
} else if multitouchManager.isPollingForDevices {
Log.info(
"Multitouch manager polling for device connections (e.g., Bluetooth trackpad)",
category: .device)
} else {
Log.warning(
"Multitouch manager inactive: no compatible multitouch hardware detected.",
Expand Down
16 changes: 8 additions & 8 deletions MiddleDrag/Core/MouseEventGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ final class MouseEventGenerator: @unchecked Sendable {
guard shouldPostEvents else { return }
let error = CGAssociateMouseAndMouseCursorPosition(1)
if error != CGError.success {
Log.warning(unsafe "Failed to re-associate cursor: \(error.rawValue)", category: .gesture)
Log.warning("Failed to re-associate cursor: \(error.rawValue)", category: .gesture)
}
if let source = eventSource {
source.localEventsSuppressionInterval = 0.25
Expand Down Expand Up @@ -146,7 +146,7 @@ final class MouseEventGenerator: @unchecked Sendable {
if shouldPostEvents {
let error = CGAssociateMouseAndMouseCursorPosition(0)
if error != CGError.success {
Log.warning(unsafe "Failed to disassociate cursor: \(error.rawValue)", category: .gesture)
Log.warning("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)
Expand Down Expand Up @@ -428,21 +428,21 @@ final class MouseEventGenerator: @unchecked Sendable {
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
unsafe CGDisplayRegisterReconfigurationCallback({ _, flags, _ in
// Only invalidate after the reconfiguration completes
if flags.contains(.beginConfigurationFlag) { return }
MouseEventGenerator.displayBoundsLock.lock()
MouseEventGenerator._cachedDisplayBounds = nil
unsafe MouseEventGenerator._cachedDisplayBounds = nil
MouseEventGenerator.displayBoundsLock.unlock()
}, nil)
return true
}()

internal static var globalDisplayBounds: CGRect {
_ = _displayReconfigToken // Ensure callback is registered
_ = unsafe _displayReconfigToken // Ensure callback is registered

displayBoundsLock.lock()
if let cached = _cachedDisplayBounds {
if let cached = unsafe _cachedDisplayBounds {
displayBoundsLock.unlock()
return cached
}
Expand All @@ -451,7 +451,7 @@ final class MouseEventGenerator: @unchecked Sendable {
// Compute outside lock (CGGetOnlineDisplayList is thread-safe)
var displayIDs = [CGDirectDisplayID](repeating: 0, count: 16)
var displayCount: UInt32 = 0
CGGetOnlineDisplayList(16, &displayIDs, &displayCount)
unsafe CGGetOnlineDisplayList(16, &displayIDs, &displayCount)

var union = CGRect.null
for i in 0..<Int(displayCount) {
Expand All @@ -460,7 +460,7 @@ final class MouseEventGenerator: @unchecked Sendable {
let result = union == .null ? CGRect(x: 0, y: 0, width: 1920, height: 1080) : union

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

return result
Expand Down
216 changes: 211 additions & 5 deletions MiddleDrag/Managers/MultitouchManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,20 @@ public final class MultitouchManager: @unchecked Sendable {
/// race conditions in the MultitouchSupport framework's internal thread.
static let minimumRestartInterval: TimeInterval = 0.6

/// Initial interval between polling attempts when no multitouch device is found at launch.
/// This handles Bluetooth trackpads that connect after login (common during boot).
/// 3 seconds is a good balance between responsiveness and low overhead.
static let devicePollingInterval: TimeInterval = 3.0

/// Maximum interval between polling attempts after exponential backoff.
/// Caps at 30 seconds to avoid excessive resource usage while still checking.
static let maxDevicePollingInterval: TimeInterval = 30.0

/// Maximum total polling duration before giving up (5 minutes).
/// If no device connects within this window, polling stops and the user
/// can manually re-enable via the menu bar.
static let maxPollingDuration: TimeInterval = 300.0

// MARK: - Properties

/// Current gesture configuration
Expand Down Expand Up @@ -95,6 +109,19 @@ public final class MultitouchManager: @unchecked Sendable {
private var isRestartInProgress = false
private var lastRestartCompletedTime: TimeInterval = 0

// Device polling for late-connecting devices (e.g., Bluetooth trackpads at login)
private var devicePollingTimer: DispatchSourceTimer?
/// Current polling interval — increases with exponential backoff.
/// Internal access for testability.
internal var currentPollingInterval: TimeInterval = 0
/// When polling started — used to enforce maxPollingDuration timeout.
/// Internal access for testability.
internal var pollingStartTime: TimeInterval = 0
/// Whether we are actively polling for multitouch device connections.
/// This is true when start() was called but no devices were found, so we're
/// periodically checking for devices that may connect later (e.g., Bluetooth trackpad at boot).
public private(set) var isPollingForDevices = false

// Processing queue
private let gestureQueue = DispatchQueue(label: "com.middledrag.gesture", qos: .userInteractive)

Expand Down Expand Up @@ -149,7 +176,7 @@ public final class MultitouchManager: @unchecked Sendable {

/// Start monitoring for gestures
public func start() {
guard !isMonitoring else { return }
guard !isMonitoring && !isPollingForDevices else { return }

applyConfiguration()
let eventTapSuccess = eventTapSetupFactory()
Expand All @@ -164,13 +191,18 @@ public final class MultitouchManager: @unchecked Sendable {

guard deviceMonitor?.start() == true else {
Log.warning(
"No compatible multitouch hardware detected. Gesture monitoring disabled.",
"No compatible multitouch hardware detected. Will poll for device connections.",
category: .device)
deviceMonitor?.stop()
deviceMonitor = nil
teardownEventTap()
isMonitoring = false
isEnabled = false

// Start polling for late-connecting devices (e.g., Bluetooth trackpad at boot).
// Also register wake observers so polling resumes after sleep.
addSleepWakeObservers()
startDevicePolling()
return
}

Expand All @@ -182,6 +214,9 @@ public final class MultitouchManager: @unchecked Sendable {

/// Stop monitoring
public func stop() {
// Stop device polling if active
stopDevicePolling()

// Clear restart state and cancel any pending restart work item.
// This must be done under lock to prevent data races with restart().
restartLock.lock()
Expand All @@ -204,12 +239,15 @@ public final class MultitouchManager: @unchecked Sendable {
/// Restart monitoring (used after sleep/wake)
public func restart() {
// Allow restart if either:
// 1. wakeObserver exists (normal production case after successful start)
// 1. wakeObserver exists (normal production case after successful start, or polling state)
// 2. isMonitoring is true (for test scenarios where event tap setup may fail)
// Using wakeObserver allows retry after failed restart (when isMonitoring=false)
// because internalStop() sets isMonitoring=false before setupEventTap() runs
guard wakeObserver != nil || isMonitoring else { return }

// Stop device polling if active — restart will re-evaluate device availability
stopDevicePolling()

// Prevent concurrent restart operations - this is critical to avoid race conditions
// when rapid foreground/background toggling triggers multiple restart() calls.
// The MultitouchSupport framework's internal thread can crash (EXC_BREAKPOINT) if
Expand Down Expand Up @@ -301,14 +339,15 @@ public final class MultitouchManager: @unchecked Sendable {

guard deviceMonitor?.start() == true else {
Log.warning(
"Restart aborted: no compatible multitouch hardware detected.",
"Restart: no compatible multitouch hardware detected. Will poll for device connections.",
category: .device)
deviceMonitor?.stop()
deviceMonitor = nil
teardownEventTap()
isMonitoring = false
isEnabled = false
removeSleepWakeObservers()
// Start polling instead of giving up — device may reconnect shortly after wake
startDevicePolling()
markRestartComplete()
return
}
Expand All @@ -327,6 +366,157 @@ public final class MultitouchManager: @unchecked Sendable {
restartLock.unlock()
}

// MARK: - Device Polling

/// Start polling for multitouch device connections.
/// Called when start() or performRestart() finds no devices — typically during boot
/// when a Bluetooth Magic Trackpad hasn't connected yet.
private func startDevicePolling() {
guard !isPollingForDevices else { return }

isPollingForDevices = true
currentPollingInterval = Self.devicePollingInterval
pollingStartTime = CACurrentMediaTime()
Log.info(
"Starting device polling (initial interval: \(Self.devicePollingInterval)s, max duration: \(Self.maxPollingDuration)s)",
category: .device)

scheduleNextPoll()
}

/// Stop device polling.
/// Called when devices are found, when stop() is called, or when restart() begins.
private func stopDevicePolling() {
guard isPollingForDevices else { return }

cancelPollingTimer()
isPollingForDevices = false
currentPollingInterval = 0
pollingStartTime = 0
Log.debug("Device polling stopped", category: .device)
}

/// Cancel the polling timer without resetting backoff state.
/// Used when pausing polling during a connection attempt so that if it fails,
/// resumeDevicePolling() can continue with the correct interval and elapsed time.
private func cancelPollingTimer() {
devicePollingTimer?.cancel()
devicePollingTimer = nil
}

/// Schedule the next poll with exponential backoff.
private func scheduleNextPoll() {
devicePollingTimer?.cancel()

let timer = DispatchSource.makeTimerSource(queue: .main)
timer.schedule(deadline: .now() + currentPollingInterval)
timer.setEventHandler { [weak self] in
self?.pollForDevices()
}
timer.resume()
devicePollingTimer = timer
}

/// Resume polling after a failed connection attempt, preserving backoff state.
/// Unlike startDevicePolling(), this doesn't reset the interval or start time.
/// Internal access for testability.
internal func resumeDevicePolling() {
isPollingForDevices = true
currentPollingInterval = min(currentPollingInterval * 2, Self.maxDevicePollingInterval)
Log.debug(
unsafe "Resuming device polling (next in \(String(format: "%.0f", currentPollingInterval))s)",
category: .device)
scheduleNextPoll()
}

/// Check if any multitouch devices are now available.
/// Called periodically by the polling timer with exponential backoff.
/// Internal access for testability.
internal func pollForDevices() {
guard isPollingForDevices else {
stopDevicePolling()
return
}

// Check if we've exceeded the maximum polling duration
let elapsed = CACurrentMediaTime() - pollingStartTime
if elapsed >= Self.maxPollingDuration {
Log.info(
"Device polling timed out after \(Int(elapsed))s — no multitouch device found. "
+ "User can re-enable from menu bar.",
category: .device)
stopDevicePolling()
// Notify UI so it can show the timed-out state
NotificationCenter.default.post(name: .middleDragPollingTimedOut, object: nil)
return
}

// Quick check using the framework's device list
guard let deviceList = MTDeviceCreateList(),
CFArrayGetCount(deviceList) > 0
else {
Log.debug(
unsafe "Device poll: no multitouch devices found yet (next in \(String(format: "%.0f", currentPollingInterval))s)",
category: .device)
// Exponential backoff: double the interval, capped at max
currentPollingInterval = min(currentPollingInterval * 2, Self.maxDevicePollingInterval)
scheduleNextPoll()
return
}

Log.info(
"Device poll: multitouch device(s) detected, attempting connection...",
category: .device)
// Pause the timer but preserve backoff state — if connection fails,
// resumeDevicePolling() needs the current interval and start time intact.
cancelPollingTimer()

attemptDeviceConnection()
}

/// Attempt to connect to a detected multitouch device.
/// Called by pollForDevices() after MTDeviceCreateList confirms a device exists.
/// On failure, resumes polling with backoff. On success, transitions to monitoring.
/// Internal access for testability — allows tests to exercise connection logic
/// without depending on real hardware via MTDeviceCreateList.
internal func attemptDeviceConnection() {
applyConfiguration()
let eventTapSuccess = eventTapSetupFactory()

guard eventTapSuccess else {
Log.error("Device poll: could not create event tap", category: .device)
// Resume polling — event tap failure may be transient.
// Use resumeDevicePolling to preserve backoff state.
resumeDevicePolling()
return
}

deviceMonitor = deviceProviderFactory()
unsafe deviceMonitor?.delegate = self

guard deviceMonitor?.start() == true else {
Log.warning(
"Device poll: device detected but could not start monitoring, resuming polling",
category: .device)
deviceMonitor?.stop()
deviceMonitor = nil
teardownEventTap()
resumeDevicePolling()
return
}

// Success! Monitoring is now active.
isMonitoring = true
isEnabled = true
isPollingForDevices = false
currentPollingInterval = 0
pollingStartTime = 0
Log.info("Multitouch monitoring started after device connection", category: .device)

// Notify UI so menu bar icon updates from disabled → enabled
NotificationCenter.default.post(name: .middleDragDeviceConnected, object: nil)
}

/// Internal stop without removing sleep/wake observers
private func internalStop() {
mouseGenerator.cancelDrag()
Expand Down Expand Up @@ -384,6 +574,22 @@ public final class MultitouchManager: @unchecked Sendable {

/// Toggle enabled state
func toggleEnabled() {
// If we're polling for devices, treat toggle as "stop trying"
if isPollingForDevices {
stopDevicePolling()
removeSleepWakeObservers()
isEnabled = false
return
}

// If user is trying to enable while not monitoring,
// attempt to start monitoring. This handles the case where the app launched
// before a Bluetooth trackpad connected and the user manually tries to enable.
if !isEnabled && !isMonitoring {
start()
return
}

isEnabled.toggle()

if !isEnabled {
Expand Down
Loading
Loading