3e6c9f6060
Extends virtual-controller support beyond Xbox 360 + DualSense. Goal: a physical Xbox One or PS4 pad on the client gets a near-native matching virtual pad on the host, auto-resolved from the controller type. Protocol/core: - GamepadPref gains XboxOne (wire 3) + DualShock4 (wire 4); to_u8/from_u8/ from_name/as_str + C ABI PUNKTFUNK_GAMEPAD_XBOXONE/_DUALSHOCK4 constants (compile-time guard ties them to the enum). Single-byte wire form is unchanged, so it's forward-compatible (older peers degrade to Auto). Host (Linux): - New UHID DualShock 4 backend (inject/dualshock4.rs) bound by hid-playstation: lightbar, touchpad, motion, rumble — DualSense minus adaptive triggers / player LEDs / mute. Reuses the DualSense pure state + button mapping; only the report byte layout, the real-DS4 HID descriptor, the GET_REPORT handshake (0x12 MAC mandatory; 0x02 calibration; 0xa3 firmware) and the touchpad resolution (1920x942) differ. Touchpad/motion ride the existing 0xCC plane, lightbar the 0xCD Led plane (deduped); rumble the universal 0xCA plane. - Xbox One/Series is the uinput Xbox-360 backend parameterized with the One S USB identity (045e:02ea) for matching glyphs — XInput-identical otherwise. - PadBackend dispatch + resolver handle both; off Linux the UHID pads and One/Series fold into Xbox 360. Windows-host DS4 (ViGEm) deferred. Clients (auto-resolve physical pad -> virtual type, plus manual settings): - Linux/Windows (SDL3): SDL_GAMEPAD_TYPE_PS4 -> DualShock 4, _XBOXONE -> Xbox One; PadInfo carries the resolved pref; DS4 touchpad/motion capture + lightbar already type-agnostic. Linux settings combo + label updated. - Apple (GameController): GCDualShockGamepad/GCXboxGamepad detection, DS4 touchpad capture, settings picker entries. - Android (Kotlin): InputDevice VID/PID auto-detect (matching the other clients) + settings entries. - probe: --gamepad help/aliases. Also hardens the Android JNI boundary: wrap the teardown + poll-thread shims in catch_unwind so a panic degrades to a logged no-op instead of aborting the app. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
202 lines
9.3 KiB
Swift
202 lines
9.3 KiB
Swift
// Controller discovery + selection, app-lifetime. One GamepadManager (`.shared`) watches
|
|
// GCController connect/disconnect from launch, so the Settings page shows live controller
|
|
// state without a session, and the session components (GamepadCapture / GamepadFeedback)
|
|
// follow `active` — exactly ONE physical controller is forwarded to the host, as pad 0.
|
|
//
|
|
// Selection: the user can pin a controller in Settings (persisted under
|
|
// DefaultsKey.gamepadID); with no pin — or the pinned one absent — the most recently
|
|
// connected extended gamepad wins. GCController has no stable hardware serial, so the pin
|
|
// is a fingerprint of vendorName|productCategory (+ a connect-order suffix for twins);
|
|
// identical twin controllers may swap a pin across reconnects, which the Settings footer
|
|
// documents.
|
|
//
|
|
// A singleton (not a SwiftUI environment object) because macOS shows Settings in its own
|
|
// `Settings{}` scene — there is no common ancestor view to inject from.
|
|
|
|
import Combine
|
|
import Foundation
|
|
import GameController
|
|
|
|
@MainActor
|
|
public final class GamepadManager: ObservableObject {
|
|
public static let shared = GamepadManager()
|
|
|
|
/// One detected controller, decorated for the Settings UI.
|
|
public struct DiscoveredController: Identifiable, Equatable {
|
|
/// Stable-ish fingerprint: `vendorName|productCategory` (+ `#n` for twins).
|
|
public let id: String
|
|
/// User-facing name (the vendor string, e.g. "DualSense Wireless Controller").
|
|
public let name: String
|
|
public let productCategory: String
|
|
/// The full extended profile exists — only these are forwardable.
|
|
public let isExtended: Bool
|
|
/// The virtual-pad type a physical match resolves to under `.auto`: DualSense →
|
|
/// `.dualSense`, DualShock 4 → `.dualShock4`, an Xbox pad → `.xboxOne`, anything
|
|
/// else → `.xbox360`. (`.auto` is never stored here.)
|
|
public let kind: PunktfunkConnection.GamepadType
|
|
public let hasLight: Bool
|
|
public let hasHaptics: Bool
|
|
public let hasMotion: Bool
|
|
public let hasAdaptiveTriggers: Bool
|
|
/// Specifically a DualSense — gates the DualSense-only feedback (adaptive triggers,
|
|
/// player LEDs) and the PlayStation glyph in Settings.
|
|
public var isDualSense: Bool { kind == .dualSense }
|
|
/// A PlayStation pad with a touchpad + motion (DualSense OR DualShock 4) — gates
|
|
/// rich-input CAPTURE (touchpad contacts + gyro/accel on plane 0xCC).
|
|
public var hasTouchpadAndMotion: Bool {
|
|
kind == .dualSense || kind == .dualShock4
|
|
}
|
|
/// 0...1, nil when the controller doesn't report a battery (e.g. wired).
|
|
public let batteryLevel: Float?
|
|
public let isCharging: Bool
|
|
public let controller: GCController
|
|
|
|
public static func == (l: DiscoveredController, r: DiscoveredController) -> Bool {
|
|
l.id == r.id && l.controller === r.controller
|
|
&& l.batteryLevel == r.batteryLevel && l.isCharging == r.isCharging
|
|
}
|
|
}
|
|
|
|
/// Every detected controller, in connect order (Settings lists these).
|
|
@Published public private(set) var controllers: [DiscoveredController] = []
|
|
|
|
/// The one controller forwarded to the host (pad 0); nil when none qualifies.
|
|
@Published public private(set) var active: DiscoveredController?
|
|
|
|
/// The user's pinned controller fingerprint ("" = automatic). Persisted; updating it
|
|
/// reselects immediately, so a Settings Picker can bind straight to this.
|
|
@Published public var preferredID: String {
|
|
didSet {
|
|
UserDefaults.standard.set(preferredID, forKey: Self.preferredKey)
|
|
reselect()
|
|
}
|
|
}
|
|
|
|
private static let preferredKey = DefaultsKey.gamepadID
|
|
/// Connect order (identity-keyed) — drives both twin de-dup suffixes and auto-pick.
|
|
private var connectOrder: [ObjectIdentifier] = []
|
|
private var observers: [NSObjectProtocol] = []
|
|
|
|
private init() {
|
|
preferredID = UserDefaults.standard.string(forKey: Self.preferredKey) ?? ""
|
|
observers.append(NotificationCenter.default.addObserver(
|
|
forName: .GCControllerDidConnect, object: nil, queue: .main
|
|
) { [weak self] n in
|
|
MainActor.assumeIsolated {
|
|
guard let self, let c = n.object as? GCController else { return }
|
|
self.noteConnected(c)
|
|
}
|
|
})
|
|
observers.append(NotificationCenter.default.addObserver(
|
|
forName: .GCControllerDidDisconnect, object: nil, queue: .main
|
|
) { [weak self] _ in
|
|
MainActor.assumeIsolated { self?.rebuild() }
|
|
})
|
|
for c in GCController.controllers() { connectOrder.append(ObjectIdentifier(c)) }
|
|
rebuild()
|
|
}
|
|
|
|
/// Re-read battery levels etc. (the notifications only fire on connect/disconnect) —
|
|
/// Settings calls this on appear.
|
|
public func refresh() {
|
|
rebuild()
|
|
}
|
|
|
|
/// Scan for nearby wireless controllers while the Settings page is visible.
|
|
public func startDiscovery() {
|
|
GCController.startWirelessControllerDiscovery()
|
|
}
|
|
|
|
public func stopDiscovery() {
|
|
GCController.stopWirelessControllerDiscovery()
|
|
}
|
|
|
|
/// Connect-time resolution of the user's controller-type setting: an explicit choice
|
|
/// wins; `.auto` matches the virtual pad to the active physical controller (DualSense →
|
|
/// DualSense, DualShock 4 → DualShock 4, an Xbox pad → Xbox One, anything else → Xbox
|
|
/// 360); no controller at all defers to the host.
|
|
public func resolveType(
|
|
setting: PunktfunkConnection.GamepadType
|
|
) -> PunktfunkConnection.GamepadType {
|
|
guard setting == .auto else { return setting }
|
|
// Refresh from the LIVE controller list first. `active` is otherwise only populated by the
|
|
// async `.GCControllerDidConnect` notification, so at connect time it can still be nil even
|
|
// with a DualSense attached — which would send `.auto` and the host would create an Xbox 360
|
|
// pad. `rebuild()` re-reads `GCController.controllers()` synchronously, closing that race.
|
|
rebuild()
|
|
guard let active else { return .auto }
|
|
return active.kind
|
|
}
|
|
|
|
private func noteConnected(_ c: GCController) {
|
|
let key = ObjectIdentifier(c)
|
|
connectOrder.removeAll { $0 == key }
|
|
connectOrder.append(key)
|
|
rebuild()
|
|
}
|
|
|
|
private func rebuild() {
|
|
let present = GCController.controllers()
|
|
connectOrder.removeAll { key in !present.contains { ObjectIdentifier($0) == key } }
|
|
for c in present where !connectOrder.contains(ObjectIdentifier(c)) {
|
|
connectOrder.append(ObjectIdentifier(c))
|
|
}
|
|
// In connect order, fingerprinting twins by their position among same-named pads.
|
|
let ordered = connectOrder.compactMap { key in
|
|
present.first { ObjectIdentifier($0) == key }
|
|
}
|
|
var seen: [String: Int] = [:]
|
|
controllers = ordered.map { c in
|
|
let base = "\(c.vendorName ?? "Controller")|\(c.productCategory)"
|
|
let n = (seen[base] ?? 0) + 1
|
|
seen[base] = n
|
|
return Self.describe(c, id: n == 1 ? base : "\(base)#\(n)")
|
|
}
|
|
reselect()
|
|
}
|
|
|
|
private func reselect() {
|
|
let candidates = controllers.filter(\.isExtended)
|
|
// The pin wins when present; otherwise the most recently connected extended pad
|
|
// (list is in connect order). A stale pin falls back to automatic.
|
|
active = candidates.last { $0.id == preferredID } ?? candidates.last
|
|
}
|
|
|
|
private static func describe(_ c: GCController, id: String) -> DiscoveredController {
|
|
let extended = c.extendedGamepad
|
|
let kind = padKind(extended)
|
|
return DiscoveredController(
|
|
id: id,
|
|
name: c.vendorName ?? c.productCategory,
|
|
productCategory: c.productCategory,
|
|
isExtended: extended != nil,
|
|
kind: kind,
|
|
hasLight: c.light != nil,
|
|
hasHaptics: c.haptics != nil,
|
|
hasMotion: c.motion != nil,
|
|
// GCDualSenseGamepad's triggers are GCDualSenseAdaptiveTrigger by declaration; the
|
|
// DualShock 4 has none.
|
|
hasAdaptiveTriggers: kind == .dualSense,
|
|
batteryLevel: c.battery.flatMap { $0.batteryLevel >= 0 ? $0.batteryLevel : nil },
|
|
isCharging: c.battery?.batteryState == .charging,
|
|
controller: c)
|
|
}
|
|
|
|
/// Resolve a physical controller's matching virtual-pad type from its GameController
|
|
/// subclass. Detection order (all are `: GCExtendedGamepad`): DualSense first, then
|
|
/// DualShock 4, then any Xbox pad, else fall back to Xbox 360. A non-extended / absent
|
|
/// profile also falls back to `.xbox360` (it's never forwarded anyway).
|
|
private static func padKind(
|
|
_ extended: GCExtendedGamepad?
|
|
) -> PunktfunkConnection.GamepadType {
|
|
guard let extended else { return .xbox360 }
|
|
// Deployment floor (macOS 14 / iOS 17 / tvOS 17) clears every introduction version
|
|
// here, so no `@available` guard is needed — matching the unguarded
|
|
// `GCDualSenseGamepad` use elsewhere in the package.
|
|
if extended is GCDualSenseGamepad { return .dualSense }
|
|
if extended is GCDualShockGamepad { return .dualShock4 }
|
|
if extended is GCXboxGamepad { return .xboxOne }
|
|
return .xbox360
|
|
}
|
|
}
|