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 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")
|
||||
|
||||
Reference in New Issue
Block a user