-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathRemoteInputHandler.swift
More file actions
295 lines (253 loc) · 12.4 KB
/
RemoteInputHandler.swift
File metadata and controls
295 lines (253 loc) · 12.4 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
//
// RemoteInputHandler.swift
// HyperVibe
//
// Processes HID input events from Siri Remote
//
import IOKit
import IOKit.hid
import Foundation
import Carbon.HIToolbox
import AppKit
class RemoteInputHandler {
private let cursorController: CursorController
private weak var menuBarManager: MenuBarManager?
private var devices: [IOHIDDevice] = []
/// Called on any button activity; use to trigger trackpad re-scan after remote wake.
var onButtonActivity: (() -> Void)?
// First press after connection: do not perform action (sound already played at connect).
private var isFirstPressAfterConnection = false
// Click/drag state
private var isSelectPressed = false
private var selectPressTime: UInt64 = 0
private var isDragging = false
private let clickThreshold: Double = 0.25
// Prevent double-processing with MediaKeyInterceptor
static var lastProcessedButton: String?
static var lastProcessedTime: UInt64 = 0
/// Virtual keys currently held down, keyed by the HID button that initiated the hold.
/// Captured at press time so release can fire the correct keyUp even if the user
/// rebinds the button mid-hold. Cleared on device removal to avoid stuck modifiers.
private var heldKeys: [String: (keyCode: Int, flags: CGEventFlags)] = [:]
/// Last observed pressed/released state per button. The Siri Remote mirrors each logical
/// button across multiple HID interfaces (6 seized here), so every physical press/release
/// fires the callback N times. This collapses dup events to a single state transition.
private var buttonState: [String: Bool] = [:]
init(cursorController: CursorController, menuBarManager: MenuBarManager) {
self.cursorController = cursorController
self.menuBarManager = menuBarManager
}
func setRemoteDevice(_ device: IOHIDDevice?) {
guard let device = device else {
releaseAllHeldKeys()
for d in devices {
IOHIDDeviceRegisterInputValueCallback(d, nil, nil)
IOHIDDeviceUnscheduleFromRunLoop(d, CFRunLoopGetMain(), CFRunLoopMode.commonModes.rawValue)
IOHIDDeviceClose(d, IOOptionBits(kIOHIDOptionsTypeNone))
}
devices.removeAll()
isFirstPressAfterConnection = false
return
}
guard !devices.contains(where: { $0 == device }) else { return }
// Seize device to prevent system from handling events
let openResult = IOHIDDeviceOpen(device, IOOptionBits(kIOHIDOptionsTypeSeizeDevice))
if openResult == kIOReturnSuccess {
rmDebug(String(format: "🔒 SEIZED HID device (vendor=0x%X product=0x%X)",
IOHIDDeviceGetProperty(device, kIOHIDVendorIDKey as CFString) as? Int ?? 0,
IOHIDDeviceGetProperty(device, kIOHIDProductIDKey as CFString) as? Int ?? 0))
IOHIDDeviceRegisterInputValueCallback(device, inputValueCallback, Unmanaged.passUnretained(self).toOpaque())
IOHIDDeviceScheduleWithRunLoop(device, CFRunLoopGetMain(), CFRunLoopMode.commonModes.rawValue)
devices.append(device)
isFirstPressAfterConnection = true
} else {
rmDebug(String(format: "⚠️ FAILED to seize HID device (IOReturn=0x%X) — opening unseized", openResult))
if IOHIDDeviceOpen(device, IOOptionBits(kIOHIDOptionsTypeNone)) == kIOReturnSuccess {
IOHIDDeviceRegisterInputValueCallback(device, inputValueCallback, Unmanaged.passUnretained(self).toOpaque())
IOHIDDeviceScheduleWithRunLoop(device, CFRunLoopGetMain(), CFRunLoopMode.commonModes.rawValue)
devices.append(device)
isFirstPressAfterConnection = true
}
}
}
func handleInputValue(_ value: IOHIDValue) {
let element = IOHIDValueGetElement(value)
let usagePage = IOHIDElementGetUsagePage(element)
let usage = IOHIDElementGetUsage(element)
let intValue = IOHIDValueGetIntegerValue(value)
let identified = identifyButton(page: usagePage, usage: usage)
rmDebug(String(format: "🎮 HID event: page=0x%X usage=0x%X value=%d → %@",
usagePage, usage, intValue, identified ?? "<unmapped>"))
guard let buttonName = identified else { return }
onButtonActivity?()
// Collapse mirrored-interface duplicates: only proceed on a real state transition.
let isPressed = (intValue == 1)
if buttonState[buttonName] == isPressed {
return
}
buttonState[buttonName] = isPressed
// Volume keys on the Siri Remote also travel over BT AVRCP absolute-volume, which
// coreaudiod honors below cghidEventTap. Arm the revert guard on every press so the
// CoreAudio listener snaps the level back to the pre-press value.
if isPressed && (buttonName == "volumeUp" || buttonName == "volumeDown") {
VolumeRevertGuard.shared.armFromRemoteButton()
}
// First key-down after connection: skip so the connect handshake doesn't fire an action.
if intValue == 1 && isFirstPressAfterConnection {
isFirstPressAfterConnection = false
return
}
// Select is the trackpad click — handled separately for click/drag semantics.
if buttonName == "select" {
handleSelectButton(pressed: intValue == 1)
return
}
let pressed = (intValue == 1)
// Debounce only on press — release just closes an existing hold.
if pressed {
RemoteInputHandler.lastProcessedButton = buttonName
RemoteInputHandler.lastProcessedTime = mach_absolute_time()
}
let action = menuBarManager?.getMapping(for: buttonName) ?? ButtonAction.none
if pressed {
print("🔘 Button pressed: \(buttonName) → \(action.rawValue)")
}
executeAction(action, button: buttonName, pressed: pressed)
}
private func handleSelectButton(pressed: Bool) {
if pressed && !isSelectPressed {
isSelectPressed = true
isDragging = false
selectPressTime = mach_absolute_time()
cursorController.isClickActive = true
// Start drag after threshold
DispatchQueue.main.asyncAfter(deadline: .now() + clickThreshold) { [weak self] in
guard let self = self, self.isSelectPressed && !self.isDragging else { return }
print("🔘 Select button: Drag started")
self.isDragging = true
self.cursorController.isDragging = true
self.cursorController.mouseDown()
}
} else if !pressed && isSelectPressed {
isSelectPressed = false
if isDragging {
print("🔘 Select button: Drag ended")
cursorController.isDragging = false
cursorController.mouseUp()
} else {
print("🔘 Select button: Click")
cursorController.performClick()
}
isDragging = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
self?.cursorController.isClickActive = false
}
}
}
// MARK: - Button Identification
private func identifyButton(page: UInt32, usage: UInt32) -> String? {
switch (page, usage) {
// Generic Desktop Page (0x01)
case (0x01, 0x86): return "menu" // System Menu Main
case (0x01, 0x40): return "menu" // Menu (alternative)
// Consumer Page (0x0C)
case (0x0C, 0x04): return "siri" // Siri button (actual)
case (0x0C, 0x60): return "tv" // TV button (actual)
case (0x0C, 0x80): return "select" // Selection
case (0x0C, 0x41): return "select" // Menu Select (alternative)
case (0x0C, 0xCD): return "playPause" // Play/Pause
case (0x0C, 0xE9): return "volumeUp" // Volume Increment
case (0x0C, 0xEA): return "volumeDown" // Volume Decrement
case (0x0C, 0xB5): return "nextTrack" // Scan Next Track
case (0x0C, 0xB6): return "prevTrack" // Scan Previous Track
case (0x0C, 0x223): return "tv" // AC Home (TV button alternative)
case (0x0C, 0x224): return "back" // AC Back
case (0x0C, 0x40): return "menu" // Menu
case (0x0C, 0x30): return "power" // Power
case (0x0C, 0x20): return "mute" // Mute (some remotes)
// Button Page (0x09)
case (0x09, 0x01): return "select" // Button 1
// Apple Vendor Page (0xFF00) - Siri button
case (0xFF00, 0x01): return "siri" // Siri button
case (0xFF00, 0x02): return "siri" // Siri button (alternative)
case (0xFF00, 0x03): return "siri" // Siri button (alternative)
case (0xFF00, _): return "siri" // Any Apple vendor usage = likely Siri
// Telephony Page (0x0B) - sometimes used for Siri
case (0x0B, 0x21): return "siri" // Flash
case (0x0B, 0x2F): return "siri" // Phone Mute
default: return nil
}
}
// MARK: - Action Execution
private func executeAction(_ action: ButtonAction, button: String, pressed: Bool) {
if action.requiresHold {
handleHoldAction(action, button: button, pressed: pressed)
return
}
// Tap actions fire once, on press only.
guard pressed else { return }
switch action {
case .none:
break
case .enterKey:
sendKey(kVK_Return)
case .upKey:
sendKey(kVK_UpArrow)
case .downKey:
sendKey(kVK_DownArrow)
case .escKey:
sendKey(kVK_Escape)
case .ctrlC:
sendKey(kVK_ANSI_C, flags: .maskControl)
case .spaceKey, .rightCmd, .rightOpt:
break // handled by handleHoldAction
case .trackpadClick:
cursorController.performClick()
}
}
/// Press/release a virtual key mirroring the HID press duration (push-to-talk).
private func handleHoldAction(_ action: ButtonAction, button: String, pressed: Bool) {
let spec: (keyCode: Int, flags: CGEventFlags)
switch action {
case .spaceKey: spec = (kVK_Space, [])
case .rightCmd: spec = (kVK_RightCommand, .maskCommand)
case .rightOpt: spec = (kVK_RightOption, .maskAlternate)
default: return
}
if pressed {
// Defensive: if a prior release was missed, close the stale hold before opening a new one.
if let stale = heldKeys.removeValue(forKey: button) {
postKey(keyCode: stale.keyCode, flags: [], keyDown: false)
}
postKey(keyCode: spec.keyCode, flags: spec.flags, keyDown: true)
heldKeys[button] = spec
} else {
guard let held = heldKeys.removeValue(forKey: button) else { return }
postKey(keyCode: held.keyCode, flags: [], keyDown: false)
}
}
/// Called on device removal to avoid stuck modifiers if the remote disconnects mid-hold.
private func releaseAllHeldKeys() {
for (_, held) in heldKeys {
postKey(keyCode: held.keyCode, flags: [], keyDown: false)
}
heldKeys.removeAll()
buttonState.removeAll()
}
private func postKey(keyCode: Int, flags: CGEventFlags, keyDown: Bool) {
let src = CGEventSource(stateID: .hidSystemState)
let event = CGEvent(keyboardEventSource: src, virtualKey: CGKeyCode(keyCode), keyDown: keyDown)
event?.flags = flags
event?.post(tap: .cghidEventTap)
}
private func sendKey(_ keyCode: Int, flags: CGEventFlags = []) {
postKey(keyCode: keyCode, flags: flags, keyDown: true)
usleep(10000)
postKey(keyCode: keyCode, flags: flags, keyDown: false)
}
}
// C callback
private func inputValueCallback(context: UnsafeMutableRawPointer?, result: IOReturn, sender: UnsafeMutableRawPointer?, value: IOHIDValue) {
guard let context = context else { return }
Unmanaged<RemoteInputHandler>.fromOpaque(context).takeUnretainedValue().handleInputValue(value)
}