97c67b2692
Roll the pf-client-core slot pattern to the Apple client (Swift): - GamepadManager tracks all connected GCControllers, assigning each a stable lowest-free wire pad index + concrete type, emitting GamepadArrival on connect and GamepadRemove on disconnect (index freed for reuse on re-plug). - GamepadCapture binds every controller with per-controller Slot state (buttons/axes/fingers/motion), threading the pad index into flags on every event; GamepadWire/InputEvents carry the pad + the two new events. - GamepadFeedback + RumbleRenderer go per-pad (rumbleByPad, slots[pad]), routing rumble/HID back to the correct controller by wire index. - ContentView/Settings surface every forwarded controller. pad 0 => flags 0, so single-controller wire is byte-identical. Cannot build on the Linux dev box (no Swift toolchain / Apple frameworks); wire bytes hand-checked against input.rs and GamepadWireTests extended for multi-pad. CI apple.yml (swift build/test on macOS) is the compile gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
265 lines
13 KiB
Swift
265 lines
13 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 `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 — 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 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>) -> UInt8? {
|
|
(0..<UInt8(GamepadWire.maxPads)).first { !taken.contains($0) }
|
|
}
|
|
|
|
/// The wire pad index a forwarded controller streams on, or nil when it isn't forwarded.
|
|
public func padIndex(for controller: DiscoveredController) -> 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)
|
|
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
|
|
}
|
|
}
|