// 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 `forwarded` — every forwarded controller is streamed to the host, each on its own // wire pad index (pf-client-core parity; up to `GamepadWire.maxPads`). // // Selection (mirrors pf-client-core's `forwarded_ids` + slot model): with no pin, EVERY // extended controller is forwarded — each assigned a stable lowest-free pad index held for // its forwarded lifetime, so a disconnect frees only its own index and never renumbers the // others. A pin (Settings, persisted under DefaultsKey.gamepadID) forwards ONLY that one pad // — an explicit single-player choice. `active` stays the single "primary" pad (the pinned // one, else the most recently connected extended gamepad) that the Settings / launcher / menu // UI reads. 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 (incl. the Edge — same feedback surface) — gates the /// DualSense-only feedback (adaptive triggers, player LEDs) and the PlayStation glyph /// in Settings. public var isDualSense: Bool { kind == .dualSense || kind == .dualSenseEdge } /// A PlayStation pad with a touchpad + motion (DualSense family OR DualShock 4) — gates /// rich-input CAPTURE (touchpad contacts + gyro/accel on plane 0xCC). public var hasTouchpadAndMotion: Bool { kind == .dualSense || kind == .dualSenseEdge || 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 single "primary" controller — the pinned one, else the most recently connected /// extended gamepad; nil when none qualifies. The Settings / launcher / menu UI and the /// connect-time `resolveType` read this; the streaming input path uses `forwarded`. @Published public private(set) var active: DiscoveredController? /// The controllers forwarded to the host this session, in wire-pad-index preference order /// (pf-client-core's `forwarded_ids`): a pin forwards ONLY the pinned pad; Automatic forwards /// every extended controller. GamepadCapture opens a slot per entry and GamepadFeedback routes /// feedback back to it, each on the index from `padIndex(for:)`. @Published public private(set) var forwarded: [DiscoveredController] = [] /// Stable wire pad index (0..<`GamepadWire.maxPads`) per forwarded controller, keyed by /// GCController identity. Lowest-free, held while the controller stays forwarded — a /// disconnect frees only its own index so the others never renumber (pf-client-core's /// `lowest_free_index`). Recomputed by `assignPadIndices` whenever `forwarded` changes. private var padIndexByController: [ObjectIdentifier: UInt8] = [:] /// 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. let pinned = candidates.last { $0.id == preferredID } active = pinned ?? candidates.last // Forwarded set (pf-client-core's `forwarded_ids`): a pin forwards ONLY the pinned pad // (explicit single-player); Automatic forwards every extended controller in connect order // (oldest→newest), so a game's player numbers are stable across hot-plug churn. let next = pinned.map { [$0] } ?? candidates // Update the pad-index assignment BEFORE publishing `forwarded`: @Published emits in // `willSet`, so GamepadCapture/GamepadFeedback reconcile against `padIndex(for:)` the // instant this assignment lands — a stale map here would skip a newly-forwarded pad. assignPadIndices(for: next) forwarded = next } /// Assign each forwarded controller a stable wire pad index (lowest-free, held while it stays /// forwarded) — mirrors pf-client-core's slot model, where a disconnect frees only its own /// index and the others keep theirs. A controller already holding an index keeps it across the /// churn; a slot beyond `GamepadWire.maxPads` goes unassigned (that pad is not forwarded). private func assignPadIndices(for next: [DiscoveredController]) { let live = Set(next.map { ObjectIdentifier($0.controller) }) padIndexByController = padIndexByController.filter { live.contains($0.key) } for dc in next { let key = ObjectIdentifier(dc.controller) guard padIndexByController[key] == nil, let free = Self.lowestFreeIndex(Set(padIndexByController.values)) else { continue } padIndexByController[key] = free } } /// The lowest wire pad index not already taken, or nil when all `GamepadWire.maxPads` are in /// use (pf-client-core's `lowest_free_index`). private static func lowestFreeIndex(_ taken: Set) -> UInt8? { (0.. UInt8? { padIndexByController[ObjectIdentifier(controller.controller)] } /// Drop every pad-index assignment and recompute from the current forwarded set — called when /// a streaming session begins so the assignment starts fresh (a controller pinned before the /// session forwards as pad 0, not whatever index it held for the Settings list). pf-client-core /// assigns indices at slot-open time; this reproduces that session-scoped start. public func resetForwardingAssignment() { padIndexByController.removeAll() reselect() } private static func describe(_ c: GCController, id: String) -> DiscoveredController { let extended = c.extendedGamepad let kind = padKind(extended, productCategory: c.productCategory) 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 // Edge included); the DualShock 4 has none. hasAdaptiveTriggers: kind == .dualSense || kind == .dualSenseEdge, 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 (+ the product-category string where the subclass is shared). Detection order /// (all are `: GCExtendedGamepad`): DualSense family first (the Edge is a /// `GCDualSenseGamepad` too — its distinct product category splits it out), then /// DualShock 4, any Xbox pad, then Nintendo Switch pads by category (GameController has no /// dedicated subclass for them). A non-extended / absent profile falls back to `.xbox360` /// (it's never forwarded anyway). private static func padKind( _ extended: GCExtendedGamepad?, productCategory: String ) -> PunktfunkConnection.GamepadType { guard let extended else { return .xbox360 } let category = productCategory.lowercased() // 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 category.contains("edge") ? .dualSenseEdge : .dualSense } if extended is GCDualShockGamepad { return .dualShock4 } if extended is GCXboxGamepad { return .xboxOne } // Nintendo Switch Pro Controller / a paired Joy-Con set (a full pad surface). Single // Joy-Cons ("Joy-Con (L)" / "(R)") stay on the Xbox 360 fallback — half a pad. if category.contains("switch pro") || category.contains("joy-con (l/r)") { return .switchPro } return .xbox360 } }