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,59 @@
|
||||
// The client's persistent identity + the SPAKE2 PIN pairing ceremony — the trust
|
||||
// bootstrap that precedes any pinned PunktfunkConnection.
|
||||
|
||||
import Foundation
|
||||
import PunktfunkCore
|
||||
|
||||
/// This client's persistent self-signed identity. Generate ONCE with `generateIdentity()`,
|
||||
/// store both PEMs (Keychain), present on every connect — the certificate's fingerprint is
|
||||
/// how hosts recognize this client after pairing.
|
||||
public struct ClientIdentity: Sendable {
|
||||
public let certPEM: String
|
||||
public let keyPEM: String
|
||||
public init(certPEM: String, keyPEM: String) {
|
||||
self.certPEM = certPEM
|
||||
self.keyPEM = keyPEM
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a fresh client identity (self-signed cert + key, PEM).
|
||||
public func generateIdentity() throws -> ClientIdentity {
|
||||
var cert = [CChar](repeating: 0, count: 4096)
|
||||
var key = [CChar](repeating: 0, count: 4096)
|
||||
let rc = punktfunk_generate_identity(&cert, UInt(cert.count), &key, UInt(key.count))
|
||||
guard rc == PUNKTFUNK_STATUS_OK.rawValue else {
|
||||
throw PunktfunkClientError.status(rc)
|
||||
}
|
||||
return ClientIdentity(certPEM: String(cString: cert), keyPEM: String(cString: key))
|
||||
}
|
||||
|
||||
/// Run the PIN pairing ceremony: the host displays a 4-digit PIN (its log/UI), the user
|
||||
/// types it here. On success the host stores this client's identity and the returned
|
||||
/// fingerprint is the host's now-VERIFIED identity — persist it and pass it as `pinSHA256`
|
||||
/// to every later connect. Throws `.wrongPIN` when the proof is rejected.
|
||||
public func pair(
|
||||
host: String, port: UInt16 = 9777,
|
||||
identity: ClientIdentity, pin: String, name: String,
|
||||
timeoutMs: UInt32 = 90_000
|
||||
) throws -> Data {
|
||||
var observed = [UInt8](repeating: 0, count: 32)
|
||||
// The C header types PunktfunkStatus as a bare int32 (C17, no enum import), so the ABI
|
||||
// functions return Int32 directly — compare against the enum constants' rawValue, the
|
||||
// same bridging the connection methods use (statusOK etc.).
|
||||
let rc = host.withCString { cs in
|
||||
identity.certPEM.withCString { cert in
|
||||
identity.keyPEM.withCString { key in
|
||||
pin.withCString { p in
|
||||
name.withCString { n in
|
||||
punktfunk_pair(cs, port, cert, key, p, n, &observed, timeoutMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
switch rc {
|
||||
case PUNKTFUNK_STATUS_OK.rawValue: return Data(observed)
|
||||
case PUNKTFUNK_STATUS_CRYPTO.rawValue: throw PunktfunkClientError.wrongPIN
|
||||
default: throw PunktfunkClientError.status(rc)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// mTLS for the management REST API. The host now serves the API over HTTPS and authorizes a
|
||||
// request whose client certificate is in its paired store (host commit b4a85a8) — the SAME
|
||||
// identity + trust the QUIC data plane uses — so a paired client needs no bearer token.
|
||||
//
|
||||
// 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. 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
|
||||
import Security
|
||||
import os
|
||||
|
||||
private let tlsLog = Logger(subsystem: "io.unom.punktfunk", category: "library-tls")
|
||||
|
||||
enum ClientTLS {
|
||||
enum TLSError: LocalizedError {
|
||||
case badKey(String)
|
||||
case badCert
|
||||
case identity(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// First PEM block of `type` ("CERTIFICATE" / "PRIVATE KEY") → its DER bytes.
|
||||
private static func derFromPEM(_ pem: String, type: String) -> Data? {
|
||||
guard let start = pem.range(of: "-----BEGIN \(type)-----"),
|
||||
let end = pem.range(of: "-----END \(type)-----", range: start.upperBound..<pem.endIndex)
|
||||
else { return nil }
|
||||
let b64 = pem[start.upperBound..<end.lowerBound]
|
||||
.components(separatedBy: .whitespacesAndNewlines).joined()
|
||||
return Data(base64Encoded: b64)
|
||||
}
|
||||
|
||||
/// Build a `SecIdentity` from the client's PEM cert + PKCS#8 P-256 key. Pairs them via the
|
||||
/// Keychain (stored once under a stable tag, so repeat calls reuse it).
|
||||
static func makeIdentity(certPEM: String, keyPEM: String) throws -> SecIdentity {
|
||||
// Key: CryptoKit accepts the SEC1 or PKCS#8 PEM; its x963 form is what SecKey wants.
|
||||
let priv: P256.Signing.PrivateKey
|
||||
do {
|
||||
priv = try P256.Signing.PrivateKey(pemRepresentation: keyPEM)
|
||||
} catch {
|
||||
throw TLSError.badKey(error.localizedDescription)
|
||||
}
|
||||
var keyError: Unmanaged<CFError>?
|
||||
let attrs: [CFString: Any] = [
|
||||
kSecAttrKeyType: kSecAttrKeyTypeECSECPrimeRandom,
|
||||
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
|
||||
kSecAttrKeySizeInBits: 256,
|
||||
]
|
||||
guard let secKey = SecKeyCreateWithData(
|
||||
priv.x963Representation as CFData, attrs as CFDictionary, &keyError)
|
||||
else {
|
||||
throw TLSError.badKey((keyError?.takeRetainedValue()).map { "\($0)" } ?? "SecKeyCreateWithData")
|
||||
}
|
||||
|
||||
guard let certDER = derFromPEM(certPEM, type: "CERTIFICATE"),
|
||||
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 add: [CFString: Any] = [
|
||||
kSecClass: kSecClassKey,
|
||||
kSecAttrApplicationTag: tag,
|
||||
kSecValueRef: secKey,
|
||||
]
|
||||
let status = SecItemAdd(add as CFDictionary, nil)
|
||||
guard status == errSecSuccess || status == errSecDuplicateItem else {
|
||||
throw TLSError.identity("keychain add key failed (OSStatus \(status))")
|
||||
}
|
||||
|
||||
var identity: SecIdentity?
|
||||
let idStatus = SecIdentityCreateWithCertificate(nil, cert, &identity)
|
||||
guard idStatus == errSecSuccess, let identity else {
|
||||
throw TLSError.identity("SecIdentityCreateWithCertificate (OSStatus \(idStatus))")
|
||||
}
|
||||
return identity
|
||||
#else
|
||||
// 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 — 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?, host: String, port: UInt16) {
|
||||
self.identity = identity
|
||||
self.pinnedHostFingerprint = pinnedHostFingerprint
|
||||
self.host = host
|
||||
self.port = Int(port)
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
didReceive challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||
) {
|
||||
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 = space.serverTrust,
|
||||
let leaf = (SecTrustCopyCertificateChain(trust) as? [SecCertificate])?.first
|
||||
else {
|
||||
completionHandler(.cancelAuthenticationChallenge, nil)
|
||||
return
|
||||
}
|
||||
let der = SecCertificateCopyData(leaf) as Data
|
||||
let fp = Data(SHA256.hash(data: der))
|
||||
if let pinned = pinnedHostFingerprint, pinned != fp {
|
||||
tlsLog.warning("library: host cert fingerprint mismatch — refusing")
|
||||
completionHandler(.cancelAuthenticationChallenge, nil)
|
||||
return
|
||||
}
|
||||
completionHandler(.useCredential, URLCredential(trust: trust))
|
||||
|
||||
case NSURLAuthenticationMethodClientCertificate:
|
||||
completionHandler(.useCredential,
|
||||
URLCredential(identity: identity, certificates: nil, persistence: .forSession))
|
||||
|
||||
default:
|
||||
completionHandler(.performDefaultHandling, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
// LAN auto-discovery of punktfunk/1 hosts over mDNS — the client side of the host's
|
||||
// `crate::discovery` advert (`_punktfunk._udp`). Browses with NWBrowser (TXT rides in the
|
||||
// result metadata), resolves each service to a connectable IP:port with a throwaway
|
||||
// NWConnection, and publishes the live set.
|
||||
//
|
||||
// The advertised `fp` (host cert SHA-256) is ADVISORY: mDNS is unauthenticated, so TOFU /
|
||||
// pinning still verifies the host on connect — it's surfaced only so a picker can show it and
|
||||
// pre-fill. `pair=required` lets the UI route straight to the pairing ceremony.
|
||||
//
|
||||
// iOS/tvOS gate Bonjour browsing on Info.plist `NSBonjourServices` listing `_punktfunk._udp`
|
||||
// (Config/Info.plist) — without it the system blocks the browse and nothing is returned.
|
||||
|
||||
#if canImport(Network)
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
/// A punktfunk/1 host found on the LAN. `fingerprintHex` is advisory (see file header).
|
||||
public struct DiscoveredHost: Identifiable, Sendable, Equatable {
|
||||
/// Stable host id (mDNS `id` TXT); falls back to the Bonjour instance name.
|
||||
public let id: String
|
||||
/// Bonjour instance name (the host's chosen label).
|
||||
public let name: String
|
||||
/// Resolved address to hand to `PunktfunkConnection`.
|
||||
public let host: String
|
||||
public let port: UInt16
|
||||
/// Host cert SHA-256 (lowercase hex) the host advertised, or nil if absent.
|
||||
public let fingerprintHex: String?
|
||||
/// The host advertised `pair=required` — a client must pair before it can stream.
|
||||
public let requiresPairing: Bool
|
||||
/// The host EXPLICITLY advertised `pair=optional` — only then may the client offer the
|
||||
/// reduced-security TOFU "Trust" path. A missing/unknown `pair` field is NOT optional:
|
||||
/// pairing is mandatory unless this is true (the policy authority is the host's advert).
|
||||
public let allowsTofu: Bool
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public final class HostDiscovery: ObservableObject {
|
||||
/// Currently-visible hosts, deduped by `id`, sorted by name. Main-actor.
|
||||
@Published public private(set) var hosts: [DiscoveredHost] = []
|
||||
|
||||
private var browser: NWBrowser?
|
||||
/// Keyed by the service endpoint's description (a stable, Sendable handle we can capture
|
||||
/// into the resolve callbacks without smuggling non-Sendable Network types across hops).
|
||||
private var resolved: [String: DiscoveredHost] = [:]
|
||||
private var connections: [String: NWConnection] = [:]
|
||||
|
||||
public init() {}
|
||||
|
||||
/// Start browsing `_punktfunk._udp`. Idempotent — a second call while live is a no-op.
|
||||
public func start() {
|
||||
guard browser == nil else { return }
|
||||
let browser = NWBrowser(
|
||||
for: .bonjourWithTXTRecord(type: "_punktfunk._udp", domain: nil),
|
||||
using: NWParameters())
|
||||
browser.browseResultsChangedHandler = { results, _ in
|
||||
MainActor.assumeIsolated { [weak self] in self?.reconcile(results) }
|
||||
}
|
||||
browser.stateUpdateHandler = { state in
|
||||
// A failed browser never recovers on its own; tear down and re-arm so transient
|
||||
// network changes (Wi-Fi flip, VPN) don't leave discovery silently dead.
|
||||
MainActor.assumeIsolated { [weak self] in
|
||||
if case .failed = state { self?.restart() }
|
||||
}
|
||||
}
|
||||
self.browser = browser
|
||||
browser.start(queue: .main)
|
||||
}
|
||||
|
||||
/// Stop browsing and drop all discovered state.
|
||||
public func stop() {
|
||||
browser?.cancel()
|
||||
browser = nil
|
||||
for conn in connections.values { conn.cancel() }
|
||||
connections.removeAll()
|
||||
resolved.removeAll()
|
||||
if !hosts.isEmpty { hosts = [] }
|
||||
}
|
||||
|
||||
deinit {
|
||||
browser?.cancel()
|
||||
for conn in connections.values { conn.cancel() }
|
||||
}
|
||||
|
||||
private func restart() {
|
||||
stop()
|
||||
start()
|
||||
}
|
||||
|
||||
/// Diff the browser's current result set against what we're tracking: drop departed
|
||||
/// services, resolve newly-seen ones.
|
||||
private func reconcile(_ results: Set<NWBrowser.Result>) {
|
||||
let live = Set(results.map { Self.key($0) })
|
||||
for key in resolved.keys where !live.contains(key) { resolved[key] = nil }
|
||||
for key in connections.keys where !live.contains(key) {
|
||||
connections[key]?.cancel()
|
||||
connections[key] = nil
|
||||
}
|
||||
for result in results {
|
||||
let key = Self.key(result)
|
||||
if resolved[key] == nil, connections[key] == nil { resolve(result) }
|
||||
}
|
||||
publish()
|
||||
}
|
||||
|
||||
/// Resolve one service to IP:port via a short UDP connection (it reaches `.ready` once the
|
||||
/// path is established — no data is sent), reading the TXT up front so the callback only
|
||||
/// captures Sendable values + the endpoint key.
|
||||
private func resolve(_ result: NWBrowser.Result) {
|
||||
let key = Self.key(result)
|
||||
let name = Self.instanceName(result.endpoint)
|
||||
var fp: String?
|
||||
var pair: String?
|
||||
var id: String?
|
||||
if case let .bonjour(txt) = result.metadata {
|
||||
fp = Self.entry(txt, "fp")
|
||||
pair = Self.entry(txt, "pair")
|
||||
id = Self.entry(txt, "id")
|
||||
}
|
||||
let conn = NWConnection(to: result.endpoint, using: .udp)
|
||||
connections[key] = conn
|
||||
conn.stateUpdateHandler = { state in
|
||||
MainActor.assumeIsolated { [weak self] in
|
||||
guard let self, let conn = self.connections[key] else { return }
|
||||
switch state {
|
||||
case .ready:
|
||||
if case let .hostPort(host, port)? = conn.currentPath?.remoteEndpoint,
|
||||
let address = Self.hostString(host) {
|
||||
self.resolved[key] = DiscoveredHost(
|
||||
id: (id?.isEmpty == false) ? id! : name,
|
||||
name: name, host: address, port: port.rawValue,
|
||||
fingerprintHex: fp, requiresPairing: pair == "required",
|
||||
allowsTofu: pair == "optional")
|
||||
self.publish()
|
||||
}
|
||||
conn.cancel()
|
||||
self.connections[key] = nil
|
||||
case .failed, .cancelled:
|
||||
self.connections[key] = nil
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
conn.start(queue: .main)
|
||||
}
|
||||
|
||||
/// Publish the resolved set, deduped by `id` (a host on several interfaces / re-advertising
|
||||
/// collapses to one row), sorted by name.
|
||||
private func publish() {
|
||||
var byID: [String: DiscoveredHost] = [:]
|
||||
for host in resolved.values { byID[host.id] = host }
|
||||
let next = byID.values.sorted {
|
||||
$0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
|
||||
}
|
||||
if next != hosts { hosts = next }
|
||||
}
|
||||
|
||||
private static func key(_ result: NWBrowser.Result) -> String {
|
||||
"\(result.endpoint)"
|
||||
}
|
||||
|
||||
private static func instanceName(_ endpoint: NWEndpoint) -> String {
|
||||
if case let .service(name, _, _, _) = endpoint { return name }
|
||||
return "punktfunk host"
|
||||
}
|
||||
|
||||
private static func entry(_ txt: NWTXTRecord, _ field: String) -> String? {
|
||||
if case let .string(value) = txt.getEntry(for: field), !value.isEmpty { return value }
|
||||
return nil
|
||||
}
|
||||
|
||||
/// A resolved `NWEndpoint.Host` → a plain address string for `PunktfunkConnection` (the
|
||||
/// scope id on a link-local address is stripped — the host+port pair is resolved again on
|
||||
/// the Rust side, which can't parse the `%iface` suffix).
|
||||
private static func hostString(_ host: NWEndpoint.Host) -> String? {
|
||||
switch host {
|
||||
case .ipv4(let address):
|
||||
return "\(address)".split(separator: "%").first.map(String.init)
|
||||
case .ipv6(let address):
|
||||
return "\(address)".split(separator: "%").first.map(String.init)
|
||||
case .name(let name, _):
|
||||
return name
|
||||
@unknown default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,87 @@
|
||||
// Convenience constructors for the wire input events (field semantics match
|
||||
// punktfunk_core::input::InputEvent; see punktfunk_core.h).
|
||||
|
||||
import Foundation
|
||||
import PunktfunkCore
|
||||
|
||||
public extension PunktfunkInputEvent {
|
||||
private static func make(
|
||||
_ kind: UInt32, code: UInt32, x: Int32, y: Int32, flags: UInt32 = 0
|
||||
) -> PunktfunkInputEvent {
|
||||
PunktfunkInputEvent(kind: UInt8(kind), _pad: (0, 0, 0), code: code, x: x, y: y, flags: flags)
|
||||
}
|
||||
static func mouseMove(dx: Int32, dy: Int32) -> PunktfunkInputEvent {
|
||||
make(PUNKTFUNK_INPUT_KIND_MOUSE_MOVE.rawValue, code: 0, x: dx, y: dy)
|
||||
}
|
||||
/// Absolute cursor position in client-surface pixels — the host places its cursor
|
||||
/// there (same letterbox mapping and `flags` surface-dims packing as the touch events).
|
||||
/// Used by the iPad pointer fallback when the scene can't pointer-lock and GCMouse's
|
||||
/// relative deltas aren't available; the surface dimensions must each fit in 16 bits.
|
||||
static func mouseMoveAbs(
|
||||
x: Int32, y: Int32, surfaceWidth: UInt32, surfaceHeight: UInt32
|
||||
) -> PunktfunkInputEvent {
|
||||
make(
|
||||
PUNKTFUNK_INPUT_KIND_MOUSE_MOVE_ABS.rawValue, code: 0, x: x, y: y,
|
||||
flags: ((surfaceWidth & 0xFFFF) << 16) | (surfaceHeight & 0xFFFF))
|
||||
}
|
||||
/// GameStream button ids: 1=left 2=middle 3=right 4=X1 5=X2 (host maps to evdev BTN_*).
|
||||
static func mouseButton(_ button: UInt32, down: Bool) -> PunktfunkInputEvent {
|
||||
make(
|
||||
(down ? PUNKTFUNK_INPUT_KIND_MOUSE_BUTTON_DOWN : PUNKTFUNK_INPUT_KIND_MOUSE_BUTTON_UP).rawValue,
|
||||
code: button, x: 0, y: 0)
|
||||
}
|
||||
/// `vk` is a Windows virtual-key code (the host's vk_to_evdev table consumes these).
|
||||
static func key(_ vk: UInt32, down: Bool) -> PunktfunkInputEvent {
|
||||
make((down ? PUNKTFUNK_INPUT_KIND_KEY_DOWN : PUNKTFUNK_INPUT_KIND_KEY_UP).rawValue, code: vk, x: 0, y: 0)
|
||||
}
|
||||
/// WHEEL_DELTA(120)-scaled; positive = up (vertical) / right (horizontal) — the
|
||||
/// convention Moonlight/SDL use; the host maps onto the ei/wl axes.
|
||||
static func scroll(_ delta: Int32, horizontal: Bool = false) -> PunktfunkInputEvent {
|
||||
make(PUNKTFUNK_INPUT_KIND_MOUSE_SCROLL.rawValue, code: horizontal ? 1 : 0, x: delta, y: 0)
|
||||
}
|
||||
|
||||
// Gamepad (wire contract in punktfunk_core::input::gamepad): one transition per event,
|
||||
// `pad` = controller index, accumulated host-side into a virtual Xbox 360 or DualSense
|
||||
// pad (the session's negotiated `GamepadType`).
|
||||
|
||||
/// `button` is a GameStream buttonFlags bit (A=0x1000 B=0x2000 X=0x4000 Y=0x8000,
|
||||
/// dpad=0x1/2/4/8, start=0x10 back=0x20 LS=0x40 RS=0x80 LB=0x100 RB=0x200 guide=0x400,
|
||||
/// touchpad click=0x100000 — DualSense sessions only, the xpad has no such button).
|
||||
static func gamepadButton(_ button: UInt32, down: Bool, pad: UInt32 = 0) -> PunktfunkInputEvent {
|
||||
make(
|
||||
PUNKTFUNK_INPUT_KIND_GAMEPAD_BUTTON.rawValue,
|
||||
code: button, x: down ? 1 : 0, y: 0, flags: pad)
|
||||
}
|
||||
|
||||
/// Axis ids: 0=LSX 1=LSY 2=RSX 3=RSY (−32768...32767, XInput convention: +y = UP —
|
||||
/// `GCControllerDirectionPad.yAxis` already matches, no flip), 4=LT 5=RT (0...255).
|
||||
static func gamepadAxis(_ axis: UInt32, value: Int32, pad: UInt32 = 0) -> PunktfunkInputEvent {
|
||||
make(PUNKTFUNK_INPUT_KIND_GAMEPAD_AXIS.rawValue, code: axis, x: value, y: 0, flags: pad)
|
||||
}
|
||||
|
||||
// Touch (host-side: libei ei_touchscreen on the virtual output). `id` distinguishes
|
||||
// fingers and is reusable after touchUp; coordinates are absolute pixels on the
|
||||
// client's touch surface, whose size rides in `flags` so the host can rescale —
|
||||
// the surface dimensions must each fit in 16 bits. Built for the iOS variant
|
||||
// (UITouch → these); nothing on macOS emits them yet.
|
||||
|
||||
static func touchDown(
|
||||
id: UInt32, x: Int32, y: Int32, surfaceWidth: UInt32, surfaceHeight: UInt32
|
||||
) -> PunktfunkInputEvent {
|
||||
make(
|
||||
PUNKTFUNK_INPUT_KIND_TOUCH_DOWN.rawValue, code: id, x: x, y: y,
|
||||
flags: ((surfaceWidth & 0xFFFF) << 16) | (surfaceHeight & 0xFFFF))
|
||||
}
|
||||
|
||||
static func touchMove(
|
||||
id: UInt32, x: Int32, y: Int32, surfaceWidth: UInt32, surfaceHeight: UInt32
|
||||
) -> PunktfunkInputEvent {
|
||||
make(
|
||||
PUNKTFUNK_INPUT_KIND_TOUCH_MOVE.rawValue, code: id, x: x, y: y,
|
||||
flags: ((surfaceWidth & 0xFFFF) << 16) | (surfaceHeight & 0xFFFF))
|
||||
}
|
||||
|
||||
static func touchUp(id: UInt32) -> PunktfunkInputEvent {
|
||||
make(PUNKTFUNK_INPUT_KIND_TOUCH_UP.rawValue, code: id, x: 0, y: 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// Game library client (experimental, plan step 3). Fetches the host's unified game library
|
||||
// from the management REST API (`GET /api/v1/library`) — the same payload the web console's
|
||||
// /library page renders. Read-only on the client for now; launching a chosen title is a later
|
||||
// step. Gated behind `DefaultsKey.libraryEnabled` in the UI.
|
||||
//
|
||||
// The management API serves HTTPS on a port distinct from the punktfunk/1 data plane (default
|
||||
// 47990, also advertised in the host's mDNS `mgmt` TXT). A paired client is authorized for the
|
||||
// read-only library route by its **mTLS certificate** — no bearer token. The host binds this read
|
||||
// surface to the LAN by DEFAULT (the bearer-gated admin surface stays loopback-only), so a paired
|
||||
// client browses a host's library with no operator step. This mirrors the GameEntry/Artwork/
|
||||
// LaunchSpec schema in `crates/punktfunk-host/src/library.rs`.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Cover art URLs (the public Steam CDN for Steam titles, user-supplied for custom entries).
|
||||
public struct Artwork: Codable, Hashable, Sendable {
|
||||
public var portrait: String?
|
||||
public var hero: String?
|
||||
public var logo: String?
|
||||
public var header: String?
|
||||
|
||||
/// Preferred order for a poster grid: the 600×900 capsule, falling back to the header
|
||||
/// (which is near-universal — many older titles lack a portrait capsule).
|
||||
public var posterCandidates: [URL] {
|
||||
[portrait, header, hero].compactMap { $0 }.compactMap { URL(string: $0) }
|
||||
}
|
||||
}
|
||||
|
||||
/// How the host would launch a title (carried for a later step; the client only displays it).
|
||||
public struct LaunchSpec: Codable, Hashable, Sendable {
|
||||
public var kind: String // "steam_appid" | "command"
|
||||
public var value: String
|
||||
}
|
||||
|
||||
/// One title in the unified library. `id` is store-qualified: `steam:<appid>` / `custom:<id>`.
|
||||
public struct GameEntry: Codable, Hashable, Identifiable, Sendable {
|
||||
public var id: String
|
||||
public var store: String // "steam" | "custom"
|
||||
public var title: String
|
||||
public var art: Artwork
|
||||
public var launch: LaunchSpec?
|
||||
|
||||
public var isCustom: Bool { store == "custom" }
|
||||
}
|
||||
|
||||
/// Errors surfaced to the UI so it can guide setup (the common case is "not paired yet").
|
||||
public enum LibraryError: LocalizedError {
|
||||
case unauthorized
|
||||
case http(Int)
|
||||
case unreachable(String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .unauthorized:
|
||||
return "The host didn't recognize this device. Pair with the host first — it "
|
||||
+ "authorizes paired clients by their certificate (no token needed)."
|
||||
case .http(let code):
|
||||
return "The management API returned HTTP \(code)."
|
||||
case .unreachable(let why):
|
||||
return "Couldn't reach the host's management API: \(why). It binds the LAN by default, "
|
||||
+ "so check the host is updated and reachable (a host pinned to "
|
||||
+ "`--mgmt-bind 127.0.0.1` is loopback-only and can't be browsed remotely)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The management API's default port — adjacent to the GameStream block; matches
|
||||
/// `mgmt::DEFAULT_PORT` on the host.
|
||||
public let punktfunkDefaultMgmtPort: UInt16 = 47990
|
||||
|
||||
/// Stateless fetcher for a host's library.
|
||||
public enum LibraryClient {
|
||||
/// `GET https://<address>:<port>/api/v1/library`, authenticated by **mTLS**: the client
|
||||
/// presents `identity` (its persistent cert/key PEM — the same identity the host paired over
|
||||
/// QUIC), and the host's self-signed cert is pinned by `hostFingerprint` (SHA-256 of its DER,
|
||||
/// the value the client already trusts). No bearer token — a paired client is authorized by
|
||||
/// its certificate. `hostFingerprint == nil` ⇒ TOFU (accept the presented host cert).
|
||||
public static func fetch(
|
||||
address: String,
|
||||
port: UInt16 = punktfunkDefaultMgmtPort,
|
||||
certPEM: String,
|
||||
keyPEM: String,
|
||||
hostFingerprint: Data?
|
||||
) async throws -> [GameEntry] {
|
||||
guard let url = URL(string: "https://\(address):\(port)/api/v1/library") else {
|
||||
throw LibraryError.unreachable("invalid host address")
|
||||
}
|
||||
let identity: SecIdentity
|
||||
do {
|
||||
identity = try ClientTLS.makeIdentity(certPEM: certPEM, keyPEM: keyPEM)
|
||||
} catch {
|
||||
throw LibraryError.unreachable(
|
||||
(error as? LocalizedError)?.errorDescription ?? error.localizedDescription)
|
||||
}
|
||||
let delegate = LibraryTLSDelegate(
|
||||
identity: identity, pinnedHostFingerprint: hostFingerprint, host: address, port: port)
|
||||
let session = URLSession(configuration: .ephemeral, delegate: delegate, delegateQueue: nil)
|
||||
defer { session.finishTasksAndInvalidate() }
|
||||
|
||||
let req = URLRequest(url: url, timeoutInterval: 10)
|
||||
let (data, response): (Data, URLResponse)
|
||||
do {
|
||||
(data, response) = try await session.data(for: req)
|
||||
} catch {
|
||||
throw LibraryError.unreachable(error.localizedDescription)
|
||||
}
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw LibraryError.unreachable("not an HTTP response")
|
||||
}
|
||||
switch http.statusCode {
|
||||
case 200:
|
||||
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:
|
||||
throw LibraryError.http(http.statusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,751 @@
|
||||
// Swift wrapper around the punktfunk-core C ABI's punktfunk/1 connection API.
|
||||
//
|
||||
// Threading contract (mirrors the C header): one PunktfunkConnection is pumped from a single
|
||||
// video thread via nextAU(); nextAudio() runs on its own (single) drain thread, and
|
||||
// nextRumble()/nextHidOutput() share one feedback drain thread (two core planes, one puller
|
||||
// each — polling them sequentially from one thread is within the contract); the core keeps
|
||||
// per-plane borrow slots, so the planes never alias. send() is enqueue-only and safe
|
||||
// alongside all of them. The pointers inside an AU/audio packet are only valid until the
|
||||
// next call of the same kind, so we copy into Data here — the copies are small and keep the
|
||||
// Swift side memory-safe.
|
||||
//
|
||||
// Trust: pass the host's pinned certificate fingerprint (the host logs it at startup, and
|
||||
// `hostFingerprint` reports what a trust-on-first-use connect observed — persist it, e.g.
|
||||
// in UserDefaults keyed by host, and pin it from then on).
|
||||
//
|
||||
// close() is safe from any thread: it flags the pullers to exit at their next poll
|
||||
// boundary, then takes the per-plane locks (each held across its blocking C poll), so the
|
||||
// handle is never freed under an in-flight call — the C contract ("never close with a
|
||||
// next_au/next_audio call in flight") is enforced here rather than left to callers. After
|
||||
// close, the pull methods throw `.closed` and the threads unwind on their own.
|
||||
|
||||
import Foundation
|
||||
import PunktfunkCore
|
||||
|
||||
// cbindgen's C17-compatible header spells the typedefs as plain integers
|
||||
// (`typedef int32_t PunktfunkStatus`, `typedef uint8_t PunktfunkInputKind`) while the enum
|
||||
// constants import as a distinct same-named Swift type — bridge by raw value once here.
|
||||
private let statusOK: Int32 = PUNKTFUNK_STATUS_OK.rawValue
|
||||
private let statusNoFrame: Int32 = PUNKTFUNK_STATUS_NO_FRAME.rawValue
|
||||
private let statusClosed: Int32 = PUNKTFUNK_STATUS_CLOSED.rawValue
|
||||
|
||||
/// One reassembled, FEC-recovered, decrypted access unit (Annex-B HEVC from the host).
|
||||
public struct AccessUnit: Sendable {
|
||||
public let data: Data
|
||||
public let ptsNs: UInt64
|
||||
public let frameIndex: UInt32
|
||||
public let flags: UInt32
|
||||
}
|
||||
|
||||
/// One Opus audio packet (48 kHz stereo, 5 ms frames) — decode with AVAudioConverter
|
||||
/// (`kAudioFormatOpus`) or libopus into an AVAudioEngine source node.
|
||||
public struct AudioPacket: Sendable {
|
||||
public let data: Data
|
||||
public let ptsNs: UInt64
|
||||
public let seq: UInt32
|
||||
}
|
||||
|
||||
public enum PunktfunkClientError: Error {
|
||||
/// Connect failed — wrong host/port, timeout, or a certificate-pin mismatch.
|
||||
case connectFailed
|
||||
/// `pinSHA256` was non-nil but not exactly 32 bytes. Failing closed: connecting
|
||||
/// unpinned when the caller asked for verification would be a silent trust downgrade.
|
||||
case invalidPin
|
||||
/// Pairing rejected — wrong PIN.
|
||||
case wrongPIN
|
||||
case closed
|
||||
case status(Int32)
|
||||
}
|
||||
|
||||
/// `withCString` over an optional — nil maps to a NULL C pointer.
|
||||
func withOptionalCString<R>(_ s: String?, _ body: (UnsafePointer<CChar>?) -> R) -> R {
|
||||
guard let s else { return body(nil) }
|
||||
return s.withCString { body($0) }
|
||||
}
|
||||
|
||||
public final class PunktfunkConnection {
|
||||
private var handle: OpaquePointer?
|
||||
/// Set by close() before it contends for the plane locks: the pullers see it at their
|
||||
/// next poll boundary and exit, so close() can't be starved by back-to-back polls
|
||||
/// (NSLock is not fair).
|
||||
private var closeRequested = false
|
||||
/// Serializes send()/close() against each other and guards `handle`/`closeRequested`.
|
||||
private let abiLock = NSLock()
|
||||
/// Held across the blocking next_au call; close() takes it (same plane-lock → abiLock
|
||||
/// order as the pullers) so it can never free the handle under an in-flight poll.
|
||||
private let pumpLock = NSLock()
|
||||
/// Same role for the audio drain thread (its own plane in the core).
|
||||
private let audioLock = NSLock()
|
||||
/// Same role for the feedback drain thread (rumble + HID-output — two core planes,
|
||||
/// drained sequentially by one thread).
|
||||
private let feedbackLock = NSLock()
|
||||
|
||||
/// Negotiated session mode (host-confirmed).
|
||||
public private(set) var width: UInt32 = 0
|
||||
public private(set) var height: UInt32 = 0
|
||||
public private(set) var refreshHz: UInt32 = 0
|
||||
|
||||
/// SHA-256 fingerprint of the certificate the host presented (32 bytes). After a
|
||||
/// trust-on-first-use connect, persist this and pass it as `pinSHA256` next time.
|
||||
public private(set) var hostFingerprint: Data = Data()
|
||||
|
||||
/// Compositor preference for the host's per-session virtual output (the
|
||||
/// `PUNKTFUNK_COMPOSITOR_*` ABI values). `.auto` lets the host auto-detect from its
|
||||
/// running desktop; a concrete backend is honored only if available on the host right
|
||||
/// now — else the host falls back to auto-detect and logs the real choice.
|
||||
public enum Compositor: UInt32, CaseIterable, Sendable {
|
||||
case auto = 0
|
||||
case kwin = 1
|
||||
case wlroots = 2
|
||||
case mutter = 3
|
||||
case gamescope = 4
|
||||
|
||||
/// Loose name parsing for env/dev hooks ("kde" and "sway" are accepted aliases,
|
||||
/// mirroring the host's `CompositorPref::from_name`).
|
||||
public init?(name: String) {
|
||||
switch name.lowercased() {
|
||||
case "auto": self = .auto
|
||||
case "kwin", "kde": self = .kwin
|
||||
case "wlroots", "sway", "hyprland": self = .wlroots
|
||||
case "mutter", "gnome": self = .mutter
|
||||
case "gamescope": self = .gamescope
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which virtual gamepad the host creates for this session's pads (the
|
||||
/// `PUNKTFUNK_GAMEPAD_*` ABI values). `.auto` lets the host decide (its env var, else
|
||||
/// X-Box 360); `.dualSense` / `.dualShock4` are honored only on hosts with UHID (Linux) —
|
||||
/// games then see a real PlayStation pad and its lightbar (and, on a DualSense,
|
||||
/// adaptive-trigger / player-LED) writes come back on the HID-output plane
|
||||
/// (`nextHidOutput`). `.xboxOne` is an X-Box-Series-glyph variant of `.xbox360` (same
|
||||
/// buttons/sticks/triggers + rumble, no touchpad/motion/lightbar). The host's actual
|
||||
/// choice is `resolvedGamepad`.
|
||||
public enum GamepadType: UInt32, CaseIterable, Sendable {
|
||||
case auto = 0
|
||||
case xbox360 = 1
|
||||
case dualSense = 2
|
||||
case xboxOne = 3
|
||||
case dualShock4 = 4
|
||||
// Valve Steam Controller / Steam Deck (Linux UHID hid-steam hosts). Parity only on Apple —
|
||||
// GameController never surfaces a 0x28DE HID device, so the client can't capture one; these
|
||||
// exist so the resolved type round-trips and name parsing matches the host.
|
||||
case steamController = 5
|
||||
case steamDeck = 6
|
||||
|
||||
/// Loose name parsing for env/dev hooks, mirroring the host's
|
||||
/// `GamepadPref::from_name`.
|
||||
public init?(name: String) {
|
||||
switch name.lowercased() {
|
||||
case "auto", "default": self = .auto
|
||||
case "xbox", "xbox360", "x360", "uinput": self = .xbox360
|
||||
case "dualsense", "ds", "ds5", "ps5": self = .dualSense
|
||||
case "xboxone", "xbox-one", "xboxseries", "series": self = .xboxOne
|
||||
case "dualshock4", "dualshock", "ds4", "ps4": self = .dualShock4
|
||||
case "steamdeck", "steam-deck", "deck": self = .steamDeck
|
||||
case "steamcontroller", "steam-controller", "steamcon": self = .steamController
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The virtual gamepad backend the host actually resolved (the Welcome's echo of the
|
||||
/// requested `gamepad`). `.auto` = an older host that didn't say — assume Xbox 360, no
|
||||
/// DualSense feedback.
|
||||
public private(set) var resolvedGamepad: GamepadType = .auto
|
||||
|
||||
/// The compositor the host actually resolved for this session's virtual output (the
|
||||
/// Welcome's echo of the requested `compositor`, with `.auto` resolved to a concrete
|
||||
/// backend). `.auto` = an older host that didn't say. Clients use it to decide
|
||||
/// client-side cursor behavior: `.gamescope`'s PipeWire capture carries no cursor, so
|
||||
/// the client draws its own (a visible system cursor over the stream).
|
||||
public private(set) var resolvedCompositor: Compositor = .auto
|
||||
|
||||
/// Host clock minus client clock (nanoseconds), from the connect-time wall-clock skew handshake
|
||||
/// (`punktfunk_connection_clock_offset_ns`). Add it to a local `CLOCK_REALTIME` instant to
|
||||
/// express that instant in the host's capture clock — the clock each `AccessUnit.ptsNs` is
|
||||
/// stamped in — so a glass-to-glass latency (present/enqueue time minus `ptsNs`) is valid across
|
||||
/// machines. `0` = no correction (an older host that didn't answer, or synchronized clocks).
|
||||
public private(set) var clockOffsetNs: Int64 = 0
|
||||
|
||||
/// The video encoder bitrate (kbps) the host actually configured — the requested
|
||||
/// `bitrateKbps` clamped to the host's range ([500, 2 000 000] kbps), or its default
|
||||
/// (20 000) when 0 was requested. `0` = an older host that didn't report it.
|
||||
public private(set) var resolvedBitrateKbps: UInt32 = 0
|
||||
|
||||
/// The colour signalling the host actually encodes with (CICP code points): `colorPrimaries`
|
||||
/// (1=BT.709, 9=BT.2020), `colorTransfer` (1=BT.709, 16=PQ, 18=HLG), `colorMatrix`
|
||||
/// (1=BT.709, 9=BT.2020-NCL), `colorFullRange`. BT.709 limited SDR for an older host. Configure
|
||||
/// the decoder/presenter from these; mastering metadata arrives via `nextHdrMeta`.
|
||||
public private(set) var colorPrimaries: UInt8 = 1
|
||||
public private(set) var colorTransfer: UInt8 = 1
|
||||
public private(set) var colorMatrix: UInt8 = 1
|
||||
public private(set) var colorFullRange: Bool = false
|
||||
/// Encoded bit depth (8 or 10).
|
||||
public private(set) var bitDepth: UInt8 = 8
|
||||
/// The chroma subsampling the host resolved for this session, as the HEVC `chroma_format_idc`:
|
||||
/// `1` = 4:2:0 (every pre-4:4:4 host, and the back-compat default) or `3` = full-chroma 4:4:4
|
||||
/// (only when this client advertised `videoCap444` *and* the host could open a real 4:4:4
|
||||
/// encoder). Drive the decoder's requested pixel format from this. See `isChroma444`.
|
||||
public private(set) var chromaFormat: UInt8 = 1
|
||||
/// Convenience: the resolved stream is full-chroma 4:4:4 (`chroma_format_idc == 3`).
|
||||
public var isChroma444: Bool { chromaFormat == 3 }
|
||||
/// True when the negotiated stream is HDR (PQ or HLG transfer) — drive an HDR present path and
|
||||
/// drain `nextHdrMeta`.
|
||||
public var isHDR: Bool { colorTransfer == 16 || colorTransfer == 18 }
|
||||
|
||||
/// The audio channel count the host resolved for this session (the Welcome's echo of the
|
||||
/// requested `audioChannels`, clamped to what the host can capture): `2` (stereo), `6` (5.1)
|
||||
/// or `8` (7.1). Build the playback layout from THIS, never the request. `2` for an older host.
|
||||
/// PCM from `nextAudioPcm` is interleaved in the canonical wire order FL FR FC LFE RL RR SL SR.
|
||||
public private(set) var resolvedAudioChannels: UInt8 = 2
|
||||
|
||||
/// The video codec the host resolved for this session (`Welcome.codec`, `PUNKTFUNK_CODEC_*`):
|
||||
/// `2` = HEVC (default / older host), `1` = H.264, `4` = AV1. Build the decoder from THIS. The
|
||||
/// resolved value honors the client's `preferredCodec` when the host could emit it.
|
||||
public private(set) var resolvedCodec: UInt8 = 2 // PUNKTFUNK_CODEC_HEVC
|
||||
/// The resolved codec as an `AnnexB.VideoCodec` (H.264 vs HEVC) — drives the NAL parsing.
|
||||
public var videoCodec: VideoCodec { VideoCodec(wire: resolvedCodec) }
|
||||
|
||||
/// Connect and start a session at the requested mode (the host creates a native virtual
|
||||
/// output at exactly this size/refresh). Blocks up to `timeoutMs`.
|
||||
///
|
||||
/// `pinSHA256`: the host's expected certificate fingerprint (exactly 32 bytes, else
|
||||
/// `invalidPin` is thrown — never silently downgraded); nil = trust on first use
|
||||
/// (check `hostFingerprint` afterwards). A pinned mismatch throws.
|
||||
///
|
||||
/// `identity`: this client's persistent identity (from `generateIdentity()`, stored in
|
||||
/// the Keychain) — presented so a host recognizes a paired client. nil = anonymous;
|
||||
/// hosts running `--require-pairing` reject anonymous sessions.
|
||||
///
|
||||
/// `compositor`: which backend should drive the virtual output host-side (see
|
||||
/// `Compositor`; `.auto` = host decides).
|
||||
///
|
||||
/// `gamepad`: which virtual pad the host creates for this session's controllers (see
|
||||
/// `GamepadType`; `.auto` = host decides). Check `resolvedGamepad` afterwards.
|
||||
///
|
||||
/// `bitrateKbps`: requested video encoder bitrate (0 = host default; the host clamps
|
||||
/// to its supported range). Check `resolvedBitrateKbps` afterwards — a speed test
|
||||
/// (`startSpeedTest`) is how a client picks an informed value.
|
||||
public init(
|
||||
host: String, port: UInt16 = 9777,
|
||||
width: UInt32, height: UInt32, refreshHz: UInt32,
|
||||
pinSHA256: Data? = nil,
|
||||
identity: ClientIdentity? = nil,
|
||||
compositor: Compositor = .auto,
|
||||
gamepad: GamepadType = .auto,
|
||||
bitrateKbps: UInt32 = 0,
|
||||
videoCaps: UInt8 = 0,
|
||||
audioChannels: UInt8 = 2,
|
||||
videoCodecs: UInt8 = 0x02, // PUNKTFUNK_CODEC_HEVC — the codecs this client can decode
|
||||
preferredCodec: UInt8 = 0, // 0 = auto; else PUNKTFUNK_CODEC_* soft preference
|
||||
launchID: String? = nil,
|
||||
timeoutMs: UInt32 = 10_000
|
||||
) throws {
|
||||
if let pin = pinSHA256, pin.count != 32 { throw PunktfunkClientError.invalidPin }
|
||||
var observed = [UInt8](repeating: 0, count: 32)
|
||||
// `videoCaps` advertises decode/present capability (PUNKTFUNK_VIDEO_CAP_10BIT | _HDR): the
|
||||
// host upgrades to a 10-bit / BT.2020 PQ stream only when set. 0 = 8-bit BT.709 SDR.
|
||||
// `launchID` (a host library id like "steam:570") asks the host to launch that title in
|
||||
// the session; the host resolves it against its own library — nil = the host's default.
|
||||
handle = host.withCString { cs in
|
||||
withOptionalCString(identity?.certPEM) { cert in
|
||||
withOptionalCString(identity?.keyPEM) { key in
|
||||
withOptionalCString(launchID) { launch in
|
||||
if let pin = pinSHA256 {
|
||||
return pin.withUnsafeBytes { p in
|
||||
punktfunk_connect_ex7(
|
||||
cs, port, width, height, refreshHz, compositor.rawValue,
|
||||
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
|
||||
videoCodecs, preferredCodec, launch,
|
||||
p.bindMemory(to: UInt8.self).baseAddress, &observed,
|
||||
cert, key, timeoutMs)
|
||||
}
|
||||
}
|
||||
return punktfunk_connect_ex7(
|
||||
cs, port, width, height, refreshHz, compositor.rawValue,
|
||||
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
|
||||
videoCodecs, preferredCodec, launch,
|
||||
nil, &observed, cert, key, timeoutMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
guard handle != nil else { throw PunktfunkClientError.connectFailed }
|
||||
hostFingerprint = Data(observed)
|
||||
var w: UInt32 = 0, h: UInt32 = 0, hz: UInt32 = 0
|
||||
_ = punktfunk_connection_mode(handle, &w, &h, &hz)
|
||||
self.width = w
|
||||
self.height = h
|
||||
self.refreshHz = hz
|
||||
var gp: UInt32 = 0
|
||||
_ = punktfunk_connection_gamepad(handle, &gp)
|
||||
resolvedGamepad = GamepadType(rawValue: gp) ?? .auto
|
||||
var comp: UInt32 = 0
|
||||
_ = punktfunk_connection_compositor(handle, &comp)
|
||||
resolvedCompositor = Compositor(rawValue: comp) ?? .auto
|
||||
var offset: Int64 = 0
|
||||
_ = punktfunk_connection_clock_offset_ns(handle, &offset)
|
||||
clockOffsetNs = offset
|
||||
var br: UInt32 = 0
|
||||
_ = punktfunk_connection_bitrate(handle, &br)
|
||||
resolvedBitrateKbps = br
|
||||
var prim: UInt8 = 1, trc: UInt8 = 1, mtx: UInt8 = 1, fullRange: UInt8 = 0, depth: UInt8 = 8
|
||||
_ = punktfunk_connection_color_info(handle, &prim, &trc, &mtx, &fullRange, &depth)
|
||||
colorPrimaries = prim
|
||||
colorTransfer = trc
|
||||
colorMatrix = mtx
|
||||
colorFullRange = fullRange != 0
|
||||
bitDepth = depth
|
||||
var cf: UInt8 = 1
|
||||
_ = punktfunk_connection_chroma_format(handle, &cf)
|
||||
chromaFormat = cf
|
||||
var ac: UInt8 = 2
|
||||
_ = punktfunk_connection_audio_channels(handle, &ac)
|
||||
resolvedAudioChannels = ac
|
||||
var codec: UInt8 = 2 // PUNKTFUNK_CODEC_HEVC
|
||||
_ = punktfunk_connection_codec(handle, &codec)
|
||||
resolvedCodec = codec
|
||||
}
|
||||
|
||||
/// A bandwidth speed-test measurement (see `startSpeedTest`). Partial until `done`.
|
||||
public struct ProbeResult: Sendable, Equatable {
|
||||
/// The host's end-of-burst report arrived — the numbers are final.
|
||||
public let done: Bool
|
||||
/// Probe payload bytes / packets the client received.
|
||||
public let recvBytes: UInt64
|
||||
public let recvPackets: UInt32
|
||||
/// Probe payload bytes / packets the host reported sending.
|
||||
public let hostBytes: UInt64
|
||||
public let hostPackets: UInt32
|
||||
/// Client-measured receive window (first→last probe AU), milliseconds.
|
||||
public let elapsedMs: UInt32
|
||||
/// Measured goodput, kilobits per second.
|
||||
public let throughputKbps: UInt32
|
||||
/// Delivery loss `(hostBytes − recvBytes) / hostBytes`, percent (0 if unknown).
|
||||
public let lossPct: Float
|
||||
}
|
||||
|
||||
/// Start a bandwidth speed test: the host bursts filler over the data plane at
|
||||
/// `targetKbps` of goodput for `durationMs` (clamped host-side to ≤ 3 Gbps / ≤ 5 s),
|
||||
/// briefly pausing video. Non-blocking — poll `probeResult()` until `done`. Starting
|
||||
/// a probe resets any prior measurement. Silently dropped after close.
|
||||
public func startSpeedTest(targetKbps: UInt32, durationMs: UInt32) {
|
||||
abiLock.lock()
|
||||
defer { abiLock.unlock() }
|
||||
guard let h = handle, !closeRequested else { return }
|
||||
_ = punktfunk_connection_speed_test(h, targetKbps, durationMs)
|
||||
}
|
||||
|
||||
/// The current speed-test measurement (zeros before any probe; partial until `done`).
|
||||
/// Safe to poll from any thread; nil after close.
|
||||
public func probeResult() -> ProbeResult? {
|
||||
abiLock.lock()
|
||||
defer { abiLock.unlock() }
|
||||
guard let h = handle, !closeRequested else { return nil }
|
||||
var out = PunktfunkProbeResult()
|
||||
guard punktfunk_connection_probe_result(h, &out) == statusOK else { return nil }
|
||||
return ProbeResult(
|
||||
done: out.done != 0,
|
||||
recvBytes: out.recv_bytes, recvPackets: out.recv_packets,
|
||||
hostBytes: out.host_bytes, hostPackets: out.host_packets,
|
||||
elapsedMs: out.elapsed_ms, throughputKbps: out.throughput_kbps,
|
||||
lossPct: out.loss_pct)
|
||||
}
|
||||
|
||||
/// Ask the host to switch the live session to a new mode (window resized) — no
|
||||
/// reconnect. Non-blocking; on acceptance the stream continues at the new mode (the
|
||||
/// first new-mode AU is an IDR with fresh parameter sets — `AnnexB.formatDescription`
|
||||
/// refresh-on-IDR already handles it) and `currentMode()` reflects the switch.
|
||||
public func requestMode(width: UInt32, height: UInt32, refreshHz: UInt32) {
|
||||
abiLock.lock()
|
||||
defer { abiLock.unlock() }
|
||||
guard let h = handle, !closeRequested else { return }
|
||||
_ = punktfunk_connection_request_mode(h, width, height, refreshHz)
|
||||
}
|
||||
|
||||
/// Ask the host's encoder to emit a fresh IDR keyframe now — recovery when the local
|
||||
/// decoder has wedged. The host opens the infinite-GOP stream with one IDR and then sends
|
||||
/// P-frames only, so a stalled decode (a lost/corrupt opening IDR, a bad early P-frame —
|
||||
/// most likely on the cold first connect) would otherwise stay frozen until the next
|
||||
/// loss-triggered recovery keyframe, which may be far off. Fire-and-forget; the recovered
|
||||
/// keyframe is the only ack. THROTTLE at the call site — the decode stays wedged for
|
||||
/// several frames until the IDR lands, so requesting every frame would flood the control
|
||||
/// stream. Silently dropped after close.
|
||||
public func requestKeyframe() {
|
||||
abiLock.lock()
|
||||
defer { abiLock.unlock() }
|
||||
guard let h = handle, !closeRequested else { return }
|
||||
_ = punktfunk_connection_request_keyframe(h)
|
||||
}
|
||||
|
||||
/// Cumulative access units the host→client reassembler dropped as unrecoverable (FEC couldn't
|
||||
/// rebuild them). The video pump polls this and calls `requestKeyframe()` when it climbs — the
|
||||
/// correct loss trigger under the host's infinite GOP, where unrecoverable loss yields
|
||||
/// reference-missing delta frames the decoder *silently conceals* (a frozen / garbage picture,
|
||||
/// no decode error and no `.failed` layer), so a decode-error trigger rarely fires. Monotonic
|
||||
/// for the session; 0 after close. Cheap (an atomic load) — safe to poll every pump iteration.
|
||||
public func framesDropped() -> UInt64 {
|
||||
abiLock.lock()
|
||||
defer { abiLock.unlock() }
|
||||
guard let h = handle, !closeRequested else { return 0 }
|
||||
var out: UInt64 = 0
|
||||
_ = punktfunk_connection_frames_dropped(h, &out)
|
||||
return out
|
||||
}
|
||||
|
||||
/// The currently active session mode (updated by accepted `requestMode` switches).
|
||||
public func currentMode() -> (width: UInt32, height: UInt32, refreshHz: UInt32) {
|
||||
abiLock.lock()
|
||||
defer { abiLock.unlock() }
|
||||
var w: UInt32 = 0, h: UInt32 = 0, hz: UInt32 = 0
|
||||
if let hd = handle, !closeRequested {
|
||||
_ = punktfunk_connection_mode(hd, &w, &h, &hz)
|
||||
}
|
||||
return (w, h, hz)
|
||||
}
|
||||
|
||||
/// Pull the next access unit; nil on timeout, throws `.closed` once the session ended.
|
||||
/// Call from a single pump thread.
|
||||
public func nextAU(timeoutMs: UInt32 = 100) throws -> AccessUnit? {
|
||||
pumpLock.lock()
|
||||
defer { pumpLock.unlock() }
|
||||
guard let h = liveHandle() else { throw PunktfunkClientError.closed }
|
||||
|
||||
var frame = PunktfunkFrame()
|
||||
let rc = punktfunk_connection_next_au(h, &frame, timeoutMs)
|
||||
switch rc {
|
||||
case statusOK:
|
||||
guard let base = frame.data, frame.len > 0 else { return nil }
|
||||
let data = Data(bytes: base, count: Int(frame.len)) // copy: ptr valid only until next call
|
||||
return AccessUnit(
|
||||
data: data, ptsNs: frame.pts_ns,
|
||||
frameIndex: frame.frame_index, flags: frame.flags)
|
||||
case statusNoFrame:
|
||||
return nil
|
||||
case statusClosed:
|
||||
throw PunktfunkClientError.closed
|
||||
default:
|
||||
throw PunktfunkClientError.status(rc)
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the next Opus audio packet; nil on timeout, throws `.closed` once the session
|
||||
/// ended. Drain from a dedicated audio thread — packets arrive every 5 ms (the core
|
||||
/// buffers 320 ms and drops the newest when the puller lags).
|
||||
public func nextAudio(timeoutMs: UInt32 = 100) throws -> AudioPacket? {
|
||||
audioLock.lock()
|
||||
defer { audioLock.unlock() }
|
||||
guard let h = liveHandle() else { throw PunktfunkClientError.closed }
|
||||
|
||||
var pkt = PunktfunkAudioPacket()
|
||||
let rc = punktfunk_connection_next_audio(h, &pkt, timeoutMs)
|
||||
switch rc {
|
||||
case statusOK:
|
||||
guard let base = pkt.data, pkt.len > 0 else { return nil }
|
||||
let data = Data(bytes: base, count: Int(pkt.len)) // copy: ptr valid only until next call
|
||||
return AudioPacket(data: data, ptsNs: pkt.pts_ns, seq: pkt.seq)
|
||||
case statusNoFrame:
|
||||
return nil
|
||||
case statusClosed:
|
||||
throw PunktfunkClientError.closed
|
||||
default:
|
||||
throw PunktfunkClientError.status(rc)
|
||||
}
|
||||
}
|
||||
|
||||
/// One decoded audio frame from `nextAudioPcm`: interleaved 32-bit float at 48 kHz, in the
|
||||
/// canonical wire channel order FL FR FC LFE RL RR SL SR (the first `channels`).
|
||||
public struct AudioPCM: Sendable {
|
||||
/// Interleaved f32 samples (`frameCount * channels` long), wire channel order.
|
||||
public let samples: [Float]
|
||||
/// Samples per channel.
|
||||
public let frameCount: Int
|
||||
/// Channel count (2/6/8) — `resolvedAudioChannels`.
|
||||
public let channels: Int
|
||||
public let ptsNs: UInt64
|
||||
public let seq: UInt32
|
||||
}
|
||||
|
||||
/// Pull the next audio frame, **decoded in-core** to interleaved f32 PCM — Apple's AudioToolbox
|
||||
/// Opus path is stereo-only, so surround (and, for uniformity, stereo too) is decoded by the
|
||||
/// Rust core (libopus multistream) and handed back as PCM. nil on timeout, throws `.closed` once
|
||||
/// the session ended. Drain from a dedicated audio thread (do NOT also call `nextAudio` — they
|
||||
/// share the underlying queue). The returned `samples` are copied out, so the buffer is owned.
|
||||
public func nextAudioPcm(timeoutMs: UInt32 = 100) throws -> AudioPCM? {
|
||||
audioLock.lock()
|
||||
defer { audioLock.unlock() }
|
||||
guard let h = liveHandle() else { throw PunktfunkClientError.closed }
|
||||
|
||||
var out = PunktfunkAudioPcm()
|
||||
let rc = punktfunk_connection_next_audio_pcm(h, &out, timeoutMs)
|
||||
switch rc {
|
||||
case statusOK:
|
||||
let channels = Int(out.channels)
|
||||
let total = Int(out.frame_count) * channels
|
||||
guard let base = out.samples, total > 0 else { return nil }
|
||||
// Copy: the pointer borrows connection memory only until the next PCM call.
|
||||
let samples = Array(UnsafeBufferPointer(start: base, count: total))
|
||||
return AudioPCM(
|
||||
samples: samples, frameCount: Int(out.frame_count),
|
||||
channels: channels, ptsNs: out.pts_ns, seq: out.seq)
|
||||
case statusNoFrame:
|
||||
return nil
|
||||
case statusClosed:
|
||||
throw PunktfunkClientError.closed
|
||||
default:
|
||||
throw PunktfunkClientError.status(rc)
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the next force-feedback update for the GCController haptics engine:
|
||||
/// `(pad, lowFrequency, highFrequency)` with 0...0xFFFF amplitudes, (0, 0) = stop.
|
||||
/// Drain from the (single) feedback thread, alongside `nextHidOutput`.
|
||||
public func nextRumble(timeoutMs: UInt32 = 0) throws -> (pad: UInt16, low: UInt16, high: UInt16)? {
|
||||
feedbackLock.lock()
|
||||
defer { feedbackLock.unlock() }
|
||||
guard let h = liveHandle() else { throw PunktfunkClientError.closed }
|
||||
|
||||
var pad: UInt16 = 0, low: UInt16 = 0, high: UInt16 = 0
|
||||
let rc = punktfunk_connection_next_rumble(h, &pad, &low, &high, timeoutMs)
|
||||
switch rc {
|
||||
case statusOK:
|
||||
return (pad, low, high)
|
||||
case statusNoFrame:
|
||||
return nil
|
||||
case statusClosed:
|
||||
throw PunktfunkClientError.closed
|
||||
default:
|
||||
throw PunktfunkClientError.status(rc)
|
||||
}
|
||||
}
|
||||
|
||||
/// One DualSense feedback event a game wrote to the host's virtual pad — replay it on
|
||||
/// the real controller (GCDeviceLight, GCControllerPlayerIndex,
|
||||
/// GCDualSenseAdaptiveTrigger). Only a `.dualSense` session emits these.
|
||||
public enum HidOutputEvent: Sendable, Equatable {
|
||||
/// Lightbar color.
|
||||
case led(pad: UInt8, r: UInt8, g: UInt8, b: UInt8)
|
||||
/// Player-indicator LEDs (low 5 bits).
|
||||
case playerLEDs(pad: UInt8, bits: UInt8)
|
||||
/// Adaptive-trigger effect: `which` 0 = L2, 1 = R2; `effect` is the raw DualSense
|
||||
/// trigger parameter block (mode byte + params, ≤ 11 bytes) — parse with
|
||||
/// `DualSenseTriggerEffect`.
|
||||
case triggerEffect(pad: UInt8, which: UInt8, effect: [UInt8])
|
||||
}
|
||||
|
||||
/// Pull the next PlayStation-pad feedback event (lightbar / player LEDs / adaptive
|
||||
/// triggers); nil on timeout, throws `.closed` once the session ended. Drain from the
|
||||
/// (single) feedback thread, alongside `nextRumble`. Nothing arrives unless the session's
|
||||
/// virtual pad is a DualSense (all three) or a DualShock 4 (lightbar only) — poll with a
|
||||
/// short timeout, never spin.
|
||||
public func nextHidOutput(timeoutMs: UInt32 = 0) throws -> HidOutputEvent? {
|
||||
feedbackLock.lock()
|
||||
defer { feedbackLock.unlock() }
|
||||
guard let h = liveHandle() else { throw PunktfunkClientError.closed }
|
||||
|
||||
var out = PunktfunkHidOutput()
|
||||
let rc = punktfunk_connection_next_hidout(h, &out, timeoutMs)
|
||||
switch rc {
|
||||
case statusOK:
|
||||
switch Int32(out.kind) {
|
||||
case PUNKTFUNK_HIDOUT_LED:
|
||||
return .led(pad: out.pad, r: out.r, g: out.g, b: out.b)
|
||||
case PUNKTFUNK_HIDOUT_PLAYER_LEDS:
|
||||
return .playerLEDs(pad: out.pad, bits: out.player_bits)
|
||||
case PUNKTFUNK_HIDOUT_TRIGGER:
|
||||
// The fixed C array imports as a tuple — copy out the valid prefix.
|
||||
let len = Int(min(out.effect_len, UInt8(PUNKTFUNK_HID_EFFECT_MAX)))
|
||||
let effect = withUnsafeBytes(of: out.effect) { Array($0.prefix(len)) }
|
||||
return .triggerEffect(pad: out.pad, which: out.which, effect: effect)
|
||||
default:
|
||||
return nil // unknown kind from a newer host — skip (forward-compatible)
|
||||
}
|
||||
case statusNoFrame:
|
||||
return nil
|
||||
case statusClosed:
|
||||
throw PunktfunkClientError.closed
|
||||
default:
|
||||
throw PunktfunkClientError.status(rc)
|
||||
}
|
||||
}
|
||||
|
||||
/// Video-capability bit: the client can decode a 10-bit (Main10) HEVC stream.
|
||||
public static let videoCap10Bit: UInt8 = UInt8(PUNKTFUNK_VIDEO_CAP_10BIT)
|
||||
/// Video-capability bit: the client can present BT.2020 PQ HDR10 (implies 10-bit).
|
||||
public static let videoCapHDR: UInt8 = UInt8(PUNKTFUNK_VIDEO_CAP_HDR)
|
||||
/// Video-capability bit: the client can decode a full-chroma 4:4:4 HEVC stream (Range
|
||||
/// Extensions). Advertise only when the device can *hardware*-decode it (`Stage444Probe`);
|
||||
/// the host then emits 4:4:4 only if it too opted in. `chromaFormat` reflects the real value.
|
||||
public static let videoCap444: UInt8 = UInt8(PUNKTFUNK_VIDEO_CAP_444)
|
||||
|
||||
/// Codec bits for `videoCodecs` / `preferredCodec` and the value `resolvedCodec` returns.
|
||||
public static let codecH264: UInt8 = UInt8(PUNKTFUNK_CODEC_H264)
|
||||
public static let codecHEVC: UInt8 = UInt8(PUNKTFUNK_CODEC_HEVC)
|
||||
public static let codecAV1: UInt8 = UInt8(PUNKTFUNK_CODEC_AV1)
|
||||
|
||||
/// Static HDR mastering metadata (SMPTE ST.2086 + content light level) the host sent for an HDR
|
||||
/// session. Mirrors the wire/ABI `PunktfunkHdrMeta`; primaries are in ST.2086 **G, B, R** order,
|
||||
/// 1/50000 units; mastering luminance in 0.0001 cd/m²; MaxCLL/MaxFALL in nits.
|
||||
public struct HdrMeta: Sendable, Equatable {
|
||||
public let primariesX: [UInt16] // [green, blue, red]
|
||||
public let primariesY: [UInt16]
|
||||
public let whitePointX: UInt16
|
||||
public let whitePointY: UInt16
|
||||
public let maxMasteringLuminance: UInt32 // 0.0001 cd/m²
|
||||
public let minMasteringLuminance: UInt32 // 0.0001 cd/m²
|
||||
public let maxCLL: UInt16
|
||||
public let maxFALL: UInt16
|
||||
|
||||
/// The 24-byte `mastering_display_colour_volume` payload (big-endian, ST.2086 G,B,R) — pass
|
||||
/// directly to `kCVImageBufferMasteringDisplayColorVolumeKey` or `CAEDRMetadata`'s displayInfo.
|
||||
public func masteringDisplayColorVolume() -> Data {
|
||||
var d = Data()
|
||||
func be16(_ v: UInt16) { d.append(UInt8(v >> 8)); d.append(UInt8(v & 0xFF)) }
|
||||
func be32(_ v: UInt32) {
|
||||
d.append(UInt8((v >> 24) & 0xFF)); d.append(UInt8((v >> 16) & 0xFF))
|
||||
d.append(UInt8((v >> 8) & 0xFF)); d.append(UInt8(v & 0xFF))
|
||||
}
|
||||
for i in 0..<3 { be16(primariesX[i]); be16(primariesY[i]) } // G, B, R
|
||||
be16(whitePointX); be16(whitePointY)
|
||||
be32(maxMasteringLuminance); be32(minMasteringLuminance)
|
||||
return d
|
||||
}
|
||||
|
||||
/// The 4-byte `content_light_level_info` payload (big-endian: MaxCLL, MaxFALL) — for
|
||||
/// `kCVImageBufferContentLightLevelInfoKey` or `CAEDRMetadata`'s contentInfo.
|
||||
public func contentLightLevelInfo() -> Data {
|
||||
var d = Data()
|
||||
func be16(_ v: UInt16) { d.append(UInt8(v >> 8)); d.append(UInt8(v & 0xFF)) }
|
||||
be16(maxCLL); be16(maxFALL)
|
||||
return d
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the next static HDR metadata update; nil on timeout, throws `.closed` once the session
|
||||
/// ended. Drain from the feedback thread alongside `nextRumble`/`nextHidOutput`. Nothing arrives
|
||||
/// unless `isHDR` — poll with a short timeout, never spin.
|
||||
public func nextHdrMeta(timeoutMs: UInt32 = 0) throws -> HdrMeta? {
|
||||
feedbackLock.lock()
|
||||
defer { feedbackLock.unlock() }
|
||||
guard let h = liveHandle() else { throw PunktfunkClientError.closed }
|
||||
|
||||
var out = PunktfunkHdrMeta()
|
||||
let rc = punktfunk_connection_next_hdr_meta(h, &out, timeoutMs)
|
||||
switch rc {
|
||||
case statusOK:
|
||||
// The fixed C `uint16_t[3]` arrays import as tuples — copy them out.
|
||||
let px = withUnsafeBytes(of: out.display_primaries_x) {
|
||||
Array($0.bindMemory(to: UInt16.self))
|
||||
}
|
||||
let py = withUnsafeBytes(of: out.display_primaries_y) {
|
||||
Array($0.bindMemory(to: UInt16.self))
|
||||
}
|
||||
return HdrMeta(
|
||||
primariesX: px, primariesY: py,
|
||||
whitePointX: out.white_point_x, whitePointY: out.white_point_y,
|
||||
maxMasteringLuminance: out.max_display_mastering_luminance,
|
||||
minMasteringLuminance: out.min_display_mastering_luminance,
|
||||
maxCLL: out.max_cll, maxFALL: out.max_fall)
|
||||
case statusNoFrame:
|
||||
return nil
|
||||
case statusClosed:
|
||||
throw PunktfunkClientError.closed
|
||||
default:
|
||||
throw PunktfunkClientError.status(rc)
|
||||
}
|
||||
}
|
||||
|
||||
/// Send one input event (delivered to the host as a QUIC datagram). Thread-safe;
|
||||
/// silently dropped after close.
|
||||
public func send(_ event: PunktfunkInputEvent) {
|
||||
var ev = event
|
||||
abiLock.lock()
|
||||
defer { abiLock.unlock() }
|
||||
guard let h = handle, !closeRequested else { return }
|
||||
_ = punktfunk_connection_send_input(h, &ev)
|
||||
}
|
||||
|
||||
/// Close the connection and free the handle. Safe from any thread, idempotent; waits
|
||||
/// for in-flight pulls (≤ their timeouts) before tearing down.
|
||||
public func close() {
|
||||
abiLock.lock()
|
||||
closeRequested = true
|
||||
abiLock.unlock()
|
||||
pumpLock.lock() // pullers exit at their next poll boundary, releasing these
|
||||
audioLock.lock()
|
||||
feedbackLock.lock()
|
||||
abiLock.lock()
|
||||
let h = handle
|
||||
handle = nil
|
||||
abiLock.unlock()
|
||||
feedbackLock.unlock()
|
||||
audioLock.unlock()
|
||||
pumpLock.unlock()
|
||||
if let h {
|
||||
punktfunk_connection_close(h) // joins the connection's internal Rust threads
|
||||
}
|
||||
}
|
||||
|
||||
/// Send one Opus mic frame (48 kHz) to the host, where it feeds a virtual
|
||||
/// microphone source the host's apps can record. Non-blocking enqueue, safe
|
||||
/// alongside the pull threads (same discipline as `send`). `seq`/`ptsNs` are the
|
||||
/// caller's own counters (host uses them only for diagnostics); empty `opus` is a
|
||||
/// DTX silence frame.
|
||||
public func sendMic(_ opus: Data, seq: UInt32, ptsNs: UInt64) {
|
||||
abiLock.lock()
|
||||
defer { abiLock.unlock() }
|
||||
guard let h = handle, !closeRequested else { return }
|
||||
opus.withUnsafeBytes { p in
|
||||
_ = punktfunk_connection_send_mic(
|
||||
h, p.bindMemory(to: UInt8.self).baseAddress, UInt(opus.count), seq, ptsNs)
|
||||
}
|
||||
}
|
||||
|
||||
/// Send one DualSense touchpad contact to the host's virtual pad (rich-input plane).
|
||||
/// `x`/`y` are normalized 0...65535 across the touchpad, origin top-left, +y down.
|
||||
/// Non-blocking enqueue (same discipline as `send`); pointless on non-DualSense
|
||||
/// sessions — the host ignores it there.
|
||||
public func sendTouchpad(pad: UInt8 = 0, finger: UInt8, active: Bool, x: UInt16, y: UInt16) {
|
||||
abiLock.lock()
|
||||
defer { abiLock.unlock() }
|
||||
guard let h = handle, !closeRequested else { return }
|
||||
var rich = PunktfunkRichInput()
|
||||
rich.kind = UInt8(PUNKTFUNK_RICH_TOUCHPAD)
|
||||
rich.pad = pad
|
||||
rich.finger = finger
|
||||
rich.active = active ? 1 : 0
|
||||
rich.x = x
|
||||
rich.y = y
|
||||
_ = punktfunk_connection_send_rich_input(h, &rich)
|
||||
}
|
||||
|
||||
/// Send one DualSense motion sample to the host's virtual pad (rich-input plane). The
|
||||
/// values are raw DualSense sensor units, written verbatim into the virtual pad's input
|
||||
/// report — convert with `GamepadCapture`'s scale constants (gyro: rad/s → 20 LSB per
|
||||
/// deg/s; accel: g → 10000 LSB per g).
|
||||
public func sendMotion(
|
||||
pad: UInt8 = 0,
|
||||
gyro: (Int16, Int16, Int16), accel: (Int16, Int16, Int16)
|
||||
) {
|
||||
abiLock.lock()
|
||||
defer { abiLock.unlock() }
|
||||
guard let h = handle, !closeRequested else { return }
|
||||
var rich = PunktfunkRichInput()
|
||||
rich.kind = UInt8(PUNKTFUNK_RICH_MOTION)
|
||||
rich.pad = pad
|
||||
rich.gyro = gyro
|
||||
rich.accel = accel
|
||||
_ = punktfunk_connection_send_rich_input(h, &rich)
|
||||
}
|
||||
|
||||
deinit { close() }
|
||||
|
||||
/// Snapshot the handle unless close is pending (callers hold their plane lock).
|
||||
private func liveHandle() -> OpaquePointer? {
|
||||
abiLock.lock()
|
||||
defer { abiLock.unlock() }
|
||||
return closeRequested ? nil : handle
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user