diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index e3cf808..21ba1f2 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -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 } diff --git a/clients/apple/Sources/PunktfunkClient/HomeView.swift b/clients/apple/Sources/PunktfunkClient/HomeView.swift index 3c5e951..02d9970 100644 --- a/clients/apple/Sources/PunktfunkClient/HomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/HomeView.swift @@ -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 { diff --git a/clients/apple/Sources/PunktfunkClient/HostCards.swift b/clients/apple/Sources/PunktfunkClient/HostCards.swift index cfb0270..f381bcd 100644 --- a/clients/apple/Sources/PunktfunkClient/HostCards.swift +++ b/clients/apple/Sources/PunktfunkClient/HostCards.swift @@ -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) } diff --git a/clients/apple/Sources/PunktfunkClient/HostStore.swift b/clients/apple/Sources/PunktfunkClient/HostStore.swift index 175e86b..8f57f4a 100644 --- a/clients/apple/Sources/PunktfunkClient/HostStore.swift +++ b/clients/apple/Sources/PunktfunkClient/HostStore.swift @@ -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) diff --git a/clients/apple/Sources/PunktfunkClient/LibraryView.swift b/clients/apple/Sources/PunktfunkClient/LibraryView.swift new file mode 100644 index 0000000..b21658c --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/LibraryView.swift @@ -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 `. 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) + } + } +} diff --git a/clients/apple/Sources/PunktfunkClient/SettingsView.swift b/clients/apple/Sources/PunktfunkClient/SettingsView.swift index 3f9c9a7..ae7c13a 100644 --- a/clients/apple/Sources/PunktfunkClient/SettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/SettingsView.swift @@ -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") diff --git a/clients/apple/Sources/PunktfunkKit/DefaultsKeys.swift b/clients/apple/Sources/PunktfunkKit/DefaultsKeys.swift index d4443ab..ee3115a 100644 --- a/clients/apple/Sources/PunktfunkKit/DefaultsKeys.swift +++ b/clients/apple/Sources/PunktfunkKit/DefaultsKeys.swift @@ -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" } diff --git a/clients/apple/Sources/PunktfunkKit/LibraryClient.swift b/clients/apple/Sources/PunktfunkKit/LibraryClient.swift new file mode 100644 index 0000000..63e5faf --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/LibraryClient.swift @@ -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:` / `custom:`. +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://
:/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) + } + } +} diff --git a/clients/apple/Tests/PunktfunkKitTests/LibraryClientTests.swift b/clients/apple/Tests/PunktfunkKitTests/LibraryClientTests.swift new file mode 100644 index 0000000..cb7cab0 --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/LibraryClientTests.swift @@ -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) + } +}