Files
punktfunk/clients/apple/Sources/PunktfunkKit/Connection/LibraryClient.swift
T
enricobuehler f62a48d4a9
apple / swift (pull_request) Successful in 1m42s
apple / screenshots (pull_request) Skipped
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 3m12s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m32s
ci / rust-arm64 (pull_request) Successful in 1m52s
ci / web (pull_request) Successful in 1m6s
ci / docs-site (pull_request) Successful in 1m15s
ci / bun-nix (pull_request) Successful in 18s
android / android (pull_request) Successful in 3m56s
ci / rust (pull_request) Successful in 5m40s
feat(library): launcher tiles get their launcher's logo — a brand token on the wire, the vector in every client
A launcher tile (role: "launcher", design D4) shipped no art on purpose:
a launcher's own icon is square, every client cover-crops a 2:3 poster,
and the crop turns a mark into a strip. So the tiles were the launcher's
name on a flat accent face — legible, and the blandest thing in the grid.

Entries now carry an optional `icon`: the NAME of a brand mark, never
image bytes and never a URL. `[a-z][a-z0-9-]{0,31}`, shape-validated by
the host on every lane (a client interpolates the value into a resource
name or an asset lookup, so the guard belongs upstream of all of them,
and each client re-checks rather than trusting the peer).

A token rather than art because the alternative is closed by
construction, and deliberately: the art proxy serves what the bytes ARE
(sniff_image_type) and SVG is not on that list — it is script-capable
XML and the console renders library art in a browser. Widening that
sniff would trade a rendering nicety for a stored-XSS surface. Naming
the mark keeps the refusal intact, keeps the glyph vector at whatever
size a tile happens to be, lets it take the tile's ink, and adds nothing
to a reconcile payload that is already body-limited. The cost is that a
third-party plugin cannot ship a mark no client bundles; its tile falls
back to the launcher's name, exactly as before, and the fix is a PR
adding the master.

assets/launcher-icons/ holds seven monochrome masters with per-mark
provenance and licensing (Simple Icons CC0: lutris, heroic, epic, gog;
Font Awesome CC BY: steam, xbox; Playnite's own logo, MIT). steam is
generated FROM assets/os-icons/steam.svg so the SteamOS host badge and
the Steam launcher tile can never drift.

scripts/gen-launcher-icons.sh bakes the three derivatives that cannot
consume a master (GTK symbolic SVG, Windows PNG, Apple template PDF)
and — unlike gen-os-icons.sh, which prints path data for a human to
paste — GENERATES the three inline registries (web console, Android
ImageVector, pf-console-ui Skia). Three clients x seven paths of up to
3 kB is a transcription error waiting to happen, and a mangled character
is a silently wrong logo rather than a build failure. The generated Rust
goes through rustfmt, since `cargo fmt --all --check` is a CI gate and a
generated file that fails it would fail every regeneration.

All six renderers draw the mark CONTAINED, never cover-cropped: the
masters' viewports are not square (steam 496x512, playnite 1024x1024)
and filling a 2:3 frame would reproduce the strip this exists to avoid.
Every one keeps its old fallback for a token it has no art for.

Epic, GOG and Xbox marks ship dormant. Those plugins' launcher switches
are off by default and emit nothing, because the host has no verified
launcher_ui activation for them yet — shipping the art now keeps turning
one on the one-line plugin change those plugins promise, instead of also
needing a release of all six clients.

api/openapi.json and the SDK are regenerated (the spec's version field
was stale at 0.25.0 and now reads 0.26.0, which is the crate's actual
version — an unrelated line that regeneration necessarily corrects).

Verified: host cargo check, clippy -D warnings across pf-client-core /
pf-console-ui / punktfunk-client-session / punktfunk-client-linux, plain
build, pf-console-ui tests (77, including a new one asserting all seven
masters parse under Skia and one asserting the letterbox stays inside
its box), pf-client-core tests (188), cargo fmt --all --check, Apple
swift build, Android compileDebugKotlin, web tsc + vite build,
plugin-kit tsc, biome. The Windows client is NOT compile-verified — it
cannot be built from a Mac (scripts/xcheck.sh covers only the capture
stack by design) and CI does not build it either; its tile change needs
a real box before it ships.
2026-08-10 23:26:47 +02:00

305 lines
15 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?
/// The token for this entry's brand mark (`"steam"`, `"heroic"`) — never art, never a URL.
/// `nil` on every older host and on every ordinary title. See `launcherIconImage`.
public var icon: String?
public var isCustom: Bool { store == "custom" }
/// Whether this entry opens a launcher rather than a game.
public var isLauncher: Bool { role == "launcher" }
/// The brand-icon token, re-validated rather than taken on trust.
///
/// The host checks the shape on the way in, so this can only fire for a host older than that
/// check or one that isn't ours. The value reaches `Image(named:)`, and "the peer promised" is
/// not the standard a name lookup deserves.
public var iconToken: String? {
guard let t = icon, !t.isEmpty, t.count <= 32,
let first = t.first, first.isASCII, first.isLowercase,
t.allSatisfy({ $0.isASCII && ($0.isLowercase || $0.isNumber || $0 == "-") })
else { return nil }
return t
}
/// 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)
}
}