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
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:
@@ -28,6 +28,7 @@ struct ContentView: View {
|
|||||||
@State private var showAddHost = false
|
@State private var showAddHost = false
|
||||||
@State private var pairingTarget: StoredHost?
|
@State private var pairingTarget: StoredHost?
|
||||||
@State private var speedTestTarget: StoredHost?
|
@State private var speedTestTarget: StoredHost?
|
||||||
|
@State private var libraryTarget: StoredHost?
|
||||||
#if !os(macOS)
|
#if !os(macOS)
|
||||||
@State private var showSettings = false
|
@State private var showSettings = false
|
||||||
#endif
|
#endif
|
||||||
@@ -67,6 +68,9 @@ struct ContentView: View {
|
|||||||
.sheet(item: $speedTestTarget) { host in
|
.sheet(item: $speedTestTarget) { host in
|
||||||
SpeedTestSheet(host: host)
|
SpeedTestSheet(host: host)
|
||||||
}
|
}
|
||||||
|
.sheet(item: $libraryTarget) { host in
|
||||||
|
NavigationStack { LibraryView(store: store, host: host) }
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,13 +79,14 @@ struct ContentView: View {
|
|||||||
HomeView(
|
HomeView(
|
||||||
store: store, model: model, discovery: discovery,
|
store: store, model: model, discovery: discovery,
|
||||||
showAddHost: $showAddHost, pairingTarget: $pairingTarget,
|
showAddHost: $showAddHost, pairingTarget: $pairingTarget,
|
||||||
speedTestTarget: $speedTestTarget,
|
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
|
||||||
connect: connect, connectDiscovered: connectDiscovered, onPaired: handlePaired)
|
connect: connect, connectDiscovered: connectDiscovered, onPaired: handlePaired)
|
||||||
#else
|
#else
|
||||||
HomeView(
|
HomeView(
|
||||||
store: store, model: model, discovery: discovery,
|
store: store, model: model, discovery: discovery,
|
||||||
showAddHost: $showAddHost, pairingTarget: $pairingTarget,
|
showAddHost: $showAddHost, pairingTarget: $pairingTarget,
|
||||||
speedTestTarget: $speedTestTarget, showSettings: $showSettings,
|
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
|
||||||
|
showSettings: $showSettings,
|
||||||
connect: connect, connectDiscovered: connectDiscovered, onPaired: handlePaired)
|
connect: connect, connectDiscovered: connectDiscovered, onPaired: handlePaired)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ struct HomeView: View {
|
|||||||
@Binding var showAddHost: Bool
|
@Binding var showAddHost: Bool
|
||||||
@Binding var pairingTarget: StoredHost?
|
@Binding var pairingTarget: StoredHost?
|
||||||
@Binding var speedTestTarget: StoredHost?
|
@Binding var speedTestTarget: StoredHost?
|
||||||
|
@Binding var libraryTarget: StoredHost?
|
||||||
#if !os(macOS)
|
#if !os(macOS)
|
||||||
@Binding var showSettings: Bool
|
@Binding var showSettings: Bool
|
||||||
#endif
|
#endif
|
||||||
@@ -23,6 +24,8 @@ struct HomeView: View {
|
|||||||
let connectDiscovered: (DiscoveredHost) -> Void
|
let connectDiscovered: (DiscoveredHost) -> Void
|
||||||
/// Pairing succeeded (tvOS PairSheet route) — pin + connect (ContentView guards staleness).
|
/// Pairing succeeded (tvOS PairSheet route) — pin + connect (ContentView guards staleness).
|
||||||
let onPaired: (StoredHost, Data) -> Void
|
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 {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -81,6 +84,9 @@ struct HomeView: View {
|
|||||||
.navigationDestination(item: $speedTestTarget) { host in
|
.navigationDestination(item: $speedTestTarget) { host in
|
||||||
SpeedTestSheet(host: host)
|
SpeedTestSheet(host: host)
|
||||||
}
|
}
|
||||||
|
.navigationDestination(item: $libraryTarget) { host in
|
||||||
|
LibraryView(store: store, host: host)
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
@@ -146,7 +152,8 @@ struct HomeView: View {
|
|||||||
// MARK: - Cards
|
// MARK: - Cards
|
||||||
|
|
||||||
private func hostCard(_ host: StoredHost) -> some View {
|
private func hostCard(_ host: StoredHost) -> some View {
|
||||||
HostCardView(
|
let onBrowseLibrary: (() -> Void)? = libraryEnabled ? { libraryTarget = host } : nil
|
||||||
|
return HostCardView(
|
||||||
host: host,
|
host: host,
|
||||||
isOnline: isOnline(host),
|
isOnline: isOnline(host),
|
||||||
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
|
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
|
||||||
@@ -156,7 +163,8 @@ struct HomeView: View {
|
|||||||
onPair: { if !model.isBusy { pairingTarget = host } },
|
onPair: { if !model.isBusy { pairingTarget = host } },
|
||||||
onSpeedTest: { if !model.isBusy { speedTestTarget = host } },
|
onSpeedTest: { if !model.isBusy { speedTestTarget = host } },
|
||||||
onForget: { store.forgetIdentity(host) },
|
onForget: { store.forgetIdentity(host) },
|
||||||
onRemove: { store.remove(host) })
|
onRemove: { store.remove(host) },
|
||||||
|
onBrowseLibrary: onBrowseLibrary)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var discoveredSection: some View {
|
private var discoveredSection: some View {
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ struct HostCardView: View {
|
|||||||
let onSpeedTest: () -> Void
|
let onSpeedTest: () -> Void
|
||||||
let onForget: () -> Void
|
let onForget: () -> Void
|
||||||
let onRemove: () -> 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 {
|
var body: some View {
|
||||||
let m = CardMetrics.current
|
let m = CardMetrics.current
|
||||||
@@ -104,6 +106,9 @@ struct HostCardView: View {
|
|||||||
.contextMenu {
|
.contextMenu {
|
||||||
Button("Pair with PIN…", action: onPair)
|
Button("Pair with PIN…", action: onPair)
|
||||||
Button("Test Network Speed…", action: onSpeedTest)
|
Button("Test Network Speed…", action: onSpeedTest)
|
||||||
|
if let onBrowseLibrary {
|
||||||
|
Button("Browse Library…", action: onBrowseLibrary)
|
||||||
|
}
|
||||||
if host.pinnedSHA256 != nil {
|
if host.pinnedSHA256 != nil {
|
||||||
Button("Forget Identity", action: onForget)
|
Button("Forget Identity", action: onForget)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,17 @@ struct StoredHost: Identifiable, Codable, Hashable {
|
|||||||
var pinnedSHA256: Data?
|
var pinnedSHA256: Data?
|
||||||
/// Last time a streaming session actually started (nil until the first one).
|
/// Last time a streaming session actually started (nil until the first one).
|
||||||
var lastConnected: Date?
|
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 displayName: String { name.isEmpty ? address : name }
|
||||||
|
var effectiveMgmtPort: UInt16 { mgmtPort ?? punktfunkDefaultMgmtPort }
|
||||||
}
|
}
|
||||||
|
|
||||||
extension StoredHost {
|
extension StoredHost {
|
||||||
@@ -87,6 +96,15 @@ final class HostStore: ObservableObject {
|
|||||||
hosts[i].pinnedSHA256 = nil
|
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() {
|
private func persist() {
|
||||||
if let data = try? JSONEncoder().encode(hosts) {
|
if let data = try? JSONEncoder().encode(hosts) {
|
||||||
UserDefaults.standard.set(data, forKey: Self.key)
|
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.bitrateKbps) private var bitrateKbps = 0
|
||||||
@AppStorage(DefaultsKey.presenter) private var presenter = "stage1"
|
@AppStorage(DefaultsKey.presenter) private var presenter = "stage1"
|
||||||
@AppStorage(DefaultsKey.cursorMode) private var cursorMode = "auto"
|
@AppStorage(DefaultsKey.cursorMode) private var cursorMode = "auto"
|
||||||
|
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
|
||||||
@AppStorage(DefaultsKey.micEnabled) private var micEnabled = true
|
@AppStorage(DefaultsKey.micEnabled) private var micEnabled = true
|
||||||
@ObservedObject private var gamepads = GamepadManager.shared
|
@ObservedObject private var gamepads = GamepadManager.shared
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@@ -405,6 +406,18 @@ struct SettingsView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.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 {
|
Section {
|
||||||
if gamepads.controllers.isEmpty {
|
if gamepads.controllers.isEmpty {
|
||||||
Text("No controllers detected")
|
Text("No controllers detected")
|
||||||
|
|||||||
@@ -22,4 +22,6 @@ public enum DefaultsKey {
|
|||||||
public static let hosts = "punktfunk.hosts"
|
public static let hosts = "punktfunk.hosts"
|
||||||
/// Client-side cursor mode: "auto" (shown only in gamescope sessions), "always", "never".
|
/// Client-side cursor mode: "auto" (shown only in gamescope sessions), "always", "never".
|
||||||
public static let cursorMode = "punktfunk.cursorMode"
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user