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
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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user