@@ -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