Skip to content

Commit 95d4928

Browse files
committed
fix: harden controller switching cleanup
1 parent 222123c commit 95d4928

3 files changed

Lines changed: 271 additions & 13 deletions

File tree

XboxControllerMapper/XboxControllerMapper/Services/Controller/ControllerService+Haptics.swift

Lines changed: 63 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,28 +31,66 @@ extension ControllerService {
3131
}
3232
}
3333
hapticLock.lock()
34+
hapticSessionGeneration += 1
3435
hapticEngines = newEngines
3536
hapticLock.unlock()
3637
}
3738

3839
func stopHaptics() {
39-
hapticLock.lock()
40-
let engines = hapticEngines
41-
hapticEngines.removeAll()
42-
activeHapticPlayers.removeAll()
43-
hapticLock.unlock()
44-
for engine in engines {
45-
engine.stop()
46-
}
40+
let stop = { [self] in
41+
hapticLock.lock()
42+
hapticSessionGeneration += 1
43+
let engines = hapticEngines
44+
let players = activeHapticPlayers
45+
hapticEngines.removeAll()
46+
activeHapticPlayers.removeAll()
47+
hapticLock.unlock()
48+
49+
for player in players {
50+
try? player.player.stop(atTime: CHHapticTimeImmediate)
51+
}
52+
for engine in engines {
53+
engine.stop()
54+
}
55+
}
56+
57+
if DispatchQueue.getSpecific(key: hapticQueueSpecificKey) != nil {
58+
stop()
59+
} else {
60+
hapticQueue.sync(execute: stop)
61+
}
4762
}
4863

64+
nonisolated func currentHapticSessionGeneration() -> UInt64 {
65+
hapticLock.lock()
66+
defer { hapticLock.unlock() }
67+
return hapticSessionGeneration
68+
}
69+
70+
nonisolated func isCurrentHapticSession(_ generation: UInt64) -> Bool {
71+
currentHapticSessionGeneration() == generation
72+
}
73+
74+
#if DEBUG
75+
nonisolated func invalidateHapticSessionForTesting() {
76+
hapticLock.lock()
77+
hapticSessionGeneration += 1
78+
hapticLock.unlock()
79+
}
80+
#endif
81+
4982
/// Plays a haptic pulse on the controller
5083
/// - Parameters:
5184
/// - intensity: Haptic intensity from 0.0 to 1.0
5285
/// - duration: Duration in seconds
5386
nonisolated func playHaptic(intensity: Float = 0.5, sharpness: Float = 0.5, duration: TimeInterval = 0.1, transient: Bool = false) {
87+
let sessionGeneration = currentHapticSessionGeneration()
5488
hapticQueue.async { [weak self] in
5589
guard let self = self else { return }
90+
guard self.isCurrentHapticSession(sessionGeneration) else { return }
91+
#if DEBUG
92+
self.hapticSessionAcceptedForTesting?(sessionGeneration)
93+
#endif
5694

5795
if self.readStorage(\.isSteamController) {
5896
self.steamHIDControllerLock.lock()
@@ -69,12 +107,17 @@ extension ControllerService {
69107
// Snapshot engines under lock
70108
self.hapticLock.lock()
71109
let engines = self.hapticEngines
110+
let sessionStillCurrent = self.hapticSessionGeneration == sessionGeneration
72111
self.hapticLock.unlock()
73-
guard !engines.isEmpty else { return }
112+
guard sessionStillCurrent, !engines.isEmpty else { return }
74113

75114
// Prune expired players to avoid truncating overlapping haptics
76115
let now = CFAbsoluteTimeGetCurrent()
77116
self.hapticLock.lock()
117+
guard self.hapticSessionGeneration == sessionGeneration else {
118+
self.hapticLock.unlock()
119+
return
120+
}
78121
self.activeHapticPlayers.removeAll { $0.endTime <= now }
79122
self.hapticLock.unlock()
80123

@@ -110,20 +153,31 @@ extension ControllerService {
110153
// Retain players so they aren't deallocated before playback completes
111154
var newPlayers: [ActiveHapticPlayer] = []
112155
for engine in engines {
156+
guard self.isCurrentHapticSession(sessionGeneration) else { break }
113157
do {
114158
let player = try engine.makePlayer(with: pattern)
115159
newPlayers.append(ActiveHapticPlayer(player: player, endTime: endTime))
160+
guard self.isCurrentHapticSession(sessionGeneration) else { break }
116161
try player.start(atTime: CHHapticTimeImmediate)
117162
} catch {
118163
// Try to restart engine and retry once
164+
guard self.isCurrentHapticSession(sessionGeneration) else { break }
119165
try? engine.start()
120166
if let player = try? engine.makePlayer(with: pattern) {
121167
newPlayers.append(ActiveHapticPlayer(player: player, endTime: endTime))
168+
guard self.isCurrentHapticSession(sessionGeneration) else { break }
122169
try? player.start(atTime: CHHapticTimeImmediate)
123170
}
124171
}
125172
}
126173
self.hapticLock.lock()
174+
guard self.hapticSessionGeneration == sessionGeneration else {
175+
self.hapticLock.unlock()
176+
for player in newPlayers {
177+
try? player.player.stop(atTime: CHHapticTimeImmediate)
178+
}
179+
return
180+
}
127181
self.activeHapticPlayers.append(contentsOf: newPlayers)
128182
if self.activeHapticPlayers.count > 12 {
129183
self.activeHapticPlayers.removeFirst(self.activeHapticPlayers.count - 12)

XboxControllerMapper/XboxControllerMapper/Services/Controller/ControllerService.swift

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,8 @@ class ControllerService: ObservableObject {
263263
private var connectionGeneration: UInt64 = 0
264264
private var configuredGameControllerIDs: Set<ObjectIdentifier> = []
265265
private let inactiveControllerAnalogActivationDeadzone: Float = 0.18
266+
private let inactiveControllerTakeoverQuietInterval: TimeInterval = 0.75
267+
private var activeGameControllerLastInputTime: TimeInterval = 0
266268

267269
/// Generation token for the battery polling chain. `updateBatteryInfo()` bumps
268270
/// it so previously scheduled polls cancel themselves — otherwise every external
@@ -707,13 +709,18 @@ class ControllerService: ObservableObject {
707709
// Haptic engines for controller feedback (try multiple localities)
708710
// Protected by hapticLock — accessed from both @MainActor (setup/stop) and hapticQueue (play)
709711
let hapticLock = NSLock()
710-
var hapticEngines: [CHHapticEngine] = []
712+
nonisolated(unsafe) var hapticEngines: [CHHapticEngine] = []
711713
let hapticQueue = DispatchQueue(label: "com.xboxmapper.haptic", qos: .userInitiated)
714+
let hapticQueueSpecificKey = DispatchSpecificKey<Void>()
712715
struct ActiveHapticPlayer {
713716
let player: CHHapticPatternPlayer
714717
let endTime: TimeInterval
715718
}
716-
var activeHapticPlayers: [ActiveHapticPlayer] = []
719+
nonisolated(unsafe) var activeHapticPlayers: [ActiveHapticPlayer] = []
720+
nonisolated(unsafe) var hapticSessionGeneration: UInt64 = 0
721+
#if DEBUG
722+
nonisolated(unsafe) var hapticSessionAcceptedForTesting: ((UInt64) -> Void)?
723+
#endif
717724

718725
/// Display name shown in the toolbar pill for `--screenshot-variant` captures.
719726
static func screenshotControllerName(for variant: String) -> String {
@@ -816,6 +823,7 @@ class ControllerService: ObservableObject {
816823
let shouldEnableHardwareMonitoring = enableHardwareMonitoring && !Self.isRunningTests
817824
self.guideMonitor = XboxGuideMonitor(enableHardwareMonitoring: shouldEnableHardwareMonitoring)
818825
self.hardwareMonitoringEnabled = shouldEnableHardwareMonitoring
826+
hapticQueue.setSpecific(key: hapticQueueSpecificKey, value: ())
819827

820828
// Load last controller type (so UI shows correct button labels when no controller is connected)
821829
storage.restoreControllerTypeFlags(
@@ -1036,6 +1044,7 @@ class ControllerService: ObservableObject {
10361044
connectionGeneration, reason, controller.vendorName ?? "(nil)")
10371045

10381046
prepareForActiveControllerSwitch()
1047+
activeGameControllerLastInputTime = CFAbsoluteTimeGetCurrent()
10391048

10401049
// Cancel generic HID fallback if GameController framework claimed this device
10411050
genericHIDFallbackTimer?.cancel()
@@ -1092,8 +1101,10 @@ class ControllerService: ObservableObject {
10921101
self?.objectWillChange.send()
10931102
}
10941103

1104+
let activationGeneration = connectionGeneration
10951105
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
1096-
self?.playHaptic(intensity: 0.7, sharpness: 0.6, duration: 0.12)
1106+
guard let self, self.connectionGeneration == activationGeneration else { return }
1107+
self.playHaptic(intensity: 0.7, sharpness: 0.6, duration: 0.12)
10971108
}
10981109
updateBatteryInfo()
10991110
startDisplayUpdateTimer()
@@ -1131,6 +1142,7 @@ class ControllerService: ObservableObject {
11311142
cleanupEightBitDoHIDMonitoring()
11321143
stopEliteHelper()
11331144
controllerMappingSource = nil
1145+
activeGameControllerLastInputTime = 0
11341146

11351147
activeButtons.removeAll()
11361148
leftStick = .zero
@@ -1187,6 +1199,7 @@ class ControllerService: ObservableObject {
11871199
connectedController?.motion?.sensorsActive = false
11881200
connectedController = nil
11891201
configuredGameControllerIDs.removeAll()
1202+
activeGameControllerLastInputTime = 0
11901203
isConnected = false
11911204
currentControllerIdentity = nil
11921205
isGenericController = false
@@ -1480,13 +1493,71 @@ class ControllerService: ObservableObject {
14801493
if steamHIDActiveDevice != nil {
14811494
return false
14821495
}
1496+
let now = CFAbsoluteTimeGetCurrent()
14831497
if connectedController !== controller {
1484-
guard meaningful else { return false }
1498+
guard Self.shouldActivateInactiveControllerInput(
1499+
meaningful: meaningful,
1500+
activeControllerHasInput: hasActiveGameControllerInput(),
1501+
activeControllerLastInputTime: activeGameControllerLastInputTime,
1502+
now: now,
1503+
quietInterval: inactiveControllerTakeoverQuietInterval
1504+
) else { return false }
14851505
activateGameController(controller, reason: "input")
1506+
} else {
1507+
if meaningful || hasActiveGameControllerInput() {
1508+
activeGameControllerLastInputTime = now
1509+
}
14861510
}
14871511
return shouldAcceptGameControllerInput()
14881512
}
14891513

1514+
nonisolated static func shouldActivateInactiveControllerInput(
1515+
meaningful: Bool,
1516+
activeControllerHasInput: Bool,
1517+
activeControllerLastInputTime: TimeInterval,
1518+
now: TimeInterval,
1519+
quietInterval: TimeInterval
1520+
) -> Bool {
1521+
guard meaningful else { return false }
1522+
guard !activeControllerHasInput else { return false }
1523+
guard activeControllerLastInputTime > 0 else { return true }
1524+
return now - activeControllerLastInputTime >= quietInterval
1525+
}
1526+
1527+
nonisolated func hasActiveGameControllerInput() -> Bool {
1528+
storage.lock.lock()
1529+
defer { storage.lock.unlock() }
1530+
return Self.hasActiveGameControllerInput(
1531+
activeButtons: storage.activeButtons,
1532+
leftStick: storage.leftStick,
1533+
rightStick: storage.rightStick,
1534+
leftTrigger: storage.leftTrigger,
1535+
rightTrigger: storage.rightTrigger,
1536+
touchpadIsActive: storage.isTouchpadTouching ||
1537+
storage.isTouchpadSecondaryTouching ||
1538+
storage.isSteamLeftTouchpadTouching ||
1539+
storage.isSteamRightTouchpadTouching,
1540+
deadzone: inactiveControllerAnalogActivationDeadzone
1541+
)
1542+
}
1543+
1544+
nonisolated static func hasActiveGameControllerInput(
1545+
activeButtons: Set<ControllerButton>,
1546+
leftStick: CGPoint,
1547+
rightStick: CGPoint,
1548+
leftTrigger: Float,
1549+
rightTrigger: Float,
1550+
touchpadIsActive: Bool,
1551+
deadzone: Float
1552+
) -> Bool {
1553+
guard activeButtons.isEmpty else { return true }
1554+
guard !touchpadIsActive else { return true }
1555+
if hypotf(Float(leftStick.x), Float(leftStick.y)) >= deadzone { return true }
1556+
if hypotf(Float(rightStick.x), Float(rightStick.y)) >= deadzone { return true }
1557+
if leftTrigger >= deadzone || rightTrigger >= deadzone { return true }
1558+
return false
1559+
}
1560+
14901561
func clearGameControllerHandlers(for controller: GCController) {
14911562
if let gamepad = controller.extendedGamepad {
14921563
let buttons: [GCControllerButtonInput?] = [

0 commit comments

Comments
 (0)