Files
punktfunk/clients/apple/Sources/PunktfunkKit/Connection/LibraryClient.swift
T
enricobuehler 7798401f06
ci / rust-arm64 (pull_request) Successful in 1m36s
ci / bun-nix (pull_request) Successful in 24s
ci / web (pull_request) Successful in 1m16s
apple / swift (pull_request) Successful in 1m38s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m12s
ci / rust (pull_request) Successful in 13m3s
perf(apple): cache posters on disk and pool the mgmt connections
Moving the management API onto Network.framework left one request per
connection, so a library grid paid a TLS handshake per poster where the pooled
URLSession had shared one. And the Apple client -- unlike Windows -- never
cached art at all, so it re-fetched every poster on every visit.

ArtCache: a size- and age-bounded blob cache in the CACHES directory (every byte
is re-derivable from the host, so the system is welcome to evict it). Keyed by
the SHA-256 of the absolute URL, so host-proxy paths and store CDN URLs share
one cache without colliding. Reads touch the entry, so eviction is by last USE,
not last write. Empty bodies and data: URLs are refused -- neither is worth a
file. Defaults: 128 MB, 30 days.

Connection pooling: MgmtConnectionPool keeps up to four keep-alive connections
per host and makes further callers wait rather than opening more, which is the
part that matters -- a grid can ask for dozens of posters at once. A connection
the host dropped since we last used it is indistinguishable from a live one
until we write, so a REUSED connection that fails is retried once on a fresh
one; a fresh failure is a real failure.

Keep-alive means a response can no longer be delimited by the peer hanging up,
so HTTPResponseParser.messageLength finds the end from the framing itself --
Content-Length or the chunked terminal chunk plus trailers. Getting that wrong
would truncate a response or bleed one into the next, silently, so it carries
the bulk of the new tests. A connection with bytes left over after a response is
dropped rather than reused: we never pipeline, so anything trailing means we are
out of sync.

LibraryView closes the loader's pooled connections on disappear instead of
leaving sockets open on a screen the user has left.

16 new tests: message framing (both encodings, partial reads, back-to-back
responses, close detection) and the cache (binary round trip, key separation,
refusals, expiry, LRU eviction).
2026-08-08 01:09:25 +02:00

289 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
// `punktfunkDefaultMgmtPort` (and StoredHost/DefaultsKey) now live in PunktfunkShared so the
// dependency-free widget extension can share them; PunktfunkKit re-exports the module.
import PunktfunkShared
/// 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" | "lutris" | "heroic" | "epic" | "gog" | "xbox"
public var title: String
public var art: Artwork
public var launch: LaunchSpec?
/// `"game"` (the default, and what an older host omits) or `"launcher"` — an entry that opens
/// the launcher itself (Steam Big Picture, Heroic) rather than a title. Deliberately a plain
/// optional String: the host owns the vocabulary, and an unknown future value must never fail
/// the whole library decode. Anything that isn't `"launcher"` is a game (design D4).
public var role: String?
public var isCustom: Bool { store == "custom" }
/// Whether this entry opens a launcher rather than a game.
public var isLauncher: Bool { role == "launcher" }
/// Display name for the store badge — the same table the Rust clients use
/// (`pf-console-ui::library::store_label`). Before this existed the badge said "Steam" for
/// every non-custom entry, which a Lutris or GOG title made a lie.
public var storeLabel: String {
switch store {
case "steam": return "Steam"
case "custom": return "Custom"
case "heroic": return "Heroic"
case "lutris": return "Lutris"
case "epic": return "Epic"
case "gog": return "GOG"
case "xbox": return "Xbox"
default: return "Game"
}
}
}
public extension Array where Element == GameEntry {
/// Design D4: launcher entries lead the shelf, and the host's title order survives within each
/// group. Applied once where the library is fetched, so no individual view has to remember
/// the rule — and a library without launcher entries comes back untouched.
var launchersFirst: [GameEntry] {
let launchers = filter(\.isLauncher)
return launchers.isEmpty ? self : launchers + filter { !$0.isLauncher }
}
}
/// Errors surfaced to the UI so it can guide setup (the common case is "not paired yet").
public enum LibraryError: LocalizedError {
case unauthorized
/// The host's certificate didn't hash to the fingerprint pinned at pairing — an impostor, or
/// a host reinstalled/re-keyed since. Distinct from `unreachable` because the remedy is
/// completely different: re-pair, don't go hunting the network.
case pinMismatch
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 .pinMismatch:
return "The host's certificate doesn't match the one this device paired with. "
+ "If the host was reinstalled, forget it here and pair again."
case .http(let code):
return "The management API returned HTTP \(code)."
case .unreachable(let why):
// The library rides a DIFFERENT port than the stream (the management API, 47990 by
// default; the stream is QUIC on 9777), so it can fail while streaming to the same
// host works perfectly — say that first, because the opposite assumption has sent
// more than one person hunting the wrong layer. Opening that URL in a browser is the
// fastest way to tell "port unreachable" apart from anything client-side.
return "Couldn't reach the host's management API: \(why). The library uses a "
+ "different port than the stream (47990 by default), so streaming can work "
+ "while this doesn't. Check that port is reachable from this device, and that "
+ "the host isn't pinned to `--mgmt-bind 127.0.0.1`, which serves it to the "
+ "host itself only."
}
}
}
/// 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 base = URL(string: "\(baseURL(address: address, port: port))/api/v1/library")
else { throw LibraryError.unreachable("invalid host address") }
let identity = try clientIdentity(certPEM: certPEM, keyPEM: keyPEM)
let response = try await send(
path: "/api/v1/library", address: address, port: port,
identity: identity, hostFingerprint: hostFingerprint)
switch response.status {
case 200:
var games = try JSONDecoder().decode([GameEntry].self, from: response.body)
// Steam art 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.
for i in games.indices {
games[i].art = games[i].art.resolved(against: base)
}
return games
// 403 joins 401 here: both are the host declining this certificate, and the remedy the
// user needs is the same one.
case 401, 403:
throw LibraryError.unauthorized
default:
throw LibraryError.http(response.status)
}
}
/// `https://addr:port`, IPv6 literals bracketed — the mirror of the Rust client's `base_url`.
static func baseURL(address: String, port: UInt16) -> String {
let bare = address.hasPrefix("[") && address.hasSuffix("]")
? String(address.dropFirst().dropLast()) : address
return bare.contains(":") ? "https://[\(bare)]:\(port)" : "https://\(bare):\(port)"
}
/// Build the paired identity, restating any keychain failure in the UI's vocabulary.
static func clientIdentity(certPEM: String, keyPEM: String) throws -> SecIdentity {
do {
return try ClientTLS.makeIdentity(certPEM: certPEM, keyPEM: keyPEM)
} catch {
throw LibraryError.unreachable(
(error as? LocalizedError)?.errorDescription ?? error.localizedDescription)
}
}
/// One GET against the host, with transport failures mapped onto `LibraryError`.
static func send(
path: String, address: String, port: UInt16,
identity: SecIdentity, hostFingerprint: Data?
) async throws -> HTTPResponse {
do {
return try await MgmtTransport.get(
host: address, port: port, path: path,
identity: identity, pinnedHostFingerprint: hostFingerprint)
} catch MgmtTransportError.pinMismatch {
throw LibraryError.pinMismatch
} catch MgmtTransportError.timedOut {
throw LibraryError.unreachable("timed out")
} catch let error as MgmtTransportError {
throw LibraryError.unreachable(String(describing: error))
} catch {
throw LibraryError.unreachable(error.localizedDescription)
}
}
}
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
}
}
/// Loads cover art for the library UI, routing each URL to the transport that suits its origin.
///
/// A `GameEntry`'s art candidates mix two very different things: the host's own art proxy
/// (`/api/v1/library/art/...`, resolved to absolute URLs against this host) and public store CDN
/// URLs carried verbatim on custom/GOG/Heroic entries. Host URLs go over [`MgmtTransport`] with
/// the paired identity and the pinned fingerprint — outside the URL loading system, so App
/// Transport Security can stay ON app-wide. Every other origin keeps ordinary `URLSession` with
/// full system trust evaluation and no client certificate, which is exactly what it should get.
///
/// Posters are cached on disk (`ArtCache`), so a second visit to a library costs no network at
/// all — and the connections behind a first visit are pooled and kept alive rather than paying a
/// TLS handshake per tile.
///
/// Built once per library screen and reused across a whole grid's worth of posters.
public final class LibraryArtLoader: @unchecked Sendable {
private let address: String
private let port: UInt16
private let identity: SecIdentity
private let hostFingerprint: Data?
/// Third-party origins only. No delegate: these are ordinary public HTTPS URLs and get the
/// system's normal certificate validation.
private let cdn = URLSession(configuration: .default)
/// nil when the caches directory is unavailable — then we simply always fetch.
private let cache = ArtCache.standard()
public init(
address: String,
port: UInt16 = punktfunkDefaultMgmtPort,
certPEM: String,
keyPEM: String,
hostFingerprint: Data?
) throws {
self.address = address
self.port = port
self.identity = try LibraryClient.clientIdentity(certPEM: certPEM, keyPEM: keyPEM)
self.hostFingerprint = hostFingerprint
}
public func data(for url: URL) async throws -> Data {
if let cache, let cached = await cache.data(for: url) { return cached }
let fetched = try await fetch(url)
if let cache { await cache.store(fetched, for: url) }
return fetched
}
/// Release this host's pooled connections — call when the library screen goes away, so we
/// don't sit on open TLS sockets the user is finished with.
public func close() async {
await MgmtConnectionPool.shared.closeAll(
matching: "\(MgmtTransport.unbracketed(address)):\(port):")
}
private func fetch(_ url: URL) async throws -> Data {
guard isHostOrigin(url) else { return try await cdn.data(from: url).0 }
var path = url.path.isEmpty ? "/" : url.path
if let query = url.query { path += "?\(query)" }
let response = try await LibraryClient.send(
path: path, address: address, port: port,
identity: identity, hostFingerprint: hostFingerprint)
guard response.status == 200 else { throw LibraryError.http(response.status) }
return response.body
}
/// Does this URL point at the host's own art proxy? Compared on host + port rather than a
/// string prefix, so a differently-spelled but equivalent URL still takes the pinned path.
private func isHostOrigin(_ url: URL) -> Bool {
guard let host = url.host else { return false }
let bare = address.hasPrefix("[") && address.hasSuffix("]")
? String(address.dropFirst().dropLast()) : address
let scheme = url.scheme?.lowercased()
return host.caseInsensitiveCompare(bare) == .orderedSame
&& (url.port ?? (scheme == "http" ? 80 : 443)) == Int(port)
}
}