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
@@ -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)
}
}
}