feat(apple): gamepad UI v2 — controller settings + add host, aurora, macOS

Sources reorganized (client: Home/Session/Settings/Stores/Support/Trust; kit:
Audio/Connection/Gamepad/Input/Support/Video/Views) with the big files split
along the same seams.

The gamepad mode is couch-complete, and now on macOS too (the living-room
Mac case), not just iOS/iPadOS:

- GamepadSettingsView: a console-style, fully controller-navigable settings
  screen (X from the launcher) — up/down moves focus, left/right steps values
  (clamped, boundary thud), A cycles/toggles, B closes; the focused row shows a
  one-line description. Backed by GamepadMenuList, the vertical sibling of
  GamepadCarousel, and SettingsOptions — the option lists hoisted out of
  SettingsView statics and shared by the touch, tvOS and gamepad settings.
- GamepadAddHostView + GamepadKeyboard: register a host end to end with a pad
  — field rows open an on-screen controller keyboard (dpad grid, A types,
  X backspaces, B done); the launcher carousel ends in an Add Host tile, so
  the dead-end "add one with touch first" empty state is gone.
- Launcher polish: contextual hint bar with the pad's real button glyphs,
  controller name + battery chip, one shared console chrome.
- GamepadScreenBackground: an animated aurora (TimelineView-driven drifting
  blobs in the brand's violet family, breathing radii, slow hue shift,
  legibility scrim; freezes under Reduce Motion). Pure SwiftUI on purpose — a
  .metal library only bundles reliably in one of the two build systems (SPM vs
  the xcodeproj's synced folders) these sources compile under.
- macOS port: settings/add-host/library present as sized sheets (a macOS sheet
  takes its content's IDEAL size, and the GeometryReader-driven screens
  collapsed to nothing), NSScreen-based mode lists, scroll indicators .never
  (the "always show scroll bars" setting overrides .hidden), tray scrims so
  scrolled rows dim under the pinned title/hints, extra title clearance, and a
  PUNKTFUNK_FORCE_GAMEPAD_UI=1 dev hook — launcher/settings/add-host/keyboard/
  library render-verified live on a real Mac + LAN hosts.
- GamepadMenuInput: X button support, and (re)start now snapshots held buttons
  so a controller handoff press never fires twice (the B that closed the
  keyboard no longer also cancels the screen underneath).
- Cleanups: one "Connection failed" alert in ContentView instead of one per
  home screen; HostDiscovery.advertises/unsaved shared by both home screens.
- host: can_encode_444 stub for the non-Linux/Windows host build (the macOS
  synthetic-source loopback used by the Swift tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 11:05:10 +02:00
parent e925d00194
commit 133e25849d
84 changed files with 4231 additions and 2698 deletions
@@ -0,0 +1,73 @@
#if DEBUG
import Combine
import GameController
/// Local feedback driver for the Settings Controllers "Test Controller" panel (DEBUG builds
/// only). It drives the SAME CoreHaptics rumble renderer and `DualSenseTriggerEffect` path a
/// live session uses just aimed at the physically-connected controller instead of the
/// hostclient feedback planes so rumble, the adaptive triggers, the lightbar and the player
/// LEDs can be confirmed on-device without a host. Reusing the real renderers is the point:
/// a passing test exercises the exact code a session runs.
@MainActor
public final class ControllerTester: ObservableObject {
private let renderer = RumbleRenderer()
private weak var controller: GCController?
/// The rumble backend now in use "DualSense HID · USB/Bluetooth", "CoreHaptics", or ""
/// for the test panel to display so it's obvious which path a given pad takes.
@Published public private(set) var rumbleBackend = ""
public init() {}
/// Aim the feedback at a controller (nil releases it). Idempotent safe to call on every
/// active-controller change.
public func target(_ c: GCController?) {
guard c !== controller else { return }
controller = c
renderer.retarget(c) { [weak self] note in
Task { @MainActor in self?.rumbleBackend = note }
}
}
/// Drive both motors at 0...1 amplitudes low = left/heavy, high = right/light mapped to
/// the 0...0xFFFF wire range the session carries, through the real `RumbleRenderer`.
public func rumble(low: Float, high: Float) {
func u16(_ v: Float) -> UInt16 { UInt16((min(max(v, 0), 1) * 65535).rounded()) }
renderer.apply(low: u16(low), high: u16(high))
}
public func stopRumble() { renderer.apply(low: 0, high: 0) }
/// Replay an adaptive-trigger effect on a DualSense via the real `DualSenseTriggerEffect`
/// renderer. `right == false` L2, `true` R2. No-op on a non-DualSense pad.
public func applyTrigger(_ effect: DualSenseTriggerEffect, right: Bool) {
guard let ds = controller?.extendedGamepad as? GCDualSenseGamepad else { return }
effect.apply(to: right ? ds.rightTrigger : ds.leftTrigger)
}
public func resetTriggers() {
guard let ds = controller?.extendedGamepad as? GCDualSenseGamepad else { return }
ds.leftTrigger.setModeOff()
ds.rightTrigger.setModeOff()
}
/// Lightbar colour (DualSense / DualShock 4); nil turns it off. No-op without a light.
public func setLight(_ color: GCColor?) {
controller?.light?.color = color ?? GCColor(red: 0, green: 0, blue: 0)
}
/// Player-indicator LEDs (`.index1`...`.index4`, or `.indexUnset` to clear).
public func setPlayerIndex(_ index: GCControllerPlayerIndex) {
controller?.playerIndex = index
}
/// Silence every channel and release the controller call on the panel's disappear.
public func stop() {
resetTriggers()
setPlayerIndex(.indexUnset)
setLight(nil)
renderer.retarget(nil) // async teardown: stops the motors + drops the controller ref
controller = nil
}
}
#endif
@@ -0,0 +1,153 @@
// Raw-HID DualSense rumble for macOS.
//
// Apple's GameController/CHHapticEngine path does NOT drive the DualSense's rumble motors on
// macOS a documented platform gap: adaptive triggers, lightbar and player LEDs all work
// (different APIs), but `CHHapticEngine` output never reaches the motors. So we write the motor
// amplitudes straight into the DualSense HID output report, exactly the way SDL and the Linux
// `hid-playstation` driver do (the same report that already rumbles this pad on a Linux host).
//
// USB (report 0x02, 48 bytes, no CRC) and Bluetooth (report 0x31, 78 bytes, trailing CRC32) are
// both handled. The App Sandbox permits the raw-HID access via the app's `device.usb` +
// `device.bluetooth` entitlements, and this coexists with GameController holding the same device
// (non-seized open). Output-only, so no run-loop scheduling is needed.
//
// macOS-only: IOKit HID device access isn't available to apps on iOS/tvOS.
#if os(macOS)
import Foundation
import IOKit
import IOKit.hid
import os
private let log = Logger(subsystem: "io.unom.punktfunk", category: "gamepad")
/// Opens the first connected Sony DualSense and forwards motor rumble to it over raw HID.
/// Single-pad model (we forward exactly one controller), so the first match is the right one.
final class DualSenseHID {
private let manager: IOHIDManager
private var device: IOHIDDevice?
private var bluetooth = false
private var closed = false
private static let vendorSony = 0x054C
// DualSense (0x0CE6) and DualSense Edge (0x0DF2). The DualShock 4 uses a different report
// layout and is intentionally not handled here.
private static let productIDs = [0x0CE6, 0x0DF2]
/// "USB" or "Bluetooth" for logs / the debug panel. Valid after a successful `open()`.
var transport: String { bluetooth ? "Bluetooth" : "USB" }
init() {
manager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone))
}
deinit { close() }
/// Find and open the first connected DualSense. Returns false if none is present or it can't
/// be opened (caller then falls back to CoreHaptics).
func open() -> Bool {
let matches = Self.productIDs.map { pid in
[kIOHIDVendorIDKey: Self.vendorSony, kIOHIDProductIDKey: pid] as CFDictionary
}
IOHIDManagerSetDeviceMatchingMultiple(manager, matches as CFArray)
guard IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone)) == kIOReturnSuccess else {
log.info("rumble: DualSense HID manager open failed — falling back to CoreHaptics")
return false
}
guard let devices = IOHIDManagerCopyDevices(manager) as? Set<IOHIDDevice>,
let dev = devices.first
else {
log.info("rumble: no DualSense HID device found — falling back to CoreHaptics")
IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone))
return false
}
device = dev
let transport = IOHIDDeviceGetProperty(dev, kIOHIDTransportKey as CFString) as? String
bluetooth = transport?.lowercased().contains("bluetooth") ?? false
log.info("rumble: DualSense raw-HID rumble active (transport=\(self.transport, privacy: .public))")
return true
}
/// Drive the motors. `low` = left/heavy (low-frequency), `high` = right/light (high-frequency),
/// each 0...255. (0, 0) stops.
func rumble(low: UInt8, high: UInt8) {
guard let dev = device else { return }
let report = bluetooth
? Self.bluetoothReport(low: low, high: high)
: Self.usbReport(low: low, high: high)
let rc = report.withUnsafeBufferPointer { buf in
IOHIDDeviceSetReport(
dev, kIOHIDReportTypeOutput, CFIndex(report[0]), buf.baseAddress!, buf.count)
}
if rc != kIOReturnSuccess {
log.error("rumble: IOHIDDeviceSetReport failed (0x\(String(format: "%08x", rc), privacy: .public))")
}
}
func close() {
guard !closed else { return }
closed = true
if device != nil { rumble(low: 0, high: 0) } // silence the motors before releasing
device = nil
IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone))
}
// MARK: - Report builders
// DualSense effects payload (DS5EffectsState_t / hid-playstation `common`) offsets relative
// to the payload start:
// 0 flag0 (enable bits) 2 motor_right (high-freq) 3 motor_left (low-freq)
// 1 flag1 38 flag2 (enhanced enable)
// We mirror the Linux driver: flag0 = COMPATIBLE_VIBRATION | HAPTICS_SELECT, flag2 =
// COMPATIBLE_VIBRATION2 (the enhanced-firmware path), motors sent directly. valid_flag1 stays
// 0 so this rumble-only report leaves the lightbar / triggers / player LEDs (driven by
// GameController) untouched.
private static func fillEffects(_ data: inout [UInt8], at base: Int, low: UInt8, high: UInt8) {
data[base + 0] = 0x03 // COMPATIBLE_VIBRATION (0x01) | HAPTICS_SELECT (0x02)
data[base + 2] = high // motor_right
data[base + 3] = low // motor_left
data[base + 38] = 0x04 // COMPATIBLE_VIBRATION2 (enhanced rumble, firmware 0x0224)
}
// `usbReport` / `bluetoothReport` / `crc32` are internal (not private) so the unit tests can
// pin the exact wire layout against the SDL / hid-playstation spec without a physical pad.
static func usbReport(low: UInt8, high: UInt8) -> [UInt8] {
var d = [UInt8](repeating: 0, count: 48)
d[0] = 0x02 // report id
fillEffects(&d, at: 1, low: low, high: high)
return d
}
static func bluetoothReport(low: UInt8, high: UInt8) -> [UInt8] {
var d = [UInt8](repeating: 0, count: 78)
d[0] = 0x31 // report id
d[1] = 0x00 // seq/tag (static, as SDL)
d[2] = 0x10 // magic
fillEffects(&d, at: 3, low: low, high: high)
// Trailing CRC32 over a 0xA2 seed byte + the report minus its 4 CRC bytes, little-endian.
let crc = Self.crc32(seed: 0xA2, d[0..<(d.count - 4)])
d[74] = UInt8(crc & 0xFF)
d[75] = UInt8((crc >> 8) & 0xFF)
d[76] = UInt8((crc >> 16) & 0xFF)
d[77] = UInt8((crc >> 24) & 0xFF)
return d
}
/// Standard reflected CRC32 (zlib poly 0xEDB88320, init 0xFFFFFFFF, final XOR) over `seed`
/// followed by `bytes` the DualSense Bluetooth output-report checksum (seed 0xA2). Matches
/// SDL's `SDL_crc32`/the kernel's `crc32_le` framing.
static func crc32<S: Sequence>(seed: UInt8, _ bytes: S) -> UInt32
where S.Element == UInt8 {
var crc: UInt32 = 0xFFFF_FFFF
func step(_ b: UInt8) {
crc ^= UInt32(b)
for _ in 0..<8 {
crc = (crc & 1) != 0 ? (crc >> 1) ^ 0xEDB8_8320 : crc >> 1
}
}
step(seed)
for b in bytes { step(b) }
return ~crc
}
}
#endif
@@ -0,0 +1,188 @@
// DualSense adaptive-trigger effect parsing: the host forwards the raw 11-byte trigger
// parameter block a game wrote to its virtual DualSense (output report 0x02, bytes 1121
// for L2 / 2232 for R2: one mode byte + 10 parameter bytes). The mode values and layouts
// follow the community-established conventions (Nielk1's TriggerEffectGenerator, ds5w,
// inputtino) Sony has never documented them. Parsing is TOTAL: any unknown or short
// block degrades to `.off`, never traps, so a game using an exotic raw mode can't break
// the session.
//
// `parse` is a pure function (unit-tested without a controller); `apply(to:)` maps the
// result onto Apple's GCDualSenseAdaptiveTrigger exact for the 10-zone positional modes
// (0x21/0x26 the positional resistiveStrengths/amplitudes APIs), best-effort for the
// composite ones (bow, galloping) that have no GC equivalent.
import Foundation
import GameController
/// A parsed DualSense trigger effect. Positions and strengths are normalized 0...1
/// (GCDualSenseAdaptiveTrigger's domain); `positional*` carry one value per trigger zone
/// (10 zones across the travel).
public enum DualSenseTriggerEffect: Equatable, Sendable {
case off
/// Constant resistance from `start` to the end of travel.
case feedback(start: Float, strength: Float)
/// Resistance from `start` that releases past `end` (trigger-break / weapon feel).
case weapon(start: Float, end: Float, strength: Float)
/// Vibration once the trigger passes `start`.
case vibration(start: Float, amplitude: Float, frequency: Float)
/// Per-zone resistance (10 zones).
case positionalFeedback(strengths: [Float])
/// Per-zone vibration amplitudes (10 zones) at `frequency`.
case positionalVibration(amplitudes: [Float], frequency: Float)
/// Resistance ramping `startStrength` `endStrength` between `start` and `end`
/// (the closest GC rendering of the bow effect).
case slope(start: Float, end: Float, startStrength: Float, endStrength: Float)
/// Parse a raw trigger parameter block (`[mode, p0...p9]`, 11 bytes shorter blocks
/// are zero-padded). Never fails: unknown modes are `.off`.
public static func parse(_ block: [UInt8]) -> DualSenseTriggerEffect {
guard let mode = block.first else { return .off }
var p = [UInt8](block.dropFirst())
if p.count < 10 { p.append(contentsOf: [UInt8](repeating: 0, count: 10 - p.count)) }
// Helpers for the rich (0x2x) modes: a 10-bit active-zone mask in p0/p1 and 3-bit
// per-zone values packed little-endian into the following 4 bytes.
let zoneMask = UInt16(p[0]) | (UInt16(p[1]) << 8)
let packed = UInt32(p[2]) | (UInt32(p[3]) << 8) | (UInt32(p[4]) << 16) | (UInt32(p[5]) << 24)
func zoneValues() -> [UInt8] {
(0..<10).map { i in
zoneMask & (1 << i) != 0 ? UInt8((packed >> (3 * UInt32(i))) & 0x07) : 0
}
}
// DualSense 3-bit strengths are 0...7 where an *active* zone's value v renders as
// (v+1)/8 a present-but-zero strength still resists slightly.
func strength01(_ v: UInt8, active: Bool) -> Float {
active ? Float(v + 1) / 8 : 0
}
func zone01(_ z: Int) -> Float { Float(z) / 9 }
func firstActive() -> Int? { (0..<10).first { zoneMask & (1 << $0) != 0 } }
func lastActive() -> Int? { (0..<10).last { zoneMask & (1 << $0) != 0 } }
switch mode {
case 0x00, 0x05, 0xF0...0xFF:
// 0x00/0x05 are the documented off/reset modes; 0xFC/0xFB/0xFA show up from
// calibration-adjacent writes all render as off.
return .off
case 0x01:
// Legacy continuous resistance: p0 = start position, p1 = force (both 0...255).
return .feedback(start: Float(p[0]) / 255, strength: max(Float(p[1]) / 255, 1.0 / 8))
case 0x02:
// Legacy section: p0 = start, p1 = end (0...255); full-strength inside.
let s = Float(p[0]) / 255
let e = Float(p[1]) / 255
return .weapon(start: min(s, e), end: max(max(s, e), min(s, e) + 0.01), strength: 1)
case 0x06:
// Legacy vibration ("automatic gun") Nielk1's Simple_Vibration order:
// p0 = frequency Hz, p1 = amplitude, p2 = start position.
return .vibration(
start: Float(p[2]) / 255,
amplitude: max(Float(p[1]) / 255, 1.0 / 8),
frequency: Float(p[0]) / 255)
case 0x21:
// Feedback: 10-bit zone mask + 3-bit strength per zone. A uniform suffix maps
// exactly onto the simple feedback call; mixed strengths use the positional API.
guard let first = firstActive() else { return .off }
let values = zoneValues()
let active = values.enumerated().filter { zoneMask & (1 << $0.offset) != 0 }
if active.allSatisfy({ $0.element == active[0].element })
&& active.last?.offset == 9
&& active.map(\.offset) == Array(first...9)
{
return .feedback(
start: zone01(first), strength: strength01(active[0].element, active: true))
}
return .positionalFeedback(
strengths: values.enumerated().map {
strength01($0.element, active: zoneMask & (1 << $0.offset) != 0)
})
case 0x22:
// Bow (Nielk1 mode byte 0x22): start/end zones + draw strength and snap force
// packed as a 3-bit pair in p2 (low bits draw, bits 3-5 snap; p3 is always 0).
// No GC equivalent render as a slope from draw resistance down to the snap.
guard let s = firstActive(), let e = lastActive(), s < e else { return .off }
let draw = strength01(p[2] & 0x07, active: true)
let snap = strength01((p[2] >> 3) & 0x07, active: true)
return .slope(start: zone01(s), end: zone01(e), startStrength: draw, endStrength: snap)
case 0x25:
// Weapon (Nielk1 mode byte 0x25): zone mask marks the start and end zones,
// p2 = strength (3-bit, stored minus one).
guard let s = firstActive(), let e = lastActive(), s < e else { return .off }
return .weapon(start: zone01(s), end: zone01(e), strength: strength01(p[2] & 0x07, active: true))
case 0x26:
// Vibration: 10-bit zone mask + 3-bit amplitude per zone, p8 = frequency Hz.
guard zoneMask != 0 else { return .off }
let amplitudes = zoneValues().enumerated().map {
strength01($0.element, active: zoneMask & (1 << $0.offset) != 0)
}
return .positionalVibration(amplitudes: amplitudes, frequency: Float(p[8]) / 255)
case 0x23:
// Galloping (Nielk1 mode byte 0x23): start/end zones, p2 = packed foot timing,
// p3 = frequency Hz. The temporal hoofbeat pattern has no GC equivalent
// render as vibration across the active range.
guard let s = firstActive(), let e = lastActive() else { return .off }
var amplitudes = [Float](repeating: 0, count: 10)
for z in s...e { amplitudes[z] = 0.5 }
return .positionalVibration(amplitudes: amplitudes, frequency: Float(p[3]) / 255)
case 0x27:
// Machine (Nielk1 mode byte 0x27): start/end zones, p2 = two 3-bit amplitudes
// (low bits A, bits 3-5 B raw 0...7, no minus-one), p3 = frequency Hz. The
// A/B alternation is temporal render its stronger leg across the range.
guard let s = firstActive(), let e = lastActive() else { return .off }
let amp = Float(max(p[2] & 0x07, (p[2] >> 3) & 0x07)) / 7
guard amp > 0 else { return .off }
var amplitudes = [Float](repeating: 0, count: 10)
for z in s...e { amplitudes[z] = amp }
return .positionalVibration(amplitudes: amplitudes, frequency: Float(p[3]) / 255)
default:
return .off
}
}
}
extension DualSenseTriggerEffect {
/// Replay this effect on a physical DualSense trigger. Main-thread only (GameController
/// profile mutation). The GC `frequency` parameter is normalized 0...1 like ours.
@MainActor
public func apply(to trigger: GCDualSenseAdaptiveTrigger) {
switch self {
case .off:
trigger.setModeOff()
case let .feedback(start, strength):
trigger.setModeFeedbackWithStartPosition(start, resistiveStrength: strength)
case let .weapon(start, end, strength):
trigger.setModeWeaponWithStartPosition(start, endPosition: end, resistiveStrength: strength)
case let .vibration(start, amplitude, frequency):
trigger.setModeVibrationWithStartPosition(start, amplitude: amplitude, frequency: frequency)
case let .positionalFeedback(strengths):
var s = GCDualSenseAdaptiveTrigger.PositionalResistiveStrengths(
values: (0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
withUnsafeMutableBytes(of: &s.values) { raw in
let f = raw.bindMemory(to: Float.self)
for (i, v) in strengths.prefix(10).enumerated() { f[i] = v }
}
trigger.setModeFeedback(resistiveStrengths: s)
case let .positionalVibration(amplitudes, frequency):
var a = GCDualSenseAdaptiveTrigger.PositionalAmplitudes(
values: (0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
withUnsafeMutableBytes(of: &a.values) { raw in
let f = raw.bindMemory(to: Float.self)
for (i, v) in amplitudes.prefix(10).enumerated() { f[i] = v }
}
trigger.setModeVibration(amplitudes: a, frequency: frequency)
case let .slope(start, end, startStrength, endStrength):
trigger.setModeSlopeFeedback(
startPosition: start, endPosition: end,
startStrength: startStrength, endStrength: endStrength)
}
}
}
@@ -0,0 +1,295 @@
// Gamepad capture punktfunk/1 datagrams. Forwards exactly ONE controller whatever
// GamepadManager selected as pad 0, for the lifetime of a streaming session.
//
// The wire is incremental (one button/axis transition per 18-byte event, accumulated
// host-side into the virtual pad see punktfunk_core::input::gamepad), so we snapshot the
// full GCExtendedGamepad state on every valueChanged and diff against the previous
// snapshot. Sticks are ±32767 with +y = up (GC already matches, no flip), triggers 0...255.
//
// PlayStation-pad extras ride the rich-input plane (0xCC): touchpad contacts normalized
// 0...65535 (origin top-left, +y down GC's ±1/+y-up is converted here) and motion
// samples in raw DualSense sensor units (gyro 20 LSB per deg/s, accel 10000 LSB per g
// derived from the host's fixed calibration blob; the conversion lives in ONE place,
// `Wire`, so a live sign/scale correction is a one-line change). The host ignores both
// unless the session's virtual pad is a DualSense or DualShock 4 both carry a touchpad
// and motion, so the capture below covers either (`GCDualShockGamepad` exposes the same
// `touchpad*` surface as `GCDualSenseGamepad`).
//
// Unlike mouse/keyboard capture, gamepad forwarding is NOT gated on the mouse-capture
// toggle a controller can't click local UI, so it always drives the host while the app
// is active. On deactivation, controller switch, or stop, every held control is released
// on the wire (the host pad would otherwise stay stuck on the last state).
#if os(macOS)
import AppKit
#else
import UIKit
#endif
import Combine
import Foundation
import GameController
@MainActor
public final class GamepadCapture {
private let connection: PunktfunkConnection
private let manager: GamepadManager
private var activeSub: AnyCancellable?
private var observers: [NSObjectProtocol] = []
private var bound: GCController?
/// App inactive GC stops delivering; everything is released and stays silent.
private var suspended = false
// Last wire state (the diff base also what releaseAll() unwinds).
private var buttons: UInt32 = 0
private var axes: [Int32] = [0, 0, 0, 0, 0, 0]
private var fingerActive: [Bool] = [false, false]
private var lastMotionNs: UInt64 = 0
/// Motion forwarding floor: 4 ms between samples ( 250 Hz, the DualSense's own rate).
private static let motionIntervalNs: UInt64 = 4_000_000
public init(connection: PunktfunkConnection, manager: GamepadManager) {
self.connection = connection
self.manager = manager
}
public func start() {
// Fires immediately with the current selection, then on every change a switch
// releases the old controller's wire state before the new one takes over.
activeSub = manager.$active.sink { [weak self] dc in
MainActor.assumeIsolated { self?.rebind(to: dc?.controller) }
}
#if os(macOS)
let resign = NSApplication.willResignActiveNotification
let activate = NSApplication.didBecomeActiveNotification
#else
let resign = UIApplication.willResignActiveNotification
let activate = UIApplication.didBecomeActiveNotification
#endif
observers.append(NotificationCenter.default.addObserver(
forName: resign, object: nil, queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
self?.suspended = true
self?.releaseAll()
}
})
observers.append(NotificationCenter.default.addObserver(
forName: activate, object: nil, queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
guard let self else { return }
self.suspended = false
if let ext = self.bound?.extendedGamepad { self.sync(ext) }
}
})
}
public func stop() {
releaseAll()
rebind(to: nil)
activeSub = nil
observers.forEach { NotificationCenter.default.removeObserver($0) }
observers.removeAll()
}
private func rebind(to controller: GCController?) {
guard controller !== bound else { return }
releaseAll()
if let ext = bound?.extendedGamepad {
ext.valueChangedHandler = nil
let tp = Self.touchpad(ext)
tp?.primary.valueChangedHandler = nil
tp?.secondary.valueChangedHandler = nil
}
if let motion = bound?.motion {
motion.valueChangedHandler = nil
// Power the sensors back down left active they keep the pad streaming
// gyro/accel over Bluetooth (battery drain) long after the session.
if motion.sensorsRequireManualActivation { motion.sensorsActive = false }
}
bound = controller
guard let c = controller, let ext = c.extendedGamepad else { return }
ext.valueChangedHandler = { [weak self] g, _ in
MainActor.assumeIsolated { self?.sync(g) }
}
// The Home/PS button ( guide; the host maps it to the DualSense PS / Xbox guide bit). On
// macOS the SYSTEM grabs it by default (opens Launchpad's Games folder), so it never reached
// the app `preferredSystemGestureState = .disabled` on the element is what hands it to us.
// We drive `guide` DIRECTLY from this handler's pressed value (not via buttonMask), because
// the legacy `extendedGamepad.buttonHome` is unreliable/often nil even when the physical
// element exists. On tvOS the element is absent (reserved) nil, the whole block no-ops.
if let home = c.physicalInputProfile.buttons[GCInputButtonHome] {
home.preferredSystemGestureState = .disabled
home.pressedChangedHandler = { [weak self] _, _, pressed in
MainActor.assumeIsolated { self?.sendGuide(down: pressed) }
}
}
// Wake the host pad immediately (pads are created lazily from the first event;
// a DualSense's UHID handshake + initial lightbar write only start then).
connection.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: 0))
sync(ext)
if let tp = Self.touchpad(ext) {
tp.primary.valueChangedHandler = { [weak self] _, x, y in
MainActor.assumeIsolated { self?.touch(finger: 0, x: x, y: y) }
}
tp.secondary.valueChangedHandler = { [weak self] _, x, y in
MainActor.assumeIsolated { self?.touch(finger: 1, x: x, y: y) }
}
}
if let motion = c.motion {
if motion.sensorsRequireManualActivation { motion.sensorsActive = true }
motion.valueChangedHandler = { [weak self] m in
MainActor.assumeIsolated { self?.forwardMotion(m) }
}
}
}
/// Snapshot the profile into wire state and send every transition since the last one.
private func sync(_ g: GCExtendedGamepad) {
guard !suspended else { return }
let newButtons = Self.buttonMask(g)
let changed = newButtons ^ buttons
if changed != 0 {
for bit in GamepadWire.allButtons where changed & bit != 0 {
connection.send(.gamepadButton(bit, down: newButtons & bit != 0, pad: 0))
}
buttons = newButtons
}
let newAxes: [Int32] = [
Int32((g.leftThumbstick.xAxis.value * 32767).rounded()),
Int32((g.leftThumbstick.yAxis.value * 32767).rounded()),
Int32((g.rightThumbstick.xAxis.value * 32767).rounded()),
Int32((g.rightThumbstick.yAxis.value * 32767).rounded()),
Int32((g.leftTrigger.value * 255).rounded()),
Int32((g.rightTrigger.value * 255).rounded()),
]
for (i, v) in newAxes.enumerated() where v != axes[i] {
connection.send(.gamepadAxis(UInt32(i), value: v, pad: 0))
axes[i] = v
}
}
/// Forward the guide (Home/PS) transition directly it's kept out of `buttonMask` (the legacy
/// `buttonHome` element is unreliable). Folds into `buttons` so a held PS button is released by
/// `releaseAll` on focus loss just like the others.
private func sendGuide(down: Bool) {
guard !suspended else { return }
let bit = GamepadWire.guide
let now = down ? (buttons | bit) : (buttons & ~bit)
guard now != buttons else { return }
connection.send(.gamepadButton(bit, down: down, pad: 0))
buttons = now
}
private static func buttonMask(_ g: GCExtendedGamepad) -> UInt32 {
var b: UInt32 = 0
if g.dpad.up.isPressed { b |= GamepadWire.dpadUp }
if g.dpad.down.isPressed { b |= GamepadWire.dpadDown }
if g.dpad.left.isPressed { b |= GamepadWire.dpadLeft }
if g.dpad.right.isPressed { b |= GamepadWire.dpadRight }
if g.buttonMenu.isPressed { b |= GamepadWire.start }
if g.buttonOptions?.isPressed == true { b |= GamepadWire.back }
if g.leftThumbstickButton?.isPressed == true { b |= GamepadWire.leftStickClick }
if g.rightThumbstickButton?.isPressed == true { b |= GamepadWire.rightStickClick }
if g.leftShoulder.isPressed { b |= GamepadWire.leftShoulder }
if g.rightShoulder.isPressed { b |= GamepadWire.rightShoulder }
// guide (Home/PS) is NOT read here it's forwarded directly by the Home button's
// pressedChangedHandler (the legacy `buttonHome` element is unreliable). See `rebind`.
if g.buttonA.isPressed { b |= GamepadWire.a }
if g.buttonB.isPressed { b |= GamepadWire.b }
if g.buttonX.isPressed { b |= GamepadWire.x }
if g.buttonY.isPressed { b |= GamepadWire.y }
if Self.touchpad(g)?.button.isPressed == true {
b |= GamepadWire.touchpadClick
}
return b
}
/// The touchpad surface of a PlayStation pad present on both `GCDualSenseGamepad` and
/// `GCDualShockGamepad` (DualShock 4), which don't share a common touchpad type, so we
/// downcast either and project the identical `touchpad*` properties. `nil` for any other
/// controller (Xbox, MFi).
private static func touchpad(
_ g: GCExtendedGamepad
) -> (primary: GCControllerDirectionPad, secondary: GCControllerDirectionPad,
button: GCControllerButtonInput)? {
if let ds = g as? GCDualSenseGamepad {
return (ds.touchpadPrimary, ds.touchpadSecondary, ds.touchpadButton)
}
if let ds4 = g as? GCDualShockGamepad {
return (ds4.touchpadPrimary, ds4.touchpadSecondary, ds4.touchpadButton)
}
return nil
}
/// One touchpad finger moved. GC reports ±1 positions and snaps to exactly (0, 0) on
/// lift treated as the lift signal (a real finger landing on the precise center
/// momentarily reads as a lift; harmless for a 1-in-65k coincidence).
private func touch(finger: Int, x: Float, y: Float) {
guard !suspended else { return }
let lifted = x == 0 && y == 0
if lifted {
if fingerActive[finger] {
fingerActive[finger] = false
connection.sendTouchpad(finger: UInt8(finger), active: false, x: 0, y: 0)
}
return
}
fingerActive[finger] = true
let w = GamepadWire.touchpad(x: x, y: y)
connection.sendTouchpad(finger: UInt8(finger), active: true, x: w.x, y: w.y)
}
private func forwardMotion(_ m: GCMotion) {
guard !suspended else { return }
let now = DispatchTime.now().uptimeNanoseconds
guard now &- lastMotionNs >= Self.motionIntervalNs else { return }
lastMotionNs = now
// Total acceleration in g: gravity + user when split, else the raw vector.
let ax: Float
let ay: Float
let az: Float
if m.hasGravityAndUserAcceleration {
ax = Float(m.gravity.x + m.userAcceleration.x)
ay = Float(m.gravity.y + m.userAcceleration.y)
az = Float(m.gravity.z + m.userAcceleration.z)
} else {
ax = Float(m.acceleration.x)
ay = Float(m.acceleration.y)
az = Float(m.acceleration.z)
}
let gs = GamepadWire.gyroLSBPerRadS
let as_ = GamepadWire.accelLSBPerG
connection.sendMotion(
gyro: (
GamepadWire.motionRaw(Float(m.rotationRate.x), scale: gs),
GamepadWire.motionRaw(Float(m.rotationRate.y), scale: gs),
GamepadWire.motionRaw(Float(m.rotationRate.z), scale: gs)
),
accel: (
GamepadWire.motionRaw(ax, scale: as_),
GamepadWire.motionRaw(ay, scale: as_),
GamepadWire.motionRaw(az, scale: as_)
))
}
/// Unwind everything held on the wire: button-ups, neutral axes, lifted fingers. The
/// host's virtual pad returns to rest instead of running with the last state.
private func releaseAll() {
for bit in GamepadWire.allButtons where buttons & bit != 0 {
connection.send(.gamepadButton(bit, down: false, pad: 0))
}
buttons = 0
for (i, v) in axes.enumerated() where v != 0 {
connection.send(.gamepadAxis(UInt32(i), value: 0, pad: 0))
axes[i] = 0
}
for (f, active) in fingerActive.enumerated() where active {
connection.sendTouchpad(finger: UInt8(f), active: false, x: 0, y: 0)
fingerActive[f] = false
}
}
}
@@ -0,0 +1,195 @@
// Hostclient gamepad feedback rendering: one drain thread polls the rumble (0xCA) and
// HID-output (0xCD) planes and replays them on the active physical controller
//
// rumble CHHapticEngine players (per-handle localities when the pad has them,
// one combined engine otherwise),
// lightbar GCDeviceLight,
// player LEDs GCController.playerIndex (the DS bit patterns map to player 14),
// trigger FX DualSenseTriggerEffect.parse GCDualSenseAdaptiveTrigger.
//
// Only pad 0 is rendered (exactly one controller is forwarded). HID-output traffic exists
// only on PlayStation-pad sessions (a DualSense, or a DualShock 4 = lightbar only) the
// drain always polls both planes with short timeouts and never spins, so an Xbox session
// just renders rumble. GameController profile mutation
// happens on main; CHHapticEngine work on its own serial queue; the drain thread itself
// touches neither. When GamepadManager switches the active controller mid-session, the
// old pad is reset (triggers off, player index unset) and the last known feedback state
// is replayed onto the new one.
import Combine
import Foundation
import GameController
public final class GamepadFeedback {
private let connection: PunktfunkConnection
private let flag = StopFlag()
private let drainDone = DispatchSemaphore(value: 0)
private var drainStarted = false
private let rumble = RumbleRenderer()
private var activeSub: AnyCancellable?
// Last applied feedback (main-actor) replayed when the active controller changes.
@MainActor private var target: GCController?
@MainActor private var lastLight: (r: UInt8, g: UInt8, b: UInt8)?
@MainActor private var lastPlayerBits: UInt8?
@MainActor private var lastTrigger: [DualSenseTriggerEffect?] = [nil, nil]
public init(connection: PunktfunkConnection, manager: GamepadManager) {
self.connection = connection
// Capture self weakly in the hop too, so the inner sink's weak capture isn't shadowing
// an implicit strong one and the subscription (stored on self) never retain-cycles.
Task { @MainActor [weak self] in
guard let self else { return }
self.activeSub = manager.$active.sink { [weak self] dc in
MainActor.assumeIsolated { self?.retarget(dc?.controller) }
}
}
}
/// Safety net: the drain thread captures `connection` strongly and only `self` weakly, so if
/// this is dropped without `stop()` (an abrupt teardown) the thread would poll forever and
/// leak the connection signal it to exit. (`stop()` is the normal path and also joins it.)
deinit { flag.stop() }
/// Map the DualSense player-LED bit patterns (5 LEDs, hid-playstation's player
/// conventions) onto GCControllerPlayerIndex. Unknown patterns fall back to the lit
/// count, clamped to the four indices GC offers.
public static func playerIndex(forBits bits: UInt8) -> GCControllerPlayerIndex {
switch bits & 0x1F {
case 0: return .indexUnset
case 0b00100: return .index1
case 0b01010: return .index2
case 0b10101: return .index3
case 0b11011: return .index4
default:
let lit = (bits & 0x1F).nonzeroBitCount
return GCControllerPlayerIndex(rawValue: min(lit, 4) - 1) ?? .index1
}
}
public func start() {
guard !drainStarted else { return }
drainStarted = true
// Hidout traffic (lightbar / player LEDs / triggers) only exists on a PlayStation-pad
// session a DualSense or a DualShock 4 (lightbar only). Block briefly on it there and
// let rumble own the wait elsewhere; on an Xbox session it stays nonblocking.
let thread = Thread { [connection, flag, drainDone, weak self] in
while !flag.isStopped {
do {
// Poll the feedback planes NON-BLOCKING. A blocking poll (timeoutMs > 0) holds
// the connection's shared feedback lock for its whole wait; the video pump drains
// HDR mastering metadata (nextHdrMeta) on the SAME lock every frame, so a blocking
// poll here starved it and throttled HDR to ~1 fps (SDR, which never drains HDR
// meta, was unaffected). Pacing with a short sleep OUTSIDE the lock (below) keeps
// rumble/HID latency low while leaving the lock free between polls.
if let r = try connection.nextRumble(timeoutMs: 0), r.pad == 0 {
self?.rumble.apply(low: r.low, high: r.high)
}
// Drain a BOUNDED burst of hidout events so sustained 0xCD traffic (a game writing
// per-frame LED/trigger reports) can't spin here or block stop() past one cycle.
var burst = 0
while burst < 64, !flag.isStopped,
let ev = try connection.nextHidOutput(timeoutMs: 0) {
self?.render(ev)
burst += 1
}
} catch {
break // .closed (or fatal) the session is over
}
// ~8 ms poll cadence (125 Hz), slept OUTSIDE the feedback lock low rumble/HID
// latency without holding the lock the HDR-meta drain needs.
if !flag.isStopped { Thread.sleep(forTimeInterval: 0.008) }
}
drainDone.signal()
}
thread.name = "punktfunk-feedback"
thread.qualityOfService = .userInteractive
thread.start()
}
/// Stop the drain and silence the motors. Blocks until the drain thread exits ( one
/// poll cycle) call off the main actor, before `connection.close()`.
public func stop() {
flag.stop()
if drainStarted {
drainDone.wait()
drainStarted = false
}
rumble.stop()
// Drop the retarget subscription and the dead session's cached feedback a
// controller change after teardown must not replay this session's triggers/LEDs.
Task { @MainActor in
self.activeSub = nil
self.lastLight = nil
self.lastPlayerBits = nil
self.lastTrigger = [nil, nil]
self.reset(self.target)
self.target = nil
}
}
private func render(_ ev: PunktfunkConnection.HidOutputEvent) {
DispatchQueue.main.async {
MainActor.assumeIsolated { self.apply(ev) }
}
}
@MainActor
private func apply(_ ev: PunktfunkConnection.HidOutputEvent) {
switch ev {
case let .led(pad, r, g, b):
guard pad == 0 else { return }
lastLight = (r, g, b)
target?.light?.color = GCColor(
red: Float(r) / 255, green: Float(g) / 255, blue: Float(b) / 255)
case let .playerLEDs(pad, bits):
guard pad == 0 else { return }
lastPlayerBits = bits
target?.playerIndex = Self.playerIndex(forBits: bits)
case let .triggerEffect(pad, which, effect):
guard pad == 0, which < 2 else { return }
let parsed = DualSenseTriggerEffect.parse(effect)
lastTrigger[Int(which)] = parsed
if let trigger = adaptiveTrigger(which) {
parsed.apply(to: trigger)
}
}
}
@MainActor
private func retarget(_ controller: GCController?) {
guard controller !== target else { return }
reset(target)
target = controller
rumble.retarget(controller)
// Replay the session's feedback state so a swapped-in controller looks the same.
if let (r, g, b) = lastLight {
controller?.light?.color = GCColor(
red: Float(r) / 255, green: Float(g) / 255, blue: Float(b) / 255)
}
if let bits = lastPlayerBits {
controller?.playerIndex = Self.playerIndex(forBits: bits)
}
for which in 0..<2 {
if let effect = lastTrigger[which], let trigger = adaptiveTrigger(UInt8(which)) {
effect.apply(to: trigger)
}
}
}
@MainActor
private func reset(_ controller: GCController?) {
guard let c = controller else { return }
c.playerIndex = .indexUnset
if let ds = c.extendedGamepad as? GCDualSenseGamepad {
ds.leftTrigger.setModeOff()
ds.rightTrigger.setModeOff()
}
}
@MainActor
private func adaptiveTrigger(_ which: UInt8) -> GCDualSenseAdaptiveTrigger? {
guard let ds = target?.extendedGamepad as? GCDualSenseGamepad else { return nil }
return which == 0 ? ds.leftTrigger : ds.rightTrigger
}
}
@@ -0,0 +1,201 @@
// 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
}
}
@@ -0,0 +1,178 @@
// Explicit left-stick/dpad-driven menu navigation for the gamepad UI's host carousel and library
// coverflow (iOS/iPadOS only see GamepadUIEnvironment).
//
// Polls the active controller at 60 Hz rather than installing `valueChangedHandler`/
// `pressedChangedHandler` callbacks mirroring `ControllerTestView`'s "Input" card (see its own
// comment: "Poll the live controller ... no handlers installed"), the one thing in this codebase
// already confirmed on real hardware to read a controller reliably outside a streaming session. Two
// earlier versions of this class both installed handlers directly (first reading the dpad's combined
// `.xAxis`/`.yAxis`, then its discrete `.isPressed` states, matching `GamepadCapture`'s pattern) and
// neither one's callbacks fired on-device even though the SAME controller's input showed up correctly
// in `ControllerTestView`'s poll-based readout so polling isn't just a style choice here, it's the
// only approach confirmed to actually work outside a stream. Being read-only, it also can't conflict
// with `GamepadCapture` installing its own handlers once a stream starts there's nothing to hand
// off or race over.
//
// The button set mirrors a console launcher: A confirms, B backs out, Y is a screen's secondary
// action, X a tertiary one, and the shoulders (L1/R1) are optional fast "jump" steps. Directional
// moves auto-repeat on a held stick/dpad after an initial delay; every button is edge-triggered
// (fires once per press).
import Foundation
import GameController
@MainActor
public final class GamepadMenuInput {
public enum Direction: Equatable, Sendable {
case up, down, left, right
}
private let manager: GamepadManager
private var pollTimer: Timer?
private var isActive = false
/// Seed the pressed-state trackers from the LIVE controller on the first poll after a
/// (re)start, firing nothing. Screens hand the controller off (a keyboard closes, a cover
/// dismisses) while the user is still holding the very button that triggered the handoff
/// without this, the next screen's first poll would read that held button as a fresh edge
/// and act on the same press twice (e.g. the B that closed the keyboard also backing out
/// of the screen underneath).
private var needsSnapshot = false
private var currentDirection: Direction?
private var repeatTimer: Timer?
private var wasConfirmPressed = false
private var wasSecondaryPressed = false
private var wasTertiaryPressed = false
private var wasBackPressed = false
private var wasLeftShoulderPressed = false
private var wasRightShoulderPressed = false
/// Discrete directional move already debounced (fires once on a fresh press, then repeats
/// on a hold after an initial delay, like a standard menu).
public var onMove: ((Direction) -> Void)?
/// Button A (or equivalent primary action) edge-triggered, fires once per press.
public var onConfirm: (() -> Void)?
/// Button Y (or equivalent secondary action, e.g. "open library") edge-triggered.
public var onSecondary: (() -> Void)?
/// Button X (or equivalent tertiary action, e.g. "settings" / "delete") edge-triggered.
public var onTertiary: (() -> Void)?
/// Button B (or equivalent back/dismiss) edge-triggered.
public var onBack: (() -> Void)?
/// Shoulder buttons (L1 `false` / R1 `true`) edge-triggered fast-jump steps, optional per
/// screen. Unset the shoulders do nothing.
public var onShoulder: ((Bool) -> Void)?
/// Stick magnitude below this reads as neutral (dead zone).
private let deadzone: Float = 0.5
private let initialRepeatDelay: TimeInterval = 0.38
private let repeatInterval: TimeInterval = 0.16
private let pollInterval: TimeInterval = 1.0 / 60.0
public init(manager: GamepadManager) {
self.manager = manager
}
public func start() {
guard !isActive else { return }
isActive = true
needsSnapshot = true
let timer = Timer(timeInterval: pollInterval, repeats: true) { [weak self] _ in
Task { @MainActor in self?.poll() }
}
RunLoop.main.add(timer, forMode: .common)
pollTimer = timer
}
public func stop() {
isActive = false
pollTimer?.invalidate()
pollTimer = nil
repeatTimer?.invalidate()
repeatTimer = nil
currentDirection = nil
wasConfirmPressed = false
wasSecondaryPressed = false
wasTertiaryPressed = false
wasBackPressed = false
wasLeftShoulderPressed = false
wasRightShoulderPressed = false
}
/// Reads `manager.active` fresh every tick (no persistent binding to a specific controller
/// needed) a disconnect/reconnect or a controller switch is just picked up on the next poll.
private func poll() {
guard isActive, let gamepad = manager.active?.controller.extendedGamepad else { return }
if needsSnapshot {
// Adopt whatever is held right now without firing (see `needsSnapshot`): a button
// must be RELEASED after a handoff before it can act here, and a held direction only
// keeps moving once it changes or re-engages.
needsSnapshot = false
wasConfirmPressed = gamepad.buttonA.isPressed
wasSecondaryPressed = gamepad.buttonY.isPressed
wasTertiaryPressed = gamepad.buttonX.isPressed
wasBackPressed = gamepad.buttonB.isPressed
wasLeftShoulderPressed = gamepad.leftShoulder.isPressed
wasRightShoulderPressed = gamepad.rightShoulder.isPressed
currentDirection = directionFrom(gamepad)
return
}
edge(gamepad.buttonA.isPressed, &wasConfirmPressed) { onConfirm?() }
edge(gamepad.buttonY.isPressed, &wasSecondaryPressed) { onSecondary?() }
edge(gamepad.buttonX.isPressed, &wasTertiaryPressed) { onTertiary?() }
edge(gamepad.buttonB.isPressed, &wasBackPressed) { onBack?() }
edge(gamepad.leftShoulder.isPressed, &wasLeftShoulderPressed) { onShoulder?(false) }
edge(gamepad.rightShoulder.isPressed, &wasRightShoulderPressed) { onShoulder?(true) }
updateDirection(directionFrom(gamepad))
}
/// Fire `action` on the rising edge of `pressed`, tracking the last state in `was`.
private func edge(_ pressed: Bool, _ was: inout Bool, _ action: () -> Void) {
if pressed, !was { action() }
was = pressed
}
/// The current requested direction: the left stick is the primary/natural input; the dpad is an
/// alternative. Read via discrete `.isPressed` / analog `.value` (never the dpad's combined axis
/// the first version of this class did that and it silently never registered a press on-device).
private func directionFrom(_ gamepad: GCExtendedGamepad) -> Direction? {
let stick = gamepad.leftThumbstick
let x = stick.xAxis.value
let y = stick.yAxis.value
if abs(x) > abs(y), abs(x) > deadzone {
return x > 0 ? .right : .left
} else if abs(y) > deadzone {
return y > 0 ? .up : .down
}
let dpad = gamepad.dpad
if dpad.left.isPressed { return .left }
if dpad.right.isPressed { return .right }
if dpad.up.isPressed { return .up }
if dpad.down.isPressed { return .down }
return nil
}
private func updateDirection(_ direction: Direction?) {
guard direction != currentDirection else { return }
repeatTimer?.invalidate()
repeatTimer = nil
currentDirection = direction
guard let direction else { return }
onMove?(direction)
// First repeat after a longer delay (so a quick tap doesn't double-move), then steady.
let timer = Timer(timeInterval: initialRepeatDelay, repeats: false) { [weak self] _ in
Task { @MainActor in
guard let self else { return }
self.repeatTimer?.invalidate()
let repeating = Timer(timeInterval: self.repeatInterval, repeats: true) { [weak self] _ in
Task { @MainActor in self?.onMove?(direction) }
}
RunLoop.main.add(repeating, forMode: .common)
self.repeatTimer = repeating
}
}
RunLoop.main.add(timer, forMode: .common)
repeatTimer = timer
}
}
@@ -0,0 +1,26 @@
// Whether the iOS/iPadOS/macOS UI should be in its controller-friendly mode (the console-style
// host launcher, gamepad settings, and the coverflow library browser instead of the touch/desktop
// layouts). A pure function, not a singleton: the reactivity comes from callers already observing
// `GamepadManager.shared` and the `DefaultsKey.gamepadUIEnabled` @AppStorage themselves (the same
// local-read pattern SettingsView already uses for GamepadManager), so this stays the single place
// the two combine without adding a second ObservableObject or an environment key nobody else needs.
import Foundation
public enum GamepadUIEnvironment {
/// `enabledSetting` is the user's Settings toggle (`DefaultsKey.gamepadUIEnabled`);
/// `gamepadConnected` is `GamepadManager.shared.active != nil` active only once a usable
/// controller is actually attached (a non-extended-profile device leaves `active` nil, which
/// keeps the touch UI). A `Bool` rather than the `DiscoveredController` itself: this function's
/// whole job is the AND, so there's nothing else to inspect, and it keeps the helper testable
/// without a real `GCController` (which XCTest can't construct).
public static func isActive(gamepadConnected: Bool, enabledSetting: Bool) -> Bool {
enabledSetting && (gamepadConnected || forced)
}
/// Dev-only escape hatch (like ContentView's `PUNKTFUNK_AUTOCONNECT`): pretend a controller is
/// attached so the gamepad UI can be exercised/screenshotted without physical hardware
/// essential on a headless CI Mac and for `swift run` UI work. Never set in production.
private static let forced =
ProcessInfo.processInfo.environment["PUNKTFUNK_FORCE_GAMEPAD_UI"] == "1"
}
@@ -0,0 +1,62 @@
// The gamepad wire contract shared by capture (GamepadCapture), feedback (GamepadFeedback),
// and the tests button bits, axis ids, and the touchpad/motion unit conversions.
import Foundation
/// The gamepad wire contract (mirrors `punktfunk_core::input::gamepad`).
public enum GamepadWire {
public static let dpadUp: UInt32 = 0x0001
public static let dpadDown: UInt32 = 0x0002
public static let dpadLeft: UInt32 = 0x0004
public static let dpadRight: UInt32 = 0x0008
public static let start: UInt32 = 0x0010
public static let back: UInt32 = 0x0020
public static let leftStickClick: UInt32 = 0x0040
public static let rightStickClick: UInt32 = 0x0080
public static let leftShoulder: UInt32 = 0x0100
public static let rightShoulder: UInt32 = 0x0200
public static let guide: UInt32 = 0x0400
public static let a: UInt32 = 0x1000
public static let b: UInt32 = 0x2000
public static let x: UInt32 = 0x4000
public static let y: UInt32 = 0x8000
/// DualSense touchpad click (Moonlight's extended-button bit position).
public static let touchpadClick: UInt32 = 0x10_0000
public static let allButtons: [UInt32] = [
dpadUp, dpadDown, dpadLeft, dpadRight, start, back,
leftStickClick, rightStickClick, leftShoulder, rightShoulder, guide,
a, b, x, y, touchpadClick,
]
public static let axisLSX: UInt32 = 0
public static let axisLSY: UInt32 = 1
public static let axisRSX: UInt32 = 2
public static let axisRSY: UInt32 = 3
public static let axisLT: UInt32 = 4
public static let axisRT: UInt32 = 5
/// Raw DualSense gyro units per rad/s: hid-playstation's calibration over the host's
/// fixed blob resolves to 20 LSB per deg/s.
public static let gyroLSBPerRadS: Float = 20 * 180 / .pi
/// Raw DualSense accelerometer units per g (same derivation).
public static let accelLSBPerG: Float = 10_000
/// GC touchpad coordinates (±1, +y up) wire (0...65535, origin top-left, +y down).
public static func touchpad(x: Float, y: Float) -> (x: UInt16, y: UInt16) {
let wx = ((x.clamped(to: -1...1) + 1) / 2 * 65535).rounded()
let wy = ((1 - y.clamped(to: -1...1)) / 2 * 65535).rounded()
return (UInt16(wx), UInt16(wy))
}
/// Scale + clamp one motion component into the raw signed-16 sensor domain.
public static func motionRaw(_ value: Float, scale: Float) -> Int16 {
Int16((value * scale).rounded().clamped(to: Float(Int16.min)...Float(Int16.max)))
}
}
extension Float {
fileprivate func clamped(to range: ClosedRange<Float>) -> Float {
Swift.min(Swift.max(self, range.lowerBound), range.upperBound)
}
}
@@ -0,0 +1,119 @@
// Controller-side haptic feedback for the gamepad menu UI (the host launcher + the library
// coverflow). The couch case is the whole point: the user is holding a game controller, not the
// iPhone/iPad, so a device-only `.sensoryFeedback` tick never reaches their hands this plays a
// short CoreHaptics transient on the ACTIVE controller instead, so a dpad move / launch / end-stop
// is felt on the pad. (The views pair this with `.sensoryFeedback` so a touch/handheld user still
// gets the device Taptic tick; the two are independent channels, and both firing is intended.)
//
// This is menu-only it never runs during a stream (the session's own GamepadFeedback owns the
// controller then), so there's no contention over the pad's haptic engine. Like GamepadMenuInput,
// it reads `GamepadManager.shared.active` fresh and rebuilds its engine when the controller
// changes, so a hot-swapped pad just starts buzzing on the next tick. Everything is best-effort:
// a pad with no haptics (many Xbox pads on iOS, a Siri Remote) silently no-ops.
import CoreHaptics
import Foundation
import GameController
@MainActor
public final class MenuHaptics {
private let manager: GamepadManager
/// The engine for the controller it was built against dropped and rebuilt when `active`
/// changes (identity compare) or after a stop/reset handler fires.
private var engine: CHHapticEngine?
private weak var boundController: GCController?
public init(manager: GamepadManager) {
self.manager = manager
}
/// A light, crisp detent one per menu step. Deliberately tiny so a held direction repeating
/// at ~5 Hz reads as a smooth ratchet rather than a jackhammer.
public func move() {
play(intensity: 0.45, sharpness: 0.75, duration: 0.02)
}
/// A fuller, rounder pulse on confirm/launch the "you did the thing" thunk.
public func confirm() {
play(intensity: 1.0, sharpness: 0.55, duration: 0.055)
}
/// A soft, dull bump when a move is refused at the end of a non-wrapping list low sharpness so
/// it feels like hitting a wall, distinct from the crisp `move()` detent.
public func boundary() {
play(intensity: 0.7, sharpness: 0.18, duration: 0.06)
}
/// Release the engine and forget the controller call on the menu screen's disappear so the
/// pad's haptic engine isn't held open while streaming or on the touch UI.
public func stop() {
engine?.stop(completionHandler: nil)
engine = nil
boundController = nil
}
/// Fire a single transient. Rebuilds the engine against the current active controller if it
/// changed; swallows every failure (a pad without a haptics engine, a transient XPC hiccup)
/// menu haptics are a nicety, never a correctness path.
private func play(intensity: Float, sharpness: Float, duration: TimeInterval) {
guard let controller = manager.active?.controller else {
// No pad (or a non-forwardable one): nothing to buzz. Drop any stale engine.
if boundController != nil { stop() }
return
}
guard let engine = engine(for: controller) else { return }
let event = CHHapticEvent(
eventType: .hapticTransient,
parameters: [
CHHapticEventParameter(parameterID: .hapticIntensity, value: intensity),
CHHapticEventParameter(parameterID: .hapticSharpness, value: sharpness),
],
relativeTime: 0,
duration: duration)
do {
let player = try engine.makePlayer(with: CHHapticPattern(events: [event], parameters: []))
try player.start(atTime: CHHapticTimeImmediate)
} catch {
// The engine went stale between builds (stopped/reset). Drop it; the next tick rebuilds.
self.engine = nil
boundController = nil
}
}
/// The started engine for `controller`, (re)built on first use or after a controller swap.
private func engine(for controller: GCController) -> CHHapticEngine? {
if let engine, boundController === controller { return engine }
engine?.stop(completionHandler: nil)
engine = nil
boundController = nil
guard let built = controller.haptics?.createEngine(withLocality: .default) else { return nil }
// Menu ticks carry no audio keep the engine out of the app's audio session (the same
// discipline the session RumbleRenderer uses).
built.playsHapticsOnly = true
// The haptic server can pull the engine out from under us (backgrounding, an audio
// interruption, a controller drop); drop our reference so the next tick lazily rebuilds
// rather than throwing forever.
built.stoppedHandler = { [weak self] _ in
Task { @MainActor in self?.dropEngine(if: controller) }
}
built.resetHandler = { [weak self] in
Task { @MainActor in self?.dropEngine(if: controller) }
}
do {
try built.start()
} catch {
return nil
}
engine = built
boundController = controller
return built
}
/// Drop the cached engine only if it's still the one for `controller` a handler firing after a
/// swap must not clobber the freshly built engine for the new pad.
private func dropEngine(if controller: GCController) {
guard boundController === controller else { return }
engine = nil
boundController = nil
}
}
@@ -0,0 +1,284 @@
import CoreHaptics
import Foundation
import GameController
import os
private let log = Logger(subsystem: "io.unom.punktfunk", category: "gamepad")
/// Rumble CoreHaptics, isolated on one serial queue (CHHapticEngine is not main-bound,
/// but it isn't a free-for-all either). Engines are created lazily on the first nonzero
/// amplitude and torn down on retarget; players run only while their motor is on, so an
/// idle controller costs no radio traffic. Failures (pads without haptics, engine resets)
/// downgrade to silence rumble is best-effort by design.
///
/// `@unchecked Sendable` is sound because every property (`controller`/`low`/`high`/`broken`) is
/// read and written only inside `queue` closures the serial queue is the synchronization.
final class RumbleRenderer: @unchecked Sendable {
private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive)
/// One actuator's started engine plus the player currently driving it (nil = idle). The
/// player is rebuilt per level change `drive` bakes the target intensity into a fresh
/// continuous event rather than scaling a long-lived one with a dynamic parameter.
private struct Motor {
let engine: CHHapticEngine
var player: CHHapticAdvancedPatternPlayer?
}
private var controller: GCController?
private var low: Motor?
private var high: Motor?
// `broken` latches OFF only for a controller that genuinely has no haptics engine (an Xbox pad
// on an OS that doesn't expose rumble through GameController, a Siri Remote) nothing to retry
// until the controller changes. A transient engine failure does NOT latch it; it tears down for
// a lazy rebuild instead, so a single hiccup can't kill rumble for the whole session.
private var broken = false
/// Last logged active/silent state for a one-line transition log, not per-event spam.
private var wasActive = false
// Backoff after an engine failure. A broken `gamecontrollerd.haptics` XPC connection (CoreHaptics
// -4811 "server connection broke") fails EVERY rebuild until the service relaunches and that
// break fires neither stoppedHandler nor resetHandler, so without a cooldown the next rumble
// update immediately rebuilds into the same dead connection, flooding the log and never
// recovering. Delay the next setup() growing 0.5124 s on repeated failure and clear it
// the moment a player runs cleanly (or the controller changes).
private var retryAfter = Date.distantPast
private var consecutiveFailures = 0
/// CHHapticEvent sharpness = actuator frequency. A DualSense's voice-coil motors need a
/// defined frequency to move at all an intensity-only event (no sharpness) left them
/// silent, while a classic Xbox rotor (which ignores sharpness) rumbled fine. 0.5 is the mid
/// value the known-working macOS DualSense rumble implementations use. (Used only on the
/// CoreHaptics path a DualSense on macOS is driven over raw HID instead, see below.)
private static let sharpness: Float = 0.5
#if os(macOS)
/// Set when the active pad is a DualSense: its motors are driven over raw HID (CoreHaptics
/// does not reach them on macOS adaptive triggers/lightbar work, rumble is silent). nil for
/// every other controller, which keeps the CoreHaptics path.
private var dualSenseHID: DualSenseHID?
#endif
/// `onBackend`, if given, is invoked (on the internal queue) with a human-readable name of the
/// rumble backend now in use for the debug controller-test panel.
func retarget(_ c: GCController?, onBackend: ((String) -> Void)? = nil) {
queue.async {
self.teardown()
self.closeHID()
self.controller = c
self.broken = false
self.consecutiveFailures = 0
self.retryAfter = .distantPast
_ = self.openHIDIfDualSense(c)
onBackend?(self.backendNote(for: c))
}
}
func apply(low lowAmp: UInt16, high highAmp: UInt16) {
queue.async {
let active = lowAmp != 0 || highAmp != 0
if active != self.wasActive {
self.wasActive = active
log.debug(
"rumble: \(active ? "active" : "stop", privacy: .public) low=\(lowAmp, privacy: .public) high=\(highAmp, privacy: .public)")
}
// A DualSense on macOS is driven over raw HID; CoreHaptics is the path for every
// other pad (and for a DualSense whose HID device could not be opened).
if self.hidRumble(low: lowAmp, high: highAmp) { return }
guard !self.broken else { return }
if active, self.low == nil, self.high == nil, Date() >= self.retryAfter {
self.setup()
}
let ok: Bool
if self.high != nil {
// Per-handle: low = left/heavy motor, high = right/light the XInput convention
// the wire carries.
let okLow = self.drive(&self.low, Float(lowAmp) / 65535)
let okHigh = self.drive(&self.high, Float(highAmp) / 65535)
ok = okLow && okHigh
} else {
// Combined engine: whichever motor is stronger wins.
ok = self.drive(&self.low, Float(max(lowAmp, highAmp)) / 65535)
}
// Rebuild on the next nonzero amplitude if an engine errored and tear down OUTSIDE
// the `inout` accesses above, so teardown() never mutates a motor that a `drive` call
// still holds an exclusive reference to. Back off so a broken XPC isn't re-hit every
// update; once a player is actually running the path has recovered, so clear the backoff.
if !ok {
self.teardown()
self.scheduleRetryBackoff()
} else if self.low?.player != nil || self.high?.player != nil {
self.consecutiveFailures = 0
self.retryAfter = .distantPast
}
}
}
func stop() {
queue.sync {
self.teardown()
self.closeHID()
}
}
/// Engines per handle when the pad distinguishes them (low = left/heavy motor,
/// high = right/light the Xbox/XInput convention the wire carries); one combined
/// engine otherwise, driven by whichever amplitude is stronger.
private func setup() {
guard let haptics = controller?.haptics else {
// No haptics engine at all an Xbox controller on an OS/firmware that doesn't expose
// rumble through GameController (works on Android via the standard Vibrator path, but
// Apple's support is controller/OS-dependent), or a Siri Remote. Nothing to retry until
// the controller changes; latch off (retarget clears it) and say so once.
log.info("rumble: active controller exposes no haptics engine — rumble unavailable")
broken = true
return
}
let localities = haptics.supportedLocalities
if localities.contains(.leftHandle), localities.contains(.rightHandle) {
low = makeMotor(haptics, .leftHandle)
high = makeMotor(haptics, .rightHandle)
} else {
low = makeMotor(haptics, .default)
}
if low == nil, high == nil {
// Haptics present but no engine could be built right now (server busy / XPC broken). Do
// NOT latch broken back off and the next nonzero amplitude past the cooldown retries.
log.warning("rumble: haptics present but engine setup failed — backing off, will retry")
scheduleRetryBackoff()
}
}
/// Push the next engine-build attempt out after a failure (capped exponential backoff), so a
/// broken `gamecontrollerd.haptics` connection gets time to relaunch instead of being re-hit on
/// every rumble update.
private func scheduleRetryBackoff() {
consecutiveFailures += 1
let shift = min(consecutiveFailures - 1, 4)
retryAfter = Date().addingTimeInterval(min(0.5 * Double(1 << shift), 4))
}
private func makeMotor(_ haptics: GCDeviceHaptics, _ locality: GCHapticsLocality) -> Motor? {
guard let engine = haptics.createEngine(withLocality: locality) else { return nil }
// A controller's motors carry no audio, so keep this engine OUT of the app's audio session
// (the default is to join it). Streaming keeps an AVAudioSession active the whole time;
// letting a haptics-only engine join it is a needless coupling that can get its
// gamecontrollerd XPC connection interrupted (the repeated -4811 server-connection breaks).
engine.playsHapticsOnly = true
// The haptic server can stop or reset the engine out from under us app backgrounding, an
// audio-session interruption (a call, Siri, another audio app), or a server crash. Left
// unhandled the players go dead and every later rumble throws, latching rumble off for the
// rest of the session (the "rumble worked, then went spotty" failure). Tear down on the
// serial queue so the next nonzero amplitude lazily rebuilds the engine, instead.
engine.stoppedHandler = { [weak self] reason in
log.info("rumble: haptic engine stopped (reason \(reason.rawValue, privacy: .public)) — will rebuild")
self?.queue.async { self?.teardown() }
}
engine.resetHandler = { [weak self] in
log.info("rumble: haptic engine reset — will rebuild")
self?.queue.async { self?.teardown() }
}
do {
// Start the engine now; the player that actually moves the motor is built per level
// change in `drive` (a fresh event baked at the target intensity).
try engine.start()
return Motor(engine: engine, player: nil)
} catch {
log.warning("haptic engine setup failed (\(locality.rawValue, privacy: .public)): \(error, privacy: .public)")
return nil
}
}
/// Drive one motor at `amplitude` (0...1) by (re)building a continuous player whose intensity
/// is BAKED into the event. On a DualSense this is what actually moves the actuators: a
/// fixed-intensity event scaled by a dynamic `.hapticIntensityControl` parameter (the old
/// path) drives the iPhone Taptic Engine but is silent on a controller's haptic engine. The
/// event carries an explicit sharpness (frequency) so the voice coils respond, and an infinite
/// duration so a single host update the host sends rumble only when the level changes
/// sustains until the next one. Returns false if the engine errored; the caller tears down for
/// a rebuild (done outside this `inout` access to avoid an exclusivity violation).
private func drive(_ motor: inout Motor?, _ amplitude: Float) -> Bool {
guard var m = motor else { return true }
// Replace any running player: stop the old, and for a zero level leave the motor idle.
try? m.player?.stop(atTime: CHHapticTimeImmediate)
m.player = nil
guard amplitude > 0 else { motor = m; return true }
do {
let event = CHHapticEvent(
eventType: .hapticContinuous,
parameters: [
CHHapticEventParameter(parameterID: .hapticIntensity, value: amplitude),
CHHapticEventParameter(parameterID: .hapticSharpness, value: Self.sharpness),
],
relativeTime: 0,
duration: TimeInterval(GCHapticDurationInfinite))
let player = try m.engine.makeAdvancedPlayer(
with: CHHapticPattern(events: [event], parameters: []))
try player.start(atTime: CHHapticTimeImmediate)
m.player = player
motor = m
return true
} catch {
// A transient failure (the engine stopped/reset between its handler firing and now).
// Signal a rebuild do NOT latch rumble off for the session (the old "spotty" bug).
log.warning("rumble: haptic update failed — rebuilding: \(error, privacy: .public)")
motor = m
return false
}
}
private func teardown() {
for m in [low, high].compactMap({ $0 }) {
// Disarm the handlers before stopping so stop() can't re-enter teardown via them.
// (Both properties are non-optional closures on this SDK, so assign no-ops, not nil.)
m.engine.stoppedHandler = { _ in }
m.engine.resetHandler = {}
try? m.player?.stop(atTime: CHHapticTimeImmediate)
m.engine.stop()
}
low = nil
high = nil
}
// MARK: - DualSense raw-HID rumble (macOS)
//
// On macOS the DualSense's motors aren't reachable through CHHapticEngine, so for a DualSense
// we drive them over raw HID (see `DualSenseHID`); every other pad keeps the CoreHaptics path.
// All three run on the serial `queue`, like the rest of the renderer state.
private func openHIDIfDualSense(_ c: GCController?) -> Bool {
#if os(macOS)
guard let c, c.extendedGamepad is GCDualSenseGamepad else { return false }
let hid = DualSenseHID()
guard hid.open() else { return false }
dualSenseHID = hid
return true
#else
return false
#endif
}
/// Drive the DualSense's motors over HID if that's the active backend; false not a HID pad,
/// so the caller uses CoreHaptics. The wire's 0...0xFFFF amplitudes scale to the pad's 0...255.
private func hidRumble(low: UInt16, high: UInt16) -> Bool {
#if os(macOS)
guard let hid = dualSenseHID else { return false }
hid.rumble(low: UInt8(low >> 8), high: UInt8(high >> 8))
return true
#else
return false
#endif
}
private func closeHID() {
#if os(macOS)
dualSenseHID?.close()
dualSenseHID = nil
#endif
}
private func backendNote(for c: GCController?) -> String {
#if os(macOS)
if let hid = dualSenseHID { return "DualSense HID · \(hid.transport)" }
#endif
return c == nil ? "" : "CoreHaptics"
}
}