Files
punktfunk/clients/apple/Sources/PunktfunkKit/Gamepad/DualSenseHID.swift
T
enricobuehler 133e25849d 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>
2026-07-02 11:24:44 +02:00

154 lines
6.9 KiB
Swift

// 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