feat(apple/library): experimental game-library browser (flagged off)
ci / web (push) Successful in 31s
ci / docs-site (push) Successful in 31s
apple / swift (push) Successful in 1m15s
ci / rust (push) Successful in 2m4s
ci / bench (push) Successful in 1m38s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
deb / build-publish (push) Successful in 2m23s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 4m55s
docker / deploy-docs (push) Successful in 17s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 4m28s

Plan step 3 — the Apple client surfaces the host's game library, behind a feature
flag (`DefaultsKey.libraryEnabled`, default OFF). Browsing only; launching a chosen
title is step 4.

- PunktfunkKit `LibraryClient`: Codable GameEntry/Artwork/LaunchSpec mirroring
  crates/punktfunk-host/src/library.rs, and an async fetch of GET /api/v1/library
  with a bearer token. Typed LibraryError guides setup (the common case is "needs a
  --mgmt-token"). `Artwork.posterCandidates` = portrait → header → hero.
- `LibraryView`: cross-platform poster grid (LazyVGrid, AsyncImage that walks the art
  candidates past load failures to a text placeholder), a store badge, and an inline
  Connection form (mgmt port + token) that surfaces when the API is unreachable / 401
  / no token set. Read-only.
- StoredHost gains `mgmtPort`/`mgmtToken` (the mgmt API is a distinct port from the
  data plane and needs a token off-loopback). Both OPTIONAL — synthesized Decodable
  ignores property defaults but treats a missing Optional as nil, so older saved
  hosts decode unchanged (a defaulted non-optional would wipe the list). HostStore.setMgmt.
- Entry point: a flag-gated "Browse Library…" host-card context action → LibraryView
  (sheet on macOS/iOS, pushed on tvOS), mirroring the pair/speed-test plumbing. Plus a
  Settings "Experimental" toggle.

Can't compile Swift on the Linux dev box; CI (apple.yml: swift build + swift test on
the mac mini) verifies the macOS path. Added LibraryClientTests (decode + art order)
for `swift test`. iOS/tvOS-only branches mirror existing patterns. Live-verify on the
Mac pending.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-14 14:28:16 +00:00
parent 6136ba4c72
commit 1b610d6bf5
9 changed files with 476 additions and 4 deletions
@@ -28,6 +28,7 @@ struct ContentView: View {
@State private var showAddHost = false
@State private var pairingTarget: StoredHost?
@State private var speedTestTarget: StoredHost?
@State private var libraryTarget: StoredHost?
#if !os(macOS)
@State private var showSettings = false
#endif
@@ -67,6 +68,9 @@ struct ContentView: View {
.sheet(item: $speedTestTarget) { host in
SpeedTestSheet(host: host)
}
.sheet(item: $libraryTarget) { host in
NavigationStack { LibraryView(store: store, host: host) }
}
#endif
}
@@ -75,13 +79,14 @@ struct ContentView: View {
HomeView(
store: store, model: model, discovery: discovery,
showAddHost: $showAddHost, pairingTarget: $pairingTarget,
speedTestTarget: $speedTestTarget,
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
connect: connect, connectDiscovered: connectDiscovered, onPaired: handlePaired)
#else
HomeView(
store: store, model: model, discovery: discovery,
showAddHost: $showAddHost, pairingTarget: $pairingTarget,
speedTestTarget: $speedTestTarget, showSettings: $showSettings,
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
showSettings: $showSettings,
connect: connect, connectDiscovered: connectDiscovered, onPaired: handlePaired)
#endif
}
@@ -16,6 +16,7 @@ struct HomeView: View {
@Binding var showAddHost: Bool
@Binding var pairingTarget: StoredHost?
@Binding var speedTestTarget: StoredHost?
@Binding var libraryTarget: StoredHost?
#if !os(macOS)
@Binding var showSettings: Bool
#endif
@@ -23,6 +24,8 @@ struct HomeView: View {
let connectDiscovered: (DiscoveredHost) -> Void
/// Pairing succeeded (tvOS PairSheet route) pin + connect (ContentView guards staleness).
let onPaired: (StoredHost, Data) -> Void
/// Experimental game-library browser (gated) the host-card "Browse Library" action.
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
var body: some View {
NavigationStack {
@@ -81,6 +84,9 @@ struct HomeView: View {
.navigationDestination(item: $speedTestTarget) { host in
SpeedTestSheet(host: host)
}
.navigationDestination(item: $libraryTarget) { host in
LibraryView(store: store, host: host)
}
#endif
#if !os(tvOS)
.toolbar {
@@ -146,7 +152,8 @@ struct HomeView: View {
// MARK: - Cards
private func hostCard(_ host: StoredHost) -> some View {
HostCardView(
let onBrowseLibrary: (() -> Void)? = libraryEnabled ? { libraryTarget = host } : nil
return HostCardView(
host: host,
isOnline: isOnline(host),
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
@@ -156,7 +163,8 @@ struct HomeView: View {
onPair: { if !model.isBusy { pairingTarget = host } },
onSpeedTest: { if !model.isBusy { speedTestTarget = host } },
onForget: { store.forgetIdentity(host) },
onRemove: { store.remove(host) })
onRemove: { store.remove(host) },
onBrowseLibrary: onBrowseLibrary)
}
private var discoveredSection: some View {
@@ -35,6 +35,8 @@ struct HostCardView: View {
let onSpeedTest: () -> Void
let onForget: () -> Void
let onRemove: () -> Void
/// Open the experimental library browser nil (no menu item) unless the feature flag is on.
var onBrowseLibrary: (() -> Void)? = nil
var body: some View {
let m = CardMetrics.current
@@ -104,6 +106,9 @@ struct HostCardView: View {
.contextMenu {
Button("Pair with PIN…", action: onPair)
Button("Test Network Speed…", action: onSpeedTest)
if let onBrowseLibrary {
Button("Browse Library…", action: onBrowseLibrary)
}
if host.pinnedSHA256 != nil {
Button("Forget Identity", action: onForget)
}
@@ -21,8 +21,17 @@ struct StoredHost: Identifiable, Codable, Hashable {
var pinnedSHA256: Data?
/// Last time a streaming session actually started (nil until the first one).
var lastConnected: Date?
/// Management-API port for the experimental library browser (distinct from the data-plane
/// `port`). Optional (NOT a defaulted non-optional) so older saved hosts whose JSON lacks
/// this key still decode: synthesized Decodable ignores property defaults but treats a
/// missing Optional as nil. Resolve via `effectiveMgmtPort`.
var mgmtPort: UInt16?
/// Bearer token for the management API (the host's `--mgmt-token`). Required for any
/// non-loopback mgmt bind; nil until the user enters it.
var mgmtToken: String?
var displayName: String { name.isEmpty ? address : name }
var effectiveMgmtPort: UInt16 { mgmtPort ?? punktfunkDefaultMgmtPort }
}
extension StoredHost {
@@ -87,6 +96,15 @@ final class HostStore: ObservableObject {
hosts[i].pinnedSHA256 = nil
}
/// Persist the management-API endpoint for the (experimental) library browser. An empty
/// token is stored as nil (no credential).
func setMgmt(_ hostID: UUID, port: UInt16, token: String) {
guard let i = hosts.firstIndex(where: { $0.id == hostID }) else { return }
hosts[i].mgmtPort = port
let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
hosts[i].mgmtToken = trimmed.isEmpty ? nil : trimmed
}
private func persist() {
if let data = try? JSONEncoder().encode(hosts) {
UserDefaults.standard.set(data, forKey: Self.key)
@@ -0,0 +1,258 @@
// Experimental game-library browser (plan step 3, gated behind DefaultsKey.libraryEnabled).
// Renders a poster grid of the host's library fetched over the management API. Read-only:
// launching a chosen title is a later step. Reached from a host card's "Browse Library"
// context-menu action, which only appears when the feature flag is on.
import PunktfunkKit
import SwiftUI
struct LibraryView: View {
@ObservedObject var store: HostStore
let host: StoredHost
@State private var games: [GameEntry] = []
@State private var loading = false
@State private var errorText: String?
@State private var showConfig = false
// Connection form state, seeded from the saved host.
@State private var portText: String = ""
@State private var tokenText: String = ""
var body: some View {
content
.navigationTitle("\(host.displayName) — Library")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
#if os(macOS)
ToolbarItemGroup {
connectionButton
reloadButton
}
#else
ToolbarItem(placement: .primaryAction) { reloadButton }
ToolbarItem(placement: .cancellationAction) { connectionButton }
#endif
}
.sheet(isPresented: $showConfig) { connectionSheet }
.task {
seedForm()
await load()
}
}
@ViewBuilder private var content: some View {
if loading && games.isEmpty {
ProgressView("Loading library…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let errorText, games.isEmpty {
errorState(errorText)
} else if games.isEmpty {
emptyState
} else {
grid
}
}
private var grid: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: 18) {
ForEach(games) { game in
GameCard(game: game)
}
}
.padding()
}
}
private var columns: [GridItem] {
#if os(tvOS)
let minW: CGFloat = 220
#else
let minW: CGFloat = 130
#endif
return [GridItem(.adaptive(minimum: minW), spacing: 18)]
}
private func errorState(_ text: String) -> some View {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle")
.font(.largeTitle)
.foregroundStyle(.secondary)
Text(text)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
.frame(maxWidth: 420)
Button("Connection Settings…") { showConfig = true }
.buttonStyle(.borderedProminent)
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var emptyState: some View {
VStack(spacing: 12) {
Image(systemName: "square.grid.2x2")
.font(.largeTitle)
.foregroundStyle(.secondary)
Text("No games found on this host.")
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var reloadButton: some View {
Button { Task { await load() } } label: {
Label("Reload", systemImage: "arrow.clockwise")
}
.disabled(loading)
}
private var connectionButton: some View {
Button { showConfig = true } label: {
Label("Connection", systemImage: "network")
}
}
private var connectionSheet: some View {
NavigationStack {
Form {
Section {
LabeledContent("Host") { Text(host.address) }
TextField("Management port", text: $portText)
#if !os(macOS)
.keyboardType(.numberPad)
#endif
TextField("Management token", text: $tokenText)
.autocorrectionDisabled(true)
#if !os(macOS)
.textInputAutocapitalization(.never)
#endif
} header: {
Text("Management API")
} footer: {
Text("The host must expose its management API on the LAN: "
+ "`serve --mgmt-bind 0.0.0.0 --mgmt-token <token>`. The default port "
+ "is \(punktfunkDefaultMgmtPort). Enter the same token here.")
}
}
.navigationTitle("Library Connection")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
let port = UInt16(portText) ?? punktfunkDefaultMgmtPort
store.setMgmt(host.id, port: port, token: tokenText)
showConfig = false
Task { await load() }
}
}
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { showConfig = false }
}
}
}
}
private func seedForm() {
// Always reflect the latest saved values (the host snapshot may predate a setMgmt).
let current = store.hosts.first { $0.id == host.id } ?? host
portText = String(current.effectiveMgmtPort)
tokenText = current.mgmtToken ?? ""
}
private func load() async {
loading = true
errorText = nil
let current = store.hosts.first { $0.id == host.id } ?? host
do {
games = try await LibraryClient.fetch(
address: current.address,
port: current.effectiveMgmtPort,
token: current.mgmtToken)
} catch {
games = []
if let libError = error as? LibraryError {
errorText = libError.errorDescription
// Token rejected drop the user straight into the connection form.
if case .unauthorized = libError { showConfig = true }
} else {
errorText = error.localizedDescription
}
// No credential entered yet also straight to setup.
if current.mgmtToken == nil { showConfig = true }
}
loading = false
}
}
/// One poster tile. Steam vs custom is marked with a badge; the art walks the candidate URLs
/// (portrait header hero) and finally a text placeholder.
private struct GameCard: View {
let game: GameEntry
var body: some View {
VStack(alignment: .leading, spacing: 6) {
PosterImage(candidates: game.art.posterCandidates, title: game.title)
.aspectRatio(2.0 / 3.0, contentMode: .fit)
.frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay(alignment: .topLeading) { storeBadge }
Text(game.title)
.font(.caption)
.lineLimit(2)
.foregroundStyle(.secondary)
}
}
private var storeBadge: some View {
Text(game.isCustom ? "Custom" : "Steam")
.font(.caption2.weight(.semibold))
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(.ultraThinMaterial, in: Capsule())
.padding(6)
}
}
/// Sequentially tries cover-art URLs, advancing past any that fail to load, then a placeholder.
private struct PosterImage: View {
let candidates: [URL]
let title: String
@State private var index = 0
var body: some View {
if index < candidates.count {
AsyncImage(url: candidates[index]) { phase in
switch phase {
case .success(let image):
image.resizable().scaledToFill()
case .failure:
// Advance to the next candidate on the next render pass.
Color.clear.onAppear { index += 1 }
case .empty:
ZStack { placeholder; ProgressView() }
@unknown default:
placeholder
}
}
.id(index) // recreate AsyncImage so it loads the newly-selected URL
} else {
placeholder
}
}
private var placeholder: some View {
ZStack {
Rectangle().fill(.quaternary)
Text(title)
.font(.headline)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
.padding(8)
}
}
}
@@ -19,6 +19,7 @@ struct SettingsView: View {
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
@AppStorage(DefaultsKey.presenter) private var presenter = "stage1"
@AppStorage(DefaultsKey.cursorMode) private var cursorMode = "auto"
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
@AppStorage(DefaultsKey.micEnabled) private var micEnabled = true
@ObservedObject private var gamepads = GamepadManager.shared
#if os(macOS)
@@ -405,6 +406,18 @@ struct SettingsView: View {
.font(.caption)
.foregroundStyle(.secondary)
}
Section {
Toggle("Show game library", isOn: $libraryEnabled)
} header: {
Text("Experimental")
} footer: {
Text("Adds a “Browse Library…” action to each host that lists its games "
+ "(Steam + custom) via the host's management API. The host must expose that "
+ "API on the LAN with a token (serve --mgmt-bind 0.0.0.0 --mgmt-token …). "
+ "Browsing only for now — launching a title comes later.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section {
if gamepads.controllers.isEmpty {
Text("No controllers detected")
@@ -22,4 +22,6 @@ public enum DefaultsKey {
public static let hosts = "punktfunk.hosts"
/// Client-side cursor mode: "auto" (shown only in gamescope sessions), "always", "never".
public static let cursorMode = "punktfunk.cursorMode"
/// Experimental: show the host's game library (browsed over the management API). Off by default.
public static let libraryEnabled = "punktfunk.libraryEnabled"
}
@@ -0,0 +1,100 @@
// 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 is HTTP on a port distinct from the punktfunk/1 data plane (default 47990),
// binds loopback unless started with a token, and REQUIRES a bearer token for any non-loopback
// bind. So to browse a host's library remotely the host must expose the mgmt API on the LAN with
// `--mgmt-token`; the client carries that token per host. 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 "needs a token").
public enum LibraryError: LocalizedError {
case unauthorized
case http(Int)
case unreachable(String)
public var errorDescription: String? {
switch self {
case .unauthorized:
return "The host's management API rejected the token. Start the host with "
+ "--mgmt-token and enter the same token here."
case .http(let code):
return "The management API returned HTTP \(code)."
case .unreachable(let why):
return "Couldn't reach the management API: \(why). The host must expose it on the "
+ "LAN (serve --mgmt-bind 0.0.0.0 --mgmt-token …)."
}
}
}
/// 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 http://<address>:<port>/api/v1/library` with an optional bearer token.
public static func fetch(
address: String, port: UInt16 = punktfunkDefaultMgmtPort, token: String? = nil
) async throws -> [GameEntry] {
guard let url = URL(string: "http://\(address):\(port)/api/v1/library") else {
throw LibraryError.unreachable("invalid host address")
}
var req = URLRequest(url: url, timeoutInterval: 10)
if let token, !token.isEmpty {
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
let (data, response): (Data, URLResponse)
do {
(data, response) = try await URLSession.shared.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:
return try JSONDecoder().decode([GameEntry].self, from: data)
case 401:
throw LibraryError.unauthorized
default:
throw LibraryError.http(http.statusCode)
}
}
}
@@ -0,0 +1,63 @@
// Unit tests for the game-library models decoding the management API's GET /api/v1/library
// payload and the poster-art fallback order. (The network fetch itself isn't unit-tested; it's
// exercised live against a host.)
import XCTest
@testable import PunktfunkKit
final class LibraryClientTests: XCTestCase {
func testDecodesLibraryPayload() throws {
// A Steam entry (full art + launch) and a custom entry (sparse art, no launch) the two
// shapes the host's `GameEntry` serializes (note the host omits null fields).
let json = """
[
{
"id": "steam:570",
"store": "steam",
"title": "Dota 2",
"art": {
"portrait": "https://cdn.cloudflare.steamstatic.com/steam/apps/570/library_600x900.jpg",
"hero": "https://cdn.cloudflare.steamstatic.com/steam/apps/570/library_hero.jpg",
"logo": "https://cdn.cloudflare.steamstatic.com/steam/apps/570/logo.png",
"header": "https://cdn.cloudflare.steamstatic.com/steam/apps/570/header.jpg"
},
"launch": { "kind": "steam_appid", "value": "570" }
},
{
"id": "custom:abc123",
"store": "custom",
"title": "Dolphin",
"art": { "header": "https://example.com/dolphin.jpg" }
}
]
""".data(using: .utf8)!
let games = try JSONDecoder().decode([GameEntry].self, from: json)
XCTAssertEqual(games.count, 2)
let steam = games[0]
XCTAssertEqual(steam.id, "steam:570")
XCTAssertFalse(steam.isCustom)
XCTAssertEqual(steam.launch?.kind, "steam_appid")
XCTAssertEqual(steam.launch?.value, "570")
let custom = games[1]
XCTAssertTrue(custom.isCustom)
XCTAssertNil(custom.launch)
XCTAssertNil(custom.art.portrait)
}
func testPosterCandidatesPreferPortraitThenHeader() {
let full = Artwork(
portrait: "https://x/p.jpg", hero: "https://x/hero.jpg",
logo: "https://x/logo.png", header: "https://x/h.jpg")
XCTAssertEqual(full.posterCandidates.map(\.absoluteString),
["https://x/p.jpg", "https://x/h.jpg", "https://x/hero.jpg"])
// No portrait header leads; absent fields are skipped, not nil-padded.
let sparse = Artwork(portrait: nil, hero: nil, logo: nil, header: "https://x/h.jpg")
XCTAssertEqual(sparse.posterCandidates.map(\.absoluteString), ["https://x/h.jpg"])
XCTAssertTrue(Artwork().posterCandidates.isEmpty)
}
}