01305c67a7
apple / swift (push) Successful in 54s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Failing after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Failing after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Failing after 1s
docker / deploy-docs (push) Has been skipped
android / android (push) Failing after 0s
ci / rust (push) Failing after 1s
ci / web (push) Failing after 0s
ci / docs-site (push) Failing after 0s
ci / bench (push) Failing after 0s
deb / build-publish (push) Failing after 0s
decky / build-publish (push) Failing after 1s
flatpak / build-publish (push) Failing after 0s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 0s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 0s
The DualSense intermittently showed up as an Xbox 360 pad on the host: the client's `.auto` gamepad-type resolution read `GamepadManager.active`, which is populated only by the async `.GCControllerDidConnect` notification (or the init-time snapshot). At connect time `active` could still be nil with a DualSense attached, so the client sent `.auto` and the host's pick_gamepad mapped that to Xbox 360. Confirmed live: same box, two connects minutes apart logged `gamepad="xbox360"` (auto) vs `honoring client gamepad request gamepad="dualsense"`. resolveType() now calls rebuild() first to re-read GCController.controllers() synchronously before reading `active`, closing the race for the common case (controller attached before connecting). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
172 lines
7.5 KiB
Swift
172 lines
7.5 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
|
|
public let isDualSense: Bool
|
|
public let hasLight: Bool
|
|
public let hasHaptics: Bool
|
|
public let hasMotion: Bool
|
|
public let hasAdaptiveTriggers: Bool
|
|
/// 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, 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.isDualSense ? .dualSense : .xbox360
|
|
}
|
|
|
|
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 ds = extended as? GCDualSenseGamepad
|
|
return DiscoveredController(
|
|
id: id,
|
|
name: c.vendorName ?? c.productCategory,
|
|
productCategory: c.productCategory,
|
|
isExtended: extended != nil,
|
|
isDualSense: ds != nil,
|
|
hasLight: c.light != nil,
|
|
hasHaptics: c.haptics != nil,
|
|
hasMotion: c.motion != nil,
|
|
// GCDualSenseGamepad's triggers are GCDualSenseAdaptiveTrigger by declaration.
|
|
hasAdaptiveTriggers: ds != nil,
|
|
batteryLevel: c.battery.flatMap { $0.batteryLevel >= 0 ? $0.batteryLevel : nil },
|
|
isCharging: c.battery?.batteryState == .charging,
|
|
controller: c)
|
|
}
|
|
}
|