-
Notifications
You must be signed in to change notification settings - Fork 19
Expand file tree
/
Copy pathTouchHandler.swift
More file actions
376 lines (327 loc) · 13.6 KB
/
Copy pathTouchHandler.swift
File metadata and controls
376 lines (327 loc) · 13.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
//
// TouchHandler.swift
// HyperVibe
//
// Handles Siri Remote trackpad input using Apple's private MultitouchSupport.framework
//
import Foundation
import CoreGraphics
import AppKit
import Darwin
private func touchCallback(device: MTDevice?,
touches: UnsafeMutablePointer<MTTouch>?,
numTouches: Int,
timestamp: Double,
frame: Int,
refcon: UnsafeMutableRawPointer?) {
guard let refcon = refcon else { return }
let handler = Unmanaged<TouchHandler>.fromOpaque(refcon).takeUnretainedValue()
handler.handleTouches(touches: touches, count: numTouches, timestamp: timestamp)
}
class TouchHandler {
/// mach_absolute_time() is in machine-dependent units; convert to seconds via timebase.
private static let machTimebase: (numer: UInt32, denom: UInt32) = {
var info = mach_timebase_info_data_t(numer: 0, denom: 0)
if mach_timebase_info(&info) == 0 {
return (info.numer, info.denom)
}
return (1, 1)
}()
private static func machDeltaToSeconds(from startMach: UInt64) -> Double {
guard startMach > 0 else { return 0 }
let now = mach_absolute_time()
let delta = now >= startMach ? (now - startMach) : 0
let nanos = delta * UInt64(Self.machTimebase.numer) / UInt64(Self.machTimebase.denom)
return Double(nanos) / 1_000_000_000.0
}
private let cursorController: CursorController
private var device: MTDevice?
private var reconnectTimer: Timer?
private var fastReconnectTimer: Timer?
private var wakeObserver: NSObjectProtocol?
var scrollScale: CGFloat = 150.0
private var lastTouchPosition: CGPoint?
private var lastTouchCount = 0
private var lastTouchTime: UInt64 = 0
private var touchStartTime: UInt64 = 0
private var touchStartPosition: CGPoint = .zero
private let cursorScale: CGFloat = 500.0
private let tapMaxDuration: Double = 0.22
private let tapMaxDistance: CGFloat = 0.07
// Swipe detection: velocity-gated single-finger flick. Distance > 35% of trackpad in < 350ms,
// with the dominant axis at least 2× the orthogonal axis (rejects diagonal wobble).
private let swipeMinDistance: CGFloat = 0.35
private let swipeMaxDuration: Double = 0.35
private let swipeAxisRatio: CGFloat = 2.0
private var hadMultipleFingersInSession = false
/// Fired on touch-up when a single-finger flick is detected. Dispatched on main.
var onSwipe: ((SwipeDirection) -> Void)?
private let reconnectInterval: TimeInterval = 2.0
private let idleTimeout: TimeInterval = 90.0
private let touchStarvationThreshold: TimeInterval = 15.0
init(cursorController: CursorController) {
self.cursorController = cursorController
}
deinit {
stop()
}
func start() {
findAndStartDevice()
startReconnectTimer()
// Restart MT device after sleep (trackpad stops delivering until restarted).
wakeObserver = NSWorkspace.shared.notificationCenter.addObserver(
forName: NSWorkspace.didWakeNotification,
object: nil,
queue: .main
) { [weak self] _ in
self?.restartTrackpadAfterWake()
}
}
func stop() {
if let obs = wakeObserver {
NSWorkspace.shared.notificationCenter.removeObserver(obs)
wakeObserver = nil
}
reconnectTimer?.invalidate()
reconnectTimer = nil
fastReconnectTimer?.invalidate()
fastReconnectTimer = nil
stopDevice()
}
/// Call when HID button activity is detected (e.g. after remote wake). Re-scans MT devices
/// only when we don't have a device, so we can reattach if it reappeared. If we already
/// have a working device, do nothing — restarting on every button press would break the trackpad.
func tryReconnectTrackpad() {
guard device == nil else { return }
let doScan = { [weak self] in
guard self?.device == nil else { return }
self?.findAndStartDevice()
}
if Thread.isMainThread {
doScan()
} else {
DispatchQueue.main.async { doScan() }
}
// Device may re-enumerate shortly after HID activity; retry once after a short delay.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { doScan() }
// Poll more often for a limited time so we attach as soon as the trackpad reappears.
fastReconnectTimer?.invalidate()
let startDate = Date()
fastReconnectTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] timer in
guard let self = self else { timer.invalidate(); return }
if self.device != nil {
timer.invalidate()
self.fastReconnectTimer = nil
return
}
if Date().timeIntervalSince(startDate) > 20 {
timer.invalidate()
self.fastReconnectTimer = nil
return
}
self.findAndStartDevice()
}
if let timer = fastReconnectTimer {
RunLoop.main.add(timer, forMode: .common)
}
}
private func restartTrackpadAfterWake() {
stopDevice()
findAndStartDevice()
}
private func findAndStartDevice() {
guard let cfArray = MTDeviceCreateList()?.takeRetainedValue() else { return }
let deviceList = cfArray as [MTDevice]
// Find non-built-in device (Siri Remote)
for dev in deviceList {
if !MTDeviceIsBuiltIn(dev) {
startDevice(dev)
return
}
}
// Fallback: use second device if available
if deviceList.count > 1 {
startDevice(deviceList[1])
} else if device != nil {
// Clear stale ref so next checkAndReconnect will retry when the remote reappears in the list.
stopDevice()
}
}
private func startDevice(_ dev: MTDevice) {
stopDevice()
device = dev
let refcon = Unmanaged.passUnretained(self).toOpaque()
MTRegisterContactFrameCallbackWithRefcon(dev, touchCallback, refcon)
MTDeviceStart(dev, 0)
// Reset so we don't immediately re-enter starvation and restart every 2s when no touches yet.
lastTouchTime = mach_absolute_time()
print("📱 Trackpad device connected and started")
}
private func stopDevice() {
guard let dev = device else { return }
MTUnregisterContactFrameCallback(dev, touchCallback)
MTDeviceStop(dev)
device = nil
print("📱 Trackpad device disconnected")
lastTouchPosition = nil
lastTouchCount = 0
hadMultipleFingersInSession = false
}
private func startReconnectTimer() {
reconnectTimer = Timer.scheduledTimer(withTimeInterval: reconnectInterval, repeats: true) { [weak self] _ in
self?.checkAndReconnect()
}
// Fire when app is in background (menu bar only); otherwise timer may not run.
if let timer = reconnectTimer {
RunLoop.main.add(timer, forMode: .common)
}
}
private func checkAndReconnect() {
let timeSinceLastTouch = lastTouchTime == 0 ? 0 : Self.machDeltaToSeconds(from: lastTouchTime)
guard let cfArray = MTDeviceCreateList()?.takeRetainedValue() else { return }
let deviceCount = CFArrayGetCount(cfArray)
// Restart if we have a device ref but the driver stopped (e.g. after remote sleep).
if let dev = device, !MTDeviceIsRunning(dev) {
findAndStartDevice()
return
}
// Restart if we have a device but no touch events for a while (remote slept; no "remote wake" API).
if device != nil && timeSinceLastTouch > touchStarvationThreshold {
findAndStartDevice()
return
}
if device == nil || (timeSinceLastTouch > idleTimeout && deviceCount > 1) {
findAndStartDevice()
}
}
func handleTouches(touches: UnsafeMutablePointer<MTTouch>?, count: Int, timestamp: Double) {
lastTouchTime = mach_absolute_time()
guard count > 0, let touchPtr = touches else {
// Touch ended
handleTouchEnd()
lastTouchPosition = nil
lastTouchCount = 0
return
}
// Calculate average position of all active touches
var avgX: Float = 0
var avgY: Float = 0
var activeTouchCount = 0
for i in 0..<count {
let touch = touchPtr[i]
// Only process active touches
if touch.state == MTTouchStateTouching || touch.state == MTTouchStateMakeTouch {
avgX += touch.normalizedVector.position.x
avgY += touch.normalizedVector.position.y
activeTouchCount += 1
}
}
guard activeTouchCount > 0 else {
handleTouchEnd()
lastTouchPosition = nil
lastTouchCount = 0
return
}
if activeTouchCount >= 2 {
hadMultipleFingersInSession = true
}
avgX /= Float(activeTouchCount)
avgY /= Float(activeTouchCount)
let currentPos = CGPoint(x: CGFloat(avgX), y: CGFloat(avgY))
// Handle touch start
if lastTouchPosition == nil {
hadMultipleFingersInSession = false
touchStartTime = mach_absolute_time()
touchStartPosition = currentPos
lastTouchPosition = currentPos
lastTouchCount = activeTouchCount
return
}
// Calculate delta
let deltaX = currentPos.x - (lastTouchPosition?.x ?? currentPos.x)
let deltaY = currentPos.y - (lastTouchPosition?.y ?? currentPos.y)
// Process based on finger count: 1 finger = cursor, 2 fingers = scroll
if activeTouchCount == 1 && lastTouchCount == 1 {
let clamped = moveCursor(deltaX: deltaX, deltaY: deltaY)
// Only advance touch tracking if cursor wasn't clamped in that direction
if let lastPos = lastTouchPosition {
let adjustedDeltaX = clamped.clampedX ? 0 : deltaX
let adjustedDeltaY = clamped.clampedY ? 0 : deltaY
lastTouchPosition = CGPoint(
x: lastPos.x + adjustedDeltaX,
y: lastPos.y + adjustedDeltaY
)
} else {
lastTouchPosition = currentPos
}
} else if activeTouchCount == 2 && lastTouchCount == 2 {
// Two fingers: always scroll regardless of mode
performScroll(deltaX: deltaX, deltaY: deltaY)
lastTouchPosition = currentPos
} else {
lastTouchPosition = currentPos
}
lastTouchCount = activeTouchCount
}
private func handleTouchEnd() {
guard lastTouchPosition != nil else { return }
// Don't trigger tap if physical click button is active
if cursorController.isClickActive {
return
}
// Don't trigger tap after a multi-finger gesture (e.g. two-finger scroll)
if hadMultipleFingersInSession {
return
}
let duration = Self.machDeltaToSeconds(from: touchStartTime)
let dx = (lastTouchPosition?.x ?? 0) - touchStartPosition.x
let dy = (lastTouchPosition?.y ?? 0) - touchStartPosition.y
let movement = hypot(dx, dy)
// Swipe detection (flick). Fires before tap check; distance threshold is well above
// tapMaxDistance, so a swipe can never also register as a tap.
if duration < swipeMaxDuration && movement > swipeMinDistance {
let absDx = abs(dx), absDy = abs(dy)
let direction: SwipeDirection?
if absDx > absDy * swipeAxisRatio {
direction = dx > 0 ? .right : .left
} else if absDy > absDx * swipeAxisRatio {
// MultitouchSupport reports y increasing toward the top of the trackpad.
direction = dy > 0 ? .up : .down
} else {
direction = nil
}
if let direction = direction {
DispatchQueue.main.async { [weak self] in
self?.onSwipe?(direction)
}
return
}
}
if duration < tapMaxDuration && movement < tapMaxDistance {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.cursorController.performClick()
}
}
}
private func moveCursor(deltaX: CGFloat, deltaY: CGFloat) -> (clampedX: Bool, clampedY: Bool) {
let scaledX = deltaX * cursorScale
let scaledY = -deltaY * cursorScale
var clamped = (clampedX: false, clampedY: false)
if Thread.isMainThread {
clamped = cursorController.moveCursor(deltaX: scaledX, deltaY: scaledY)
} else {
DispatchQueue.main.sync {
clamped = cursorController.moveCursor(deltaX: scaledX, deltaY: scaledY)
}
}
return clamped
}
private func performScroll(deltaX: CGFloat, deltaY: CGFloat) {
let scrollX = Int32(-deltaX * scrollScale)
let scrollY = Int32(deltaY * scrollScale)
DispatchQueue.main.async { [weak self] in
self?.cursorController.scroll(deltaX: scrollX, deltaY: scrollY)
}
}
}