The host has been able to describe a launcher entry since M2 — `role: "launcher"`,
the `steam_ui` and `launcher_ui` kinds — and the web console has grouped them into
their own rail since M4. No other client ever looked. `pf-client-core` decoded
`role` into an `is_launcher()` helper with zero call sites, and the shared console
model dropped the field entirely on its way to the renderer.
So a launcher tile arrived everywhere else as an ordinary game with no cover art:
indistinguishable from a title whose poster failed to load, sorted into the middle
of the alphabet, and captioned "Play".
One contract, implemented in each client's own idiom:
* launchers never interleave with titles — they lead, and each group keeps the
host's title order
* grid surfaces get a labelled section; a coverflow keeps its single carousel and
names the group the cursor is in, changing as it crosses the boundary. A second
focus rail would mean a new up/down nav model in three renderers for two or
three tiles
* an art-less launcher gets an accent face naming its launcher, not a title
monogram on the neutral one — "opens Steam", not "a cover that didn't load"
* anything that is not `"launcher"` is a game, and a host that omits the field
renders exactly as before (design D4's intended degradation)
* launching is unchanged: the client sends an id, the host resolves the recipe
The grouping is enforced once per client stack rather than per screen. In the
console UI it is an invariant of `LibraryShared::set_games`, so the cursor
arithmetic, the art pump and every future consumer inherit it; on Apple and Android
it is applied where the library is fetched/parsed.
Fixed in passing: the Apple and Android store badges were hard-coded
`isCustom ? "Custom" : "Steam"`, so every Lutris, GOG, Heroic, Epic and Xbox title
was labelled "Steam". Both now carry the same store table the Rust clients use.
The CLI's `--library` gains a fourth column (`game`/`launcher`), appended rather
than folded into an existing one so anything reading the first three is untouched.
Gates: punktfunk-host 436 passed / 0 failed and pf-console-ui 49 passed / 0 failed
on .21 (three new tests), workspace clippy -D warnings and cargo fmt --check clean
there; `swift build` of the full PunktfunkClient and `:app:compileDebugKotlin` clean
on macOS; `cargo check` + `clippy -D warnings` for the Windows client on .173.
Still unproven on hardware: no launcher tile has been clicked on a real host — that
needs the plugins published, which needs this branch's base merged first.
202 lines
9.4 KiB
Swift
202 lines
9.4 KiB
Swift
// 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
|
||
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)."
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
}
|