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:
@@ -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
|
||||
/// host→client 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 11–21
|
||||
// for L2 / 22–32 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 @@
|
||||
// Host→client 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 1–4),
|
||||
// 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.5→1→2→4 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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user