feat(apple): gamepad ui
apple / swift (push) Successful in 1m5s
audit / cargo-audit (push) Successful in 1m19s
android / android (push) Successful in 4m21s
ci / web (push) Successful in 58s
ci / docs-site (push) Successful in 58s
ci / rust (push) Successful in 8m40s
release / apple (push) Successful in 9m10s
ci / bench (push) Successful in 4m39s
decky / build-publish (push) Successful in 14s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 29s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
deb / build-publish (push) Successful in 3m50s
apple / screenshots (push) Successful in 5m38s
flatpak / build-publish (push) Successful in 4m12s
windows-host / package (push) Successful in 19m17s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m15s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m5s
docker / deploy-docs (push) Successful in 18s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m1s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m53s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 3m24s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 3m30s

This commit is contained in:
2026-07-01 15:14:19 +02:00
parent c8be614d9a
commit ecbbff5544
22 changed files with 1782 additions and 74 deletions
@@ -4,10 +4,16 @@
//
// To present that identity, URLSession needs a SecIdentity (cert + private key pair). The client
// stores its identity as PEM (rcgen ECDSA P-256, PKCS#8 key). We rebuild a SecIdentity natively:
// CryptoKit parses the key its X9.63 form a SecKey, the cert PEM a SecCertificate, and
// SecIdentityCreateWithCertificate pairs them via the Keychain. This is macOS-only
// (SecIdentityCreateWithCertificate is unavailable on iOS that path will need a PKCS#12); the
// client library is macOS-first today.
// CryptoKit parses the key its X9.63 form a SecKey, the cert PEM a SecCertificate. From
// there the two platform families diverge because `SecIdentityCreateWithCertificate` the
// straight-line "pair these two" API is macOS-only:
// - macOS: SecIdentityCreateWithCertificate does the pairing directly once the key is in the
// Keychain (a plain `SecItemAdd`).
// - iOS/tvOS: that API is unavailable. Instead, add BOTH the key and the certificate to the
// Keychain (under the same application tag) and query `kSecClassIdentity` the system
// correlates a stored cert against a stored key with a matching public key and vends the pair
// as one `SecIdentity`, no PKCS#12 needed. This is the standard non-macOS technique for
// "I already have a raw cert + key, not a .p12".
import CryptoKit
import Foundation
@@ -18,15 +24,12 @@ private let tlsLog = Logger(subsystem: "io.unom.punktfunk", category: "library-t
enum ClientTLS {
enum TLSError: LocalizedError {
case unsupportedPlatform
case badKey(String)
case badCert
case identity(String)
var errorDescription: String? {
switch self {
case .unsupportedPlatform:
return "Library mTLS is supported on macOS only right now."
case .badKey(let why): return "Couldn't load the client key: \(why)"
case .badCert: return "Couldn't load the client certificate."
case .identity(let why): return "Couldn't build the client identity: \(why)"
@@ -45,9 +48,8 @@ enum ClientTLS {
}
/// Build a `SecIdentity` from the client's PEM cert + PKCS#8 P-256 key. Pairs them via the
/// Keychain (the key is stored once under a stable tag, so repeat calls reuse it).
/// Keychain (stored once under a stable tag, so repeat calls reuse it).
static func makeIdentity(certPEM: String, keyPEM: String) throws -> SecIdentity {
#if os(macOS)
// Key: CryptoKit accepts the SEC1 or PKCS#8 PEM; its x963 form is what SecKey wants.
let priv: P256.Signing.PrivateKey
do {
@@ -71,9 +73,11 @@ enum ClientTLS {
let cert = SecCertificateCreateWithData(nil, certDER as CFData)
else { throw TLSError.badCert }
let tag = Data("io.unom.punktfunk.library-client-key".utf8)
#if os(macOS)
// The key must live in a Keychain for SecIdentityCreateWithCertificate to pair it with the
// cert. Add it under a stable tag; a duplicate just means a previous fetch already did.
let tag = Data("io.unom.punktfunk.library-client-key".utf8)
let add: [CFString: Any] = [
kSecClass: kSecClassKey,
kSecAttrApplicationTag: tag,
@@ -81,7 +85,7 @@ enum ClientTLS {
]
let status = SecItemAdd(add as CFDictionary, nil)
guard status == errSecSuccess || status == errSecDuplicateItem else {
throw TLSError.identity("keychain add failed (OSStatus \(status))")
throw TLSError.identity("keychain add key failed (OSStatus \(status))")
}
var identity: SecIdentity?
@@ -91,20 +95,64 @@ enum ClientTLS {
}
return identity
#else
throw TLSError.unsupportedPlatform
// Add the key (tagged) and the certificate (matched to it by public key) separately
// a duplicate of either just means a previous fetch already added it.
let addKey: [CFString: Any] = [
kSecClass: kSecClassKey,
kSecAttrApplicationTag: tag,
kSecValueRef: secKey,
]
let keyStatus = SecItemAdd(addKey as CFDictionary, nil)
guard keyStatus == errSecSuccess || keyStatus == errSecDuplicateItem else {
throw TLSError.identity("keychain add key failed (OSStatus \(keyStatus))")
}
let addCert: [CFString: Any] = [
kSecClass: kSecClassCertificate,
kSecValueRef: cert,
]
let certStatus = SecItemAdd(addCert as CFDictionary, nil)
guard certStatus == errSecSuccess || certStatus == errSecDuplicateItem else {
throw TLSError.identity("keychain add certificate failed (OSStatus \(certStatus))")
}
// The system correlates the just-added cert against the tagged key (matching public key)
// and vends the pair as a kSecClassIdentity the tag filter here matches the KEY half.
var identityRef: CFTypeRef?
let query: [CFString: Any] = [
kSecClass: kSecClassIdentity,
kSecAttrApplicationTag: tag,
kSecReturnRef: true,
]
let idStatus = SecItemCopyMatching(query as CFDictionary, &identityRef)
guard idStatus == errSecSuccess, let identityRef else {
throw TLSError.identity("SecItemCopyMatching(kSecClassIdentity) (OSStatus \(idStatus))")
}
// Safe: a kSecClassIdentity query with kSecReturnRef always vends a SecIdentity.
return (identityRef as! SecIdentity) // swiftlint:disable:this force_cast
#endif
}
}
/// URLSession delegate that pins the host's self-signed cert (by the fingerprint the client
/// already trusts) and presents the client identity for the mTLS client-cert challenge.
/// already trusts) and presents the client identity for the mTLS client-cert challenge but ONLY
/// for challenges from `host`:`port` (the punktfunk host itself). A session built with this
/// delegate is safe to reuse for OTHER origins too (e.g. a GOG/Heroic/Xbox cover-art CDN): a
/// non-matching origin falls through to `.performDefaultHandling`, i.e. normal system trust
/// evaluation and no client cert exactly what `URLSession.shared` would have done. Without the
/// host scoping, pinning would reject every external origin's cert (its fingerprint never matches
/// the host's) and the client identity would leak to servers that didn't ask for it.
final class LibraryTLSDelegate: NSObject, URLSessionDelegate {
private let identity: SecIdentity
private let pinnedHostFingerprint: Data? // SHA-256 of the host cert DER; nil = accept any (TOFU)
private let host: String
private let port: Int
init(identity: SecIdentity, pinnedHostFingerprint: Data?) {
init(identity: SecIdentity, pinnedHostFingerprint: Data?, host: String, port: UInt16) {
self.identity = identity
self.pinnedHostFingerprint = pinnedHostFingerprint
self.host = host
self.port = Int(port)
}
func urlSession(
@@ -112,11 +160,16 @@ final class LibraryTLSDelegate: NSObject, URLSessionDelegate {
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
switch challenge.protectionSpace.authenticationMethod {
let space = challenge.protectionSpace
guard space.host == host, space.port == port else {
completionHandler(.performDefaultHandling, nil)
return
}
switch space.authenticationMethod {
case NSURLAuthenticationMethodServerTrust:
// Pin the host cert by fingerprint the host is self-signed (the client trusts it the
// same way the QUIC session does). No pin yet (TOFU) accept the presented leaf.
guard let trust = challenge.protectionSpace.serverTrust,
guard let trust = space.serverTrust,
let leaf = (SecTrustCopyCertificateChain(trust) as? [SecCertificate])?.first
else {
completionHandler(.cancelAuthenticationChallenge, nil)
@@ -48,4 +48,8 @@ public enum DefaultsKey {
/// Which corner the statistics overlay sits in a `HUDPlacement` raw value
/// ("topLeading"/"topTrailing"/"bottomLeading"/"bottomTrailing"). Default top-trailing.
public static let hudPlacement = "punktfunk.hudPlacement"
/// iOS/iPadOS: switch the host list and game library to a controller-friendly layout
/// (larger focus targets, a coverflow-style library) whenever a gamepad is connected. On by
/// default; see `GamepadUIEnvironment.isActive`.
public static let gamepadUIEnabled = "punktfunk.gamepadUIEnabled"
}
@@ -0,0 +1,149 @@
// 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, 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
private var currentDirection: Direction?
private var repeatTimer: Timer?
private var wasConfirmPressed = false
private var wasSecondaryPressed = 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 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
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
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 }
edge(gamepad.buttonA.isPressed, &wasConfirmPressed) { onConfirm?() }
edge(gamepad.buttonY.isPressed, &wasSecondaryPressed) { onSecondary?() }
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,20 @@
// Whether the iOS/iPadOS UI should be in its controller-friendly mode (larger focus targets on
// the host grid, the coverflow library browser instead of the plain grid). 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
}
}
@@ -92,7 +92,8 @@ public enum LibraryClient {
throw LibraryError.unreachable(
(error as? LocalizedError)?.errorDescription ?? error.localizedDescription)
}
let delegate = LibraryTLSDelegate(identity: identity, pinnedHostFingerprint: hostFingerprint)
let delegate = LibraryTLSDelegate(
identity: identity, pinnedHostFingerprint: hostFingerprint, host: address, port: port)
let session = URLSession(configuration: .ephemeral, delegate: delegate, delegateQueue: nil)
defer { session.finishTasksAndInvalidate() }
@@ -108,7 +109,16 @@ public enum LibraryClient {
}
switch http.statusCode {
case 200:
return try JSONDecoder().decode([GameEntry].self, from: data)
var games = try JSONDecoder().decode([GameEntry].self, from: data)
// Steam art now comes back as host-relative proxy paths (`/api/v1/library/art/...`,
// see the host's `library::steam_art`) so they work the same regardless of which
// interface/port the client reached the host on. Resolve them against THIS host now,
// so every other consumer just sees ordinary absolute URLs.
let base = url
for i in games.indices {
games[i].art = games[i].art.resolved(against: base)
}
return games
case 401:
throw LibraryError.unauthorized
default:
@@ -116,3 +126,43 @@ public enum LibraryClient {
}
}
}
extension Artwork {
/// Rewrite any host-relative field (one starting with `/`) into an absolute URL against `base`.
/// External CDN URLs (GOG/Heroic/Xbox) and `data:` URLs (Lutris) already don't start with `/`,
/// so they pass through unchanged. `internal` (not `fileprivate`) so `LibraryClientTests` can
/// exercise it directly without a live host.
func resolved(against base: URL) -> Artwork {
func abs(_ s: String?) -> String? {
guard let s, s.hasPrefix("/") else { return s }
return URL(string: s, relativeTo: base)?.absoluteString ?? s
}
var a = self
a.portrait = abs(a.portrait)
a.hero = abs(a.hero)
a.logo = abs(a.logo)
a.header = abs(a.header)
return a
}
}
/// Builds the authenticated `URLSession` the library UI uses to fetch cover-art images the same
/// paired identity + host pinning as [`LibraryClient.fetch`], reused across a whole grid's worth of
/// poster loads (this session is NOT one-shot: callers own its lifetime and should invalidate it
/// when the view goes away). Safe to use for every candidate URL a `GameEntry`'s `Artwork` carries:
/// `LibraryTLSDelegate` only pins/presents-cert for the host itself, deferring to normal system
/// trust + no client cert for any other origin (an external CDN URL).
public enum LibraryImageLoader {
public static func session(
address: String,
port: UInt16 = punktfunkDefaultMgmtPort,
certPEM: String,
keyPEM: String,
hostFingerprint: Data?
) throws -> URLSession {
let identity = try ClientTLS.makeIdentity(certPEM: certPEM, keyPEM: keyPEM)
let delegate = LibraryTLSDelegate(
identity: identity, pinnedHostFingerprint: hostFingerprint, host: address, port: port)
return URLSession(configuration: .default, delegate: delegate, delegateQueue: nil)
}
}
@@ -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
}
}
@@ -154,12 +154,17 @@ private func wireChannelLayout(channels: Int) -> AVAudioChannelLayout? {
layout.pointee.mChannelLayoutTag = kAudioChannelLayoutTag_UseChannelDescriptions
layout.pointee.mChannelBitmap = AudioChannelBitmap(rawValue: 0)
layout.pointee.mNumberChannelDescriptions = UInt32(labels.count)
let descs = UnsafeMutableBufferPointer(
start: &layout.pointee.mChannelDescriptions, count: labels.count)
for (i, lbl) in labels.enumerated() {
descs[i] = AudioChannelDescription(
mChannelLabel: lbl, mChannelFlags: AudioChannelFlags(rawValue: 0),
mCoordinates: (0, 0, 0))
// `mChannelDescriptions` is the C variable-length tail array (declared `[1]`, over-allocated
// above). Scope the pointer with `withUnsafeMutablePointer` taking `&mChannelDescriptions`
// inline yields a pointer valid only for that expression, so building a buffer from it that
// outlives the call is a dangling-pointer bug. Inside the closure it stays valid while we fill it.
withUnsafeMutablePointer(to: &layout.pointee.mChannelDescriptions) { tail in
let descs = UnsafeMutableBufferPointer(start: tail, count: labels.count)
for (i, lbl) in labels.enumerated() {
descs[i] = AudioChannelDescription(
mChannelLabel: lbl, mChannelFlags: AudioChannelFlags(rawValue: 0),
mCoordinates: (0, 0, 0))
}
}
return AVAudioChannelLayout(layout: layout)
}