From ecbbff55447f020905e75140ceed67afef18027d Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 1 Jul 2026 15:14:19 +0200 Subject: [PATCH] feat(apple): gamepad ui --- Cargo.lock | 1 + api/openapi.json | 58 +++ .../apple/Punktfunk.xcodeproj/project.pbxproj | 24 +- .../Sources/PunktfunkClient/ContentView.swift | 39 ++ .../PunktfunkClient/GamepadCarousel.swift | 266 ++++++++++++ .../PunktfunkClient/GamepadHomeView.swift | 403 ++++++++++++++++++ .../LibraryCoverflowView.swift | 171 ++++++++ .../Sources/PunktfunkClient/LibraryView.swift | 135 ++++-- .../PunktfunkClient/SettingsView.swift | 27 +- .../Sources/PunktfunkKit/ClientTLS.swift | 85 +++- .../Sources/PunktfunkKit/DefaultsKeys.swift | 4 + .../PunktfunkKit/GamepadMenuInput.swift | 149 +++++++ .../PunktfunkKit/GamepadUIEnvironment.swift | 20 + .../Sources/PunktfunkKit/LibraryClient.swift | 54 ++- .../Sources/PunktfunkKit/MenuHaptics.swift | 119 ++++++ .../Sources/PunktfunkKit/SessionAudio.swift | 17 +- .../GamepadUIEnvironmentTests.swift | 14 + .../LibraryClientTests.swift | 18 + crates/punktfunk-host/Cargo.toml | 2 + crates/punktfunk-host/src/library.rs | 191 ++++++++- crates/punktfunk-host/src/mgmt.rs | 59 ++- scripts/build-xcframework.sh | 0 22 files changed, 1782 insertions(+), 74 deletions(-) create mode 100644 clients/apple/Sources/PunktfunkClient/GamepadCarousel.swift create mode 100644 clients/apple/Sources/PunktfunkClient/GamepadHomeView.swift create mode 100644 clients/apple/Sources/PunktfunkClient/LibraryCoverflowView.swift create mode 100644 clients/apple/Sources/PunktfunkKit/GamepadMenuInput.swift create mode 100644 clients/apple/Sources/PunktfunkKit/GamepadUIEnvironment.swift create mode 100644 clients/apple/Sources/PunktfunkKit/MenuHaptics.swift create mode 100644 clients/apple/Tests/PunktfunkKitTests/GamepadUIEnvironmentTests.swift mode change 100644 => 100755 scripts/build-xcframework.sh diff --git a/Cargo.lock b/Cargo.lock index 91e8d57..a2b0b45 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2844,6 +2844,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "tempfile", "tokio", "tokio-rustls", "tower", diff --git a/api/openapi.json b/api/openapi.json index 6ef09df..135fd5a 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -229,6 +229,64 @@ } } }, + "/api/v1/library/art/{id}/{kind}": { + "get": { + "tags": [ + "library" + ], + "summary": "Fetch one cover-art image for a library entry", + "description": "Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. For a Steam title, the host's own local Steam cache is tried first (exact —\nit's what the user's Steam client already shows for it), the public Steam CDN's flat URL\nconvention as a fallback (newer titles' CDN assets can live at a per-asset-hash path the host\ncan't predict, in which case this 404s and the client falls through to its next art candidate).\nOnly Steam ids are backed today; any other store 404s.", + "operationId": "getLibraryArt", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The store-qualified library id, e.g. `steam:570`", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "kind", + "in": "path", + "description": "`portrait` | `hero` | `logo` | `header`", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Image bytes", + "content": { + "image/jpeg": {} + } + }, + "401": { + "description": "Missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No art of that kind for that id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, "/api/v1/library/custom": { "post": { "tags": [ diff --git a/clients/apple/Punktfunk.xcodeproj/project.pbxproj b/clients/apple/Punktfunk.xcodeproj/project.pbxproj index 3458f1c..97b6cff 100644 --- a/clients/apple/Punktfunk.xcodeproj/project.pbxproj +++ b/clients/apple/Punktfunk.xcodeproj/project.pbxproj @@ -355,7 +355,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = punktfunk_Logo; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = Config/Punktfunk-macOS.entitlements; + CODE_SIGN_ENTITLEMENTS = "Config/Punktfunk-macOS.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; @@ -364,7 +364,7 @@ ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Config/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk"; + INFOPLIST_KEY_CFBundleDisplayName = Punktfunk; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input."; @@ -389,7 +389,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = punktfunk_Logo; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = Config/Punktfunk-macOS.entitlements; + CODE_SIGN_ENTITLEMENTS = "Config/Punktfunk-macOS.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; @@ -398,7 +398,7 @@ ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Config/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk"; + INFOPLIST_KEY_CFBundleDisplayName = Punktfunk; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input."; @@ -425,11 +425,11 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = F4H37KF6WC; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = F4H37KF6WC; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Config/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk"; + INFOPLIST_KEY_CFBundleDisplayName = Punktfunk; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input."; INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone."; @@ -464,11 +464,11 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = F4H37KF6WC; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = F4H37KF6WC; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Config/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk"; + INFOPLIST_KEY_CFBundleDisplayName = Punktfunk; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input."; INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone."; @@ -502,11 +502,11 @@ ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = F4H37KF6WC; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = F4H37KF6WC; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Config/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk"; + INFOPLIST_KEY_CFBundleDisplayName = Punktfunk; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; @@ -532,11 +532,11 @@ ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = F4H37KF6WC; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = F4H37KF6WC; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Config/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk"; + INFOPLIST_KEY_CFBundleDisplayName = Punktfunk; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index c8d1319..52d42e4 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -45,6 +45,16 @@ struct ContentView: View { #if !os(macOS) @State private var showSettings = false #endif + #if os(iOS) + // A connected controller (+ the Settings toggle) swaps the whole home screen for + // GamepadHomeView instead of retrofitting HomeView's touch UI — see `home` below. + @ObservedObject private var gamepadManager = GamepadManager.shared + @AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true + private var gamepadUIActive: Bool { + GamepadUIEnvironment.isActive( + gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled) + } + #endif var body: some View { Group { @@ -114,11 +124,23 @@ struct ContentView: View { .sheet(item: $speedTestTarget) { host in SpeedTestSheet(host: host) } + // The library is a full-screen presentation, not a sheet: on iPad a sheet is a centered page + // card, but the gamepad coverflow is meant to be an immersive, full-bleed screen (and the + // launcher behind it stops consuming the controller — see GamepadHomeView's `isActive`). + // macOS has no `fullScreenCover`, so it keeps the sheet there. + #if os(macOS) .sheet(item: $libraryTarget) { host in NavigationStack { LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) }) } } + #else + .fullScreenCover(item: $libraryTarget) { host in + NavigationStack { + LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) }) + } + } + #endif #endif // Fresh pair=required / unknown host: offer the two ways in. An action sheet (not an // alert) so it never collides with the wait alert below. "Request Access" is the no-PIN @@ -171,6 +193,23 @@ struct ContentView: View { speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget, connect: { connect($0) }, connectDiscovered: connectDiscovered, onPaired: handlePaired, onLaunchTitle: launchTitle) + #elseif os(iOS) + Group { + if gamepadUIActive { + GamepadHomeView( + store: store, model: model, discovery: discovery, + libraryTarget: $libraryTarget, + connect: { connect($0) }, connectDiscovered: connectDiscovered) + } else { + HomeView( + store: store, model: model, discovery: discovery, + showAddHost: $showAddHost, pairingTarget: $pairingTarget, + speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget, + showSettings: $showSettings, + connect: { connect($0) }, connectDiscovered: connectDiscovered, + onPaired: handlePaired, onLaunchTitle: launchTitle) + } + } #else HomeView( store: store, model: model, discovery: discovery, diff --git a/clients/apple/Sources/PunktfunkClient/GamepadCarousel.swift b/clients/apple/Sources/PunktfunkClient/GamepadCarousel.swift new file mode 100644 index 0000000..ba3512a --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/GamepadCarousel.swift @@ -0,0 +1,266 @@ +// The one piece of gamepad-menu machinery shared by the host launcher (GamepadHomeView) and the +// library coverflow (LibraryCoverflowView): a horizontal, center-snapping carousel driven entirely +// by a controller (iOS/iPadOS only). +// +// The scrolling is pure native SwiftUI — `.scrollTargetLayout()` + `.scrollTargetBehavior(.viewAligned)` +// snap exactly one item to center, and symmetric `.safeAreaPadding(.horizontal)` (sized off the live +// container width, so it's correct in an iPad split view too) lets the first and last item reach the +// middle. The CALLER owns each card's look, including its own `.scrollTransition` — this component +// deliberately applies none, so a screen can chain the VisualEffect-only transition modifiers without +// the generic wrapper here pushing the type-checker onto an overload it can't satisfy. +// +// Navigation authority: an internal `cursor` (an index), NOT the scroll-position binding, is the +// source of truth for where the gamepad is. `.scrollPosition(id:)` is a two-way binding and the +// scroll view WRITES intermediate ids into it while a programmatic animation is in flight — so +// reading the "current" item back out of it to compute the next one desyncs badly on a fast held +// stick (each move reads a lagging value and the cursor stalls before the last item). Instead a move +// advances `cursor` synchronously and points the scroll view at `items[cursor]`; scroll read-back is +// only allowed to move the cursor when the gamepad hasn't driven recently (i.e. a touch drag). +// +// Feedback is dual-channel by design: `.sensoryFeedback` ticks the DEVICE Taptic engine (for a +// handheld/touch user) and `MenuHaptics` ticks the CONTROLLER (for a couch user holding the pad). +// Both fire on a move, on confirm, and — for a non-wrapping list — a duller bump plus a short visual +// recoil when a move is refused at either end. + +import PunktfunkKit +import SwiftUI +#if os(iOS) +import UIKit + +struct GamepadCarousel: View where Item.ID: Hashable { + let items: [Item] + /// Output only: the carousel WRITES the focused item's id here for the caller's detail panel. + /// It is deliberately not what drives the scroll (see the file header). + @Binding var selection: Item.ID? + /// Every card is laid out at this fixed width so `.viewAligned` snapping + symmetric side + /// insets center exactly one at a time. + let itemWidth: CGFloat + let spacing: CGFloat + /// A → activate the centered item. + let onActivate: (Item) -> Void + /// Y → the screen's secondary action (e.g. open a host's library); nil disables it. + var onSecondary: (() -> Void)? + /// B → back/dismiss; nil disables it (e.g. the root launcher has nowhere to go back to). + var onBack: (() -> Void)? + /// L1/R1 → jump this many items at once (clamped to the ends); 0 disables the shoulders. + var shoulderJump: Int = 0 + /// Whether this carousel currently owns controller input. A presenting screen (e.g. the host + /// launcher) stays mounted behind a presented one (e.g. the library), and both carousels would + /// otherwise poll the SAME controller at once — driving both. The parent sets this false while + /// something is presented on top so only the front-most carousel consumes the gamepad. + var isActive: Bool = true + @ViewBuilder let card: (Item) -> Card + + @State private var input = GamepadMenuInput(manager: .shared) + @State private var haptics = MenuHaptics(manager: .shared) + /// Authoritative gamepad cursor (index into `items`). Never assigned from scroll read-back + /// while the gamepad is driving — that's the whole desync fix. + @State private var cursor = 0 + /// The id the scroll view is aligned to — its own two-way `.scrollPosition` state. + @State private var scrolledID: Item.ID? + /// When the gamepad last moved the cursor; gates scroll read-back so a mid-animation write can't + /// drag the cursor backward during a fast held direction. + @State private var lastNav = Date.distantPast + /// True while a programmatic scroll animation is in flight. `.scrollPosition(id:)` DROPS a new + /// write that lands mid-animation — the scroll view stays stuck on the old item even though the + /// binding updated — so we never issue one until the previous animation reports complete, then + /// `commitScroll` re-targets the current cursor (coalescing a fast burst; see `commitScroll`). + @State private var isScrolling = false + /// A short horizontal recoil when a move is refused at a list end. + @State private var bumpOffset: CGFloat = 0 + /// `.sensoryFeedback` fires on a change of its trigger; counters request a device tick for the + /// confirm and end-stop events (moves trigger on `cursor`). + @State private var activateTick = 0 + @State private var boundaryTick = 0 + + /// Read-back from a touch drag is honoured only once the gamepad has been quiet this long + /// (longer than a move animation, so overlapping held-stick moves never let it through). + private let navSettle: TimeInterval = 0.4 + + var body: some View { + GeometryReader { geo in + let inset = max(0, (geo.size.width - itemWidth) / 2) + ScrollView(.horizontal) { + HStack(spacing: spacing) { + ForEach(items) { item in + card(item) + .frame(width: itemWidth) + .contentShape(Rectangle()) + .onTapGesture { tap(item) } + } + } + .frame(height: geo.size.height) // fill so shorter cards center vertically + .scrollTargetLayout() + } + .scrollPosition(id: $scrolledID) + .scrollTargetBehavior(.viewAligned) + .scrollIndicators(.hidden) + .scrollClipDisabled() // let the focused card scale up past the strip bounds + .safeAreaPadding(.horizontal, inset) + .offset(x: bumpOffset) + } + .sensoryFeedback(.selection, trigger: cursor) + .sensoryFeedback(.impact(weight: .medium), trigger: activateTick) + .sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: boundaryTick) + .onAppear { + reconcile() + wire() + if isActive { input.start() } + } + .onDisappear { + input.stop() + haptics.stop() + } + // Hand controller input to/from a screen presented on top (see `isActive`): a covered + // carousel stops polling so it can't navigate behind the front-most one. + .onChange(of: isActive) { _, active in + if active { + wire() + input.start() + } else { + input.stop() + haptics.stop() + } + } + // A touch drag settles the scroll onto a new id: adopt it as the cursor. Ignored while a + // programmatic scroll is animating (its own intermediate id write-backs would regress the + // cursor) and briefly after a gamepad move (the same reason), so only a genuine touch drag + // — which never sets `isScrolling` — moves the cursor here. + .onChange(of: scrolledID) { _, newValue in + guard !isScrolling, Date().timeIntervalSince(lastNav) > navSettle else { return } + guard let idx = index(of: newValue), idx != cursor else { return } + cursor = idx + selection = newValue + } + // Re-seed a dropped/changed selection AND re-wire the input callbacks so they capture the + // current `items` value (a plain array — unlike an observed object it would otherwise go + // stale in the closures stored on `input`). + .onChange(of: items.map(\.id)) { _, _ in + reconcile() + wire() + } + } + + // MARK: - Input wiring + + private func wire() { + input.onMove = { move($0) } + input.onConfirm = { activate() } + input.onSecondary = onSecondary + input.onBack = onBack + input.onShoulder = shoulderJump > 0 ? { shoulder(right: $0) } : nil + } + + private func move(_ direction: GamepadMenuInput.Direction) { + let forward = direction == .right || direction == .down + step(by: forward ? 1 : -1, clampAtEnds: false) + } + + private func shoulder(right: Bool) { + step(by: right ? shoulderJump : -shoulderJump, clampAtEnds: true) + } + + /// Advance the cursor by `delta`. A single move (`clampAtEnds: false`) that would leave the list + /// recoils + bumps; a shoulder jump (`clampAtEnds: true`) lands on the end item, bumping only if + /// already there. The cursor is the authority — the scroll view is pointed at it, never read for it. + private func step(by delta: Int, clampAtEnds: Bool) { + guard !items.isEmpty else { return } + var target = cursor + delta + if target < 0 || target >= items.count { + guard clampAtEnds else { return boundaryBump(forward: delta > 0) } + target = min(max(target, 0), items.count - 1) + } + guard target != cursor else { return boundaryBump(forward: delta > 0) } + cursor = target + lastNav = Date() + haptics.move() + selection = items[target].id // text/detail updates immediately; the scroll chases + commitScroll() + } + + private let scrollAnim: TimeInterval = 0.24 + /// A hair past `scrollAnim` — long enough that the scroll has actually settled before the next + /// write, short enough to stay responsive. + private var scrollSettle: TimeInterval { scrollAnim + 0.05 } + + /// Drive the scroll toward the current cursor, one honoured write at a time. `.scrollPosition(id:)` + /// DROPS a write that lands while a scroll is still animating, so we issue at most one at a time and + /// re-target the LATEST cursor once it settles — coalescing a fast burst (hold OR quick flicks) and + /// always converging on the final item, instead of getting stuck on the old card. + /// + /// The settle is timed by a plain timer rather than `withAnimation`'s completion: `scrolledID` is a + /// discrete id, not an animatable value, so `withAnimation` has no tracked animation to fire a + /// reliable completion against (it can fire early — which is exactly what let quick flicks slip a + /// write through mid-scroll and stick). `asyncAfter` always fires, so `isScrolling` can never latch. + private func commitScroll() { + guard !isScrolling, cursor >= 0, cursor < items.count else { return } + let id = items[cursor].id + guard scrolledID != id else { return } + isScrolling = true + withAnimation(.easeOut(duration: scrollAnim)) { scrolledID = id } + DispatchQueue.main.asyncAfter(deadline: .now() + scrollSettle) { + MainActor.assumeIsolated { + isScrolling = false + commitScroll() // the cursor may have advanced while this scroll ran — chase it + } + } + } + + private func activate() { + guard cursor >= 0, cursor < items.count else { return } + activateTick &+= 1 + haptics.confirm() + onActivate(items[cursor]) + } + + /// Touch fallback matching the rest of the app: tapping the centered card activates it, tapping + /// any other re-centers on it. + private func tap(_ item: Item) { + if let idx = index(of: item.id), idx == cursor { + activate() + } else if let idx = index(of: item.id) { + cursor = idx + lastNav = Date() + haptics.move() + selection = item.id + commitScroll() + } + } + + // MARK: - Selection housekeeping + + private func index(of id: Item.ID?) -> Int? { + guard let id else { return nil } + return items.firstIndex { $0.id == id } + } + + /// Keep `cursor`/`scrolledID`/`selection` consistent with `items`: seed on appear, and on a list + /// change keep the same focused item when it survives, else clamp the cursor into range. + private func reconcile() { + guard !items.isEmpty else { + cursor = 0 + if scrolledID != nil { scrolledID = nil } + if selection != nil { selection = nil } + return + } + if let sid = scrolledID, let idx = index(of: sid) { + cursor = idx + if selection != sid { selection = sid } + } else { + let idx = min(max(cursor, 0), items.count - 1) + cursor = idx + let id = items[idx].id + scrolledID = id + selection = id + } + } + + private func boundaryBump(forward: Bool) { + boundaryTick &+= 1 + haptics.boundary() + let recoil: CGFloat = forward ? -16 : 16 + withAnimation(.spring(response: 0.16, dampingFraction: 0.42)) { bumpOffset = recoil } + withAnimation(.spring(response: 0.34, dampingFraction: 0.7).delay(0.1)) { bumpOffset = 0 } + } +} +#endif diff --git a/clients/apple/Sources/PunktfunkClient/GamepadHomeView.swift b/clients/apple/Sources/PunktfunkClient/GamepadHomeView.swift new file mode 100644 index 0000000..6019104 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/GamepadHomeView.swift @@ -0,0 +1,403 @@ +// The gamepad-driven home screen (iOS/iPadOS only): a distinct, "10-foot" console-style host +// launcher shown INSTEAD of HomeView while GamepadUIEnvironment is active — a separate screen built +// around a center-snapping carousel of hosts, driven from the couch with a controller. No touch is +// required (a tap still works as a fallback). Scope: browse saved + discovered hosts, connect, and +// — when the library flag is on — jump into a saved host's library (Y). +// +// All the scrolling/snapping/navigation/haptics live in GamepadCarousel; this file is the launcher's +// chrome. Layout discipline (so nothing is EVER clipped, portrait or landscape): the gradient is a +// `.background` modifier — NOT a ZStack sibling — because an `.ignoresSafeArea()` sibling expands the +// stack to full-screen and hands the GeometryReader the full height, laying content out under the +// status bar / home indicator. As a background it draws behind without affecting layout, so the +// GeometryReader is sized to the safe area. The title and the controller-glyph hints are pinned with +// `.safeAreaInset` (top / bottom-leading) — guaranteed inside the safe area and out of the carousel's +// vertical budget — and the card is sized off the remaining height. tvOS/macOS never mount this view. + +import PunktfunkKit +import SwiftUI +#if os(iOS) +import GameController + +/// One navigable tile: a saved host or a discovered-but-unsaved one. Hashable so it can be the +/// carousel's scroll-position identity. +private enum GamepadHomeTarget: Hashable { + case saved(UUID) + case discovered(String) +} + +/// A fully-resolved launcher tile — display fields + the activate action, built fresh each render +/// from the live stores so nothing goes stale. +private struct HomeTile: Identifiable { + let id: GamepadHomeTarget + let title: String + let subtitle: String + let isOnline: Bool + let isPaired: Bool + let isConnecting: Bool + /// Saved (solid monogram) vs. discovered-but-unsaved (tinted outline). + let filled: Bool + /// Only saved hosts have a library (matches the touch grid's context-menu gate). + let hasLibrary: Bool + let activate: () -> Void +} + +struct GamepadHomeView: View { + @ObservedObject var store: HostStore + @ObservedObject var model: SessionModel + @ObservedObject var discovery: HostDiscovery + @Binding var libraryTarget: StoredHost? + let connect: (StoredHost) -> Void + let connectDiscovered: (DiscoveredHost) -> Void + + /// Same experimental gate the touch grid's "Browse Library…" context-menu item uses. + @AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false + /// `.compact` in a landscape phone window — drives tighter chrome so everything still fits. + @Environment(\.verticalSizeClass) private var vSizeClass + @State private var selection: GamepadHomeTarget? + @State private var breathe = false + + private var compact: Bool { vSizeClass == .compact } + + var body: some View { + GeometryReader { geo in + hero(for: geo.size) + } + // Pinned inside the safe area, out of the carousel's vertical budget — never clipped. + .safeAreaInset(edge: .top, spacing: 0) { + titleView + .padding(.top, compact ? 4 : 10) + .padding(.bottom, compact ? 4 : 8) + .frame(maxWidth: .infinity) + } + .safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) { + if !tiles.isEmpty { + hintBar + .padding(.leading, 22) + .padding(.vertical, compact ? 6 : 10) + } + } + .background { background } + .onAppear { + discovery.start() + withAnimation(.easeInOut(duration: 4).repeatForever(autoreverses: true)) { breathe = true } + } + .onDisappear { discovery.stop() } + .alert( + "Connection failed", + isPresented: Binding( + get: { model.errorMessage != nil }, + set: { if !$0 { model.errorMessage = nil } }) + ) { + Button("OK", role: .cancel) {} + } message: { + Text(model.errorMessage ?? "") + } + } + + // MARK: - Hero (carousel + detail), sized to fit the space between the pinned title and hints + + @ViewBuilder private func hero(for size: CGSize) -> some View { + if tiles.isEmpty { + emptyState.frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + let cardWidth = min(340, size.width * 0.84) + // 96 ≈ the carousel's own vertical breathing (+40) plus the detail line (~54); clamp so + // the strip + detail always fit the region the safe-area insets leave. + let cardHeight = min(compact ? 170 : 210, max(118, size.height - 96)) + VStack(spacing: compact ? 8 : 10) { + Spacer(minLength: 0) + carousel(cardWidth: cardWidth, cardHeight: cardHeight) + detailPanel + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + // MARK: - Chrome + + private var background: some View { + ZStack { + LinearGradient( + colors: [.black, Color.brand.opacity(0.22), .black], + startPoint: .top, endPoint: .bottom) + // A soft brand orb behind the strip gives the flat gradient depth; it breathes slowly. + Circle() + .fill(RadialGradient( + colors: [Color.brand.opacity(0.55), .clear], + center: .center, startRadius: 0, endRadius: 300)) + .frame(width: 560, height: 560) + .blur(radius: 70) + .scaleEffect(breathe ? 1.08 : 0.92) + .opacity(breathe ? 0.5 : 0.32) + .offset(y: -20) + } + .ignoresSafeArea() + } + + private var titleView: some View { + Text("Select a Host") + .font(.geist(compact ? 20 : 30, .bold, relativeTo: .title)) + .foregroundStyle(.white) + } + + private var emptyState: some View { + VStack(spacing: 14) { + Image(systemName: "gamecontroller") + .font(.system(size: 46, weight: .light)) + .foregroundStyle(Color.brand) + Text("No hosts yet") + .font(.geist(20, .semibold, relativeTo: .title3)) + .foregroundStyle(.white) + Text("Add one with touch first — it'll show up here for the controller.") + .font(.geist(15, relativeTo: .body)) + .foregroundStyle(.white.opacity(0.6)) + .multilineTextAlignment(.center) + .frame(maxWidth: 320) + } + } + + // MARK: - Carousel + + private func carousel(cardWidth: CGFloat, cardHeight: CGFloat) -> some View { + GamepadCarousel( + items: tiles, + selection: $selection, + itemWidth: cardWidth, + spacing: 30, + onActivate: { $0.activate() }, + onSecondary: { openLibraryForSelected() }, + // Stop consuming the controller while the library is presented on top — otherwise the + // launcher navigates behind it (invisibly on iPhone, visibly on iPad's page sheet). + isActive: libraryTarget == nil + ) { tile in + hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight)) + } + .frame(height: cardHeight + 40) + } + + /// The host tile plus its focus treatment. Every continuous visual reads the scroll view's own + /// per-frame `phase` (real distance-from-centered), so the look always matches what's on screen + /// mid-scroll. `.shadow`/`.overlay` aren't part of `VisualEffect`, so the focus pop is scale + + /// brightness/saturation + a depth blur on the recessed neighbors. + private func hostCard(_ tile: HomeTile, size: CGSize) -> some View { + GamepadHostTile(tile: tile, size: size) + .scrollTransition { content, phase in + let d = CGFloat(min(abs(phase.value), 1)) + let scale = 1 - d * 0.12 + let bright = Double(-d * 0.24) + let sat = Double(1 - d * 0.42) + let soft = d * 3 + let fade = Double(1 - d * 0.22) + return content + .scaleEffect(scale) + .brightness(bright) + .saturation(sat) + .blur(radius: soft) + .opacity(fade) + } + } + + /// The "now focused" host, spelled out below the strip — empty (not hidden) so the layout + /// doesn't jump as the selection changes. + @ViewBuilder private var detailPanel: some View { + let tile = tiles.first { $0.id == selection } + VStack(spacing: 6) { + Text(tile?.title ?? " ") + .font(.geist(22, .bold, relativeTo: .title2)) + .foregroundStyle(.white) + .lineLimit(1) + HStack(spacing: 10) { + Text(tile?.subtitle ?? " ") + .font(.geist(13, relativeTo: .caption)) + .foregroundStyle(.white.opacity(0.6)) + if let tile { + statusPill(online: tile.isOnline, paired: tile.isPaired) + } + } + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 24) + .animation(.smooth(duration: 0.25), value: selection) + } + + private func statusPill(online: Bool, paired: Bool) -> some View { + HStack(spacing: 6) { + Circle() + .fill(online ? Color.green : Color.white.opacity(0.35)) + .frame(width: 6, height: 6) + Text(online ? "ONLINE" : "OFFLINE") + if paired { Text("· PAIRED") } + } + .font(.geist(11, .medium, relativeTo: .caption2)) + .tracking(0.8) + .foregroundStyle(.white.opacity(0.55)) + } + + // MARK: - Hint bar (pinned bottom-leading via safeAreaInset) + + private var hintBar: some View { + HStack(spacing: 18) { + hint(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Connect") + if showsLibraryHint { + hint(glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library") + } + } + .font(.geist(14, .semibold, relativeTo: .subheadline)) + .foregroundStyle(.white.opacity(0.85)) + } + + private func hint(glyph: String, text: String) -> some View { + HStack(spacing: 7) { + Image(systemName: glyph) + .font(.system(size: 19)) + .foregroundStyle(.white) + Text(text) + } + .fixedSize() // keep glyph + label together; never truncate a hint mid-word + } + + private var showsLibraryHint: Bool { + guard libraryEnabled else { return false } + return tiles.first { $0.id == selection }?.hasLibrary ?? false + } + + /// The active controller's real glyph for a button (Xbox "A", DualSense ✕, …) via + /// `sfSymbolsName`; a generic fallback before a controller profile resolves. + private func buttonGlyph( + _ button: KeyPath, fallback: String + ) -> String { + GamepadManager.shared.active?.controller.extendedGamepad?[keyPath: button].sfSymbolsName + ?? fallback + } + + // MARK: - Data + actions + + /// Built fresh each render from the live stores (no stale value capture) — saved hosts first, + /// then discovered-but-unsaved ones. + private var tiles: [HomeTile] { + let saved = store.hosts.map { host in + HomeTile( + id: .saved(host.id), + title: host.displayName, + subtitle: "\(host.address):\(String(host.port))", + isOnline: isOnline(host), + isPaired: host.pinnedSHA256 != nil, + isConnecting: model.phase == .connecting && model.activeHost?.id == host.id, + filled: true, + hasLibrary: true, + activate: { connect(host) }) + } + let discovered = discoveredUnsaved.map { d in + HomeTile( + id: .discovered(d.id), + title: d.name, + subtitle: "\(d.host):\(String(d.port))", + isOnline: true, + isPaired: false, + isConnecting: false, + filled: false, + hasLibrary: false, + activate: { connectDiscovered(d) }) + } + return saved + discovered + } + + /// Only saved hosts have a library — matches the touch grid, where "Browse Library…" is a + /// `HostCardView`-only action never offered on `DiscoveredCardView`. + private func openLibraryForSelected() { + guard libraryEnabled, case .saved(let id) = selection, + let host = store.hosts.first(where: { $0.id == id }) + else { return } + libraryTarget = host + } + + private func isOnline(_ host: StoredHost) -> Bool { + discovery.hosts.contains { host.matches($0) } + } + + private var discoveredUnsaved: [DiscoveredHost] { + discovery.hosts.filter { d in !store.hosts.contains { $0.matches(d) } } + } +} + +/// One "console tile" in the host carousel — a dark-glass landscape card, bigger and bolder than the +/// touch grid's `HostCardView`. Renders only its base look; the centered-tile pop is layered on by +/// the caller's `.scrollTransition` so it always tracks the real scroll position. +private struct GamepadHostTile: View { + let tile: HomeTile + let size: CGSize + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .top) { + monogramBadge + Spacer(minLength: 0) + if tile.isOnline { + Circle() + .fill(Color.green) + .frame(width: 9, height: 9) + .shadow(color: .green.opacity(0.7), radius: 5) + } + } + Spacer(minLength: 0) + Text(tile.title) + .font(.geist(23, .bold, relativeTo: .title2)) + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.7) + Text(tile.subtitle) + .font(.geist(13, relativeTo: .caption)) + .foregroundStyle(.white.opacity(0.55)) + .lineLimit(1) + .padding(.top, 2) + } + .padding(20) + .frame(width: size.width, height: size.height, alignment: .leading) + .background { + RoundedRectangle(cornerRadius: 26, style: .continuous) + .fill(.ultraThinMaterial) + .environment(\.colorScheme, .dark) + } + .overlay { + RoundedRectangle(cornerRadius: 26, style: .continuous) + .strokeBorder( + LinearGradient( + colors: [.white.opacity(0.22), .white.opacity(0.04)], + startPoint: .top, endPoint: .bottom), + style: StrokeStyle(lineWidth: 1, dash: tile.filled ? [] : [6, 5])) + } + .clipShape(RoundedRectangle(cornerRadius: 26, style: .continuous)) + .shadow(color: .black.opacity(0.45), radius: 20, y: 14) + } + + private var monogramBadge: some View { + let shape = RoundedRectangle(cornerRadius: 15, style: .continuous) + return ZStack { + shape.fill(tile.filled + ? AnyShapeStyle(LinearGradient( + colors: [Color.brand, Color.brand.opacity(0.68)], + startPoint: .top, endPoint: .bottom)) + : AnyShapeStyle(Color.brand.opacity(0.16))) + if tile.isConnecting { + ProgressView().tint(.white) + } else { + Text(monogram(tile.title)) + .font(.geistFixed(25, .bold)) + .foregroundStyle(tile.filled ? .white : Color.brand) + } + } + .frame(width: 52, height: 52) + .overlay { + if !tile.filled { + shape.strokeBorder(Color.brand.opacity(0.5), lineWidth: 1) + } + } + } + + private func monogram(_ name: String) -> String { + guard let first = name.trimmingCharacters(in: .whitespacesAndNewlines).first else { return "•" } + return String(first).uppercased() + } +} +#endif diff --git a/clients/apple/Sources/PunktfunkClient/LibraryCoverflowView.swift b/clients/apple/Sources/PunktfunkClient/LibraryCoverflowView.swift new file mode 100644 index 0000000..3716b49 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/LibraryCoverflowView.swift @@ -0,0 +1,171 @@ +// The gamepad-driven presentation of the game library (iOS/iPadOS only — see LibraryView's +// `gamepadUIActive` branch): a classic coverflow instead of the touch grid. All the +// scrolling/snapping/navigation/haptics live in GamepadCarousel; this file is the coverflow card +// (poster + the 3D recede treatment via `.scrollTransition`), the "now focused" detail panel, and +// the controller-glyph hints. A steps through covers, A launches the centered title, B closes, and +// the shoulders (L1/R1) jump a handful at a time through a long library. +// +// Layout discipline (so nothing is EVER clipped, portrait or landscape): the gradient is a +// `.background` modifier — NOT a ZStack sibling — because an `.ignoresSafeArea()` sibling expands the +// stack to full-screen and hands the GeometryReader the full height, laying content out under the +// status bar / home indicator. As a background it draws behind without affecting layout, so the +// GeometryReader is sized to the safe area, and the controller-glyph hints are pinned inside it with +// `.safeAreaInset(.bottom, alignment: .leading)`. Cover size is then derived from the height that +// remains, so a tall 2:3 poster + the detail line always fit. + +import PunktfunkKit +import SwiftUI +#if os(iOS) +import GameController +import UIKit + +struct LibraryCoverflowView: View { + let games: [GameEntry] + let imageSession: URLSession? + var onLaunch: ((String) -> Void)? + /// Button B (back) — dismisses the library screen. No touch equivalent needed here (the toolbar + /// Close button already covers that); this is what makes gamepad-only exit possible. + var onDismiss: (() -> Void)? + + /// `.compact` in a landscape phone window — drives a tighter poster so everything still fits. + @Environment(\.verticalSizeClass) private var vSizeClass + @State private var selection: String? + + private var compact: Bool { vSizeClass == .compact } + + var body: some View { + GeometryReader { geo in + content(for: geo.size) + } + .safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) { + hintBar + .padding(.leading, 22) + .padding(.vertical, compact ? 6 : 10) + } + .background { + LinearGradient( + colors: [.black, Color.brand.opacity(0.16), .black], + startPoint: .top, endPoint: .bottom) + .ignoresSafeArea() + } + } + + @ViewBuilder private func content(for size: CGSize) -> some View { + // Fit the tallest poster into the height the detail line + paddings leave (the hints are a + // safe-area inset, already out of this budget) — capped so it never dwarfs a large iPad and + // clamped by width on a narrow screen. + let reserved: CGFloat = compact ? 72 : 96 // detail line + spacers + let coverHeight = min(360, min(max(140, size.height - reserved), size.width * 0.9)) + let coverWidth = coverHeight * 2 / 3 + + VStack(spacing: 0) { + Spacer(minLength: 4) + carousel(coverWidth: coverWidth, coverHeight: coverHeight) + detailPanel + .padding(.top, 12) + Spacer(minLength: 4) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func carousel(coverWidth: CGFloat, coverHeight: CGFloat) -> some View { + GamepadCarousel( + items: games, + selection: $selection, + itemWidth: coverWidth, + spacing: 34, + onActivate: { onLaunch?($0.id) }, + onBack: { onDismiss?() }, + shoulderJump: 5 + ) { game in + cover(game, width: coverWidth, height: coverHeight) + } + .frame(height: coverHeight + 44) + } + + /// One cover + the coverflow recede. Every continuous visual reads the scroll view's own + /// per-frame `phase` (real distance-from-centered), so the tilt tracks what's actually on screen + /// mid-scroll. `.shadow` isn't a `VisualEffect`, so it's baked constant into the card; the + /// scale/rotation/opacity ramp already makes the centered cover prominent. + private func cover(_ game: GameEntry, width: CGFloat, height: CGFloat) -> some View { + PosterImage(candidates: game.art.posterCandidates, title: game.title, session: imageSession) + .frame(width: width, height: height) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay(alignment: .topLeading) { StoreBadge(isCustom: game.isCustom) } + .overlay { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(.white.opacity(0.12), lineWidth: 1) + } + .shadow(color: .black.opacity(0.5), radius: 16, y: 12) + .scrollTransition { content, phase in + let v = phase.value + let d = CGFloat(min(abs(v), 1)) + let scale = 1 - d * 0.24 + let rot = v * -38 + let anchor: UnitPoint = v < 0 ? .trailing : .leading + let bright = Double(-d * 0.22) + let fade = Double(1 - d * 0.38) + return content + .scaleEffect(scale) + .rotation3DEffect( + .degrees(rot), axis: (x: 0, y: 1, z: 0), anchor: anchor, perspective: 0.55) + .brightness(bright) + .opacity(fade) + } + } + + /// The centered title + store tag — empty (not hidden) so the layout doesn't jump. + @ViewBuilder private var detailPanel: some View { + let game = games.first { $0.id == selection } + VStack(spacing: 6) { + Text(game?.title ?? " ") + .font(.geist(compact ? 22 : 25, .bold, relativeTo: .title)) + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.75) + .multilineTextAlignment(.center) + if let game { + Text(game.isCustom ? "CUSTOM" : "STEAM") + .font(.geist(11, .semibold, relativeTo: .caption2)) + .tracking(1.2) + .foregroundStyle(.white.opacity(0.5)) + } + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 24) + .animation(.smooth(duration: 0.25), value: selection) + } + + // MARK: - Hint bar (pinned bottom-leading via safeAreaInset) + + private var hintBar: some View { + HStack(spacing: 18) { + if onLaunch != nil { + hint(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Launch") + } + hint(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Close") + } + .font(.geist(14, .semibold, relativeTo: .subheadline)) + .foregroundStyle(.white.opacity(0.85)) + } + + private func hint(glyph: String, text: String) -> some View { + HStack(spacing: 7) { + Image(systemName: glyph) + .font(.system(size: 19)) + .foregroundStyle(.white) + Text(text) + } + .fixedSize() // keep glyph + label together; never truncate a hint mid-word + } + + /// The active controller's real glyph for a button (Xbox "B", DualSense ◯, …) via + /// `sfSymbolsName`; a generic fallback before a controller profile resolves. + private func buttonGlyph( + _ button: KeyPath, fallback: String + ) -> String { + GamepadManager.shared.active?.controller.extendedGamepad?[keyPath: button].sfSymbolsName + ?? fallback + } +} +#endif diff --git a/clients/apple/Sources/PunktfunkClient/LibraryView.swift b/clients/apple/Sources/PunktfunkClient/LibraryView.swift index 4de9833..ae776f2 100644 --- a/clients/apple/Sources/PunktfunkClient/LibraryView.swift +++ b/clients/apple/Sources/PunktfunkClient/LibraryView.swift @@ -5,6 +5,11 @@ import PunktfunkKit import SwiftUI +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif struct LibraryView: View { @ObservedObject var store: HostStore @@ -12,10 +17,25 @@ struct LibraryView: View { /// Tapping a title starts a session that asks the host to launch it (the library id is passed /// through). `nil` ⇒ browse-only (cards aren't tappable). var onLaunch: ((String) -> Void)? = nil + @Environment(\.dismiss) private var dismiss @State private var games: [GameEntry] = [] @State private var loading = false @State private var errorText: String? + /// Authenticated session for cover-art fetches (the same paired identity + host pinning as the + /// list fetch, reused across every poster in the grid). Built alongside `games` in `load()`; + /// torn down on disappear since it isn't one-shot like `LibraryClient.fetch`'s own session. + @State private var imageSession: URLSession? + #if os(iOS) + // Gamepad-driven browsing is iOS/iPadOS-only — see HomeView's identical gate. tvOS keeps its + // existing plain-grid presentation of this same view unchanged. + @ObservedObject private var gamepadManager = GamepadManager.shared + @AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true + private var gamepadUIActive: Bool { + GamepadUIEnvironment.isActive( + gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled) + } + #endif var body: some View { content @@ -29,8 +49,20 @@ struct LibraryView: View { #else ToolbarItem(placement: .primaryAction) { reloadButton } #endif + // A gamepad-only user can't swipe-to-dismiss the sheet this view is presented in + // (ContentView's `.sheet(item: $libraryTarget)`) — give it a focusable, dpad-reachable + // Close action. tvOS already has its own pushed-navigation back (Menu button). + #if !os(tvOS) + ToolbarItem(placement: .cancellationAction) { + Button("Close") { dismiss() } + } + #endif } .task { await load() } + .onDisappear { + imageSession?.finishTasksAndInvalidate() + imageSession = nil + } } @ViewBuilder private var content: some View { @@ -42,7 +74,17 @@ struct LibraryView: View { } else if games.isEmpty { emptyState } else { + #if os(iOS) + if gamepadUIActive { + LibraryCoverflowView( + games: games, imageSession: imageSession, onLaunch: onLaunch, + onDismiss: { dismiss() }) + } else { + grid + } + #else grid + #endif } } @@ -51,10 +93,10 @@ struct LibraryView: View { LazyVGrid(columns: columns, spacing: 18) { ForEach(games) { game in if let onLaunch { - Button { onLaunch(game.id) } label: { GameCard(game: game) } + Button { onLaunch(game.id) } label: { GameCard(game: game, imageSession: imageSession) } .buttonStyle(.plain) } else { - GameCard(game: game) + GameCard(game: game, imageSession: imageSession) } } } @@ -125,6 +167,13 @@ struct LibraryView: View { certPEM: identity.certPEM, keyPEM: identity.keyPEM, hostFingerprint: current.pinnedSHA256) + imageSession?.finishTasksAndInvalidate() + imageSession = try LibraryImageLoader.session( + address: current.address, + port: current.effectiveMgmtPort, + certPEM: identity.certPEM, + keyPEM: identity.keyPEM, + hostFingerprint: current.pinnedSHA256) } catch { games = [] errorText = (error as? LibraryError)?.errorDescription ?? error.localizedDescription @@ -137,23 +186,30 @@ struct LibraryView: View { /// (portrait → header → hero) and finally a text placeholder. private struct GameCard: View { let game: GameEntry + let imageSession: URLSession? var body: some View { VStack(alignment: .leading, spacing: 6) { - PosterImage(candidates: game.art.posterCandidates, title: game.title) + PosterImage(candidates: game.art.posterCandidates, title: game.title, session: imageSession) .aspectRatio(2.0 / 3.0, contentMode: .fit) .frame(maxWidth: .infinity) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) - .overlay(alignment: .topLeading) { storeBadge } + .overlay(alignment: .topLeading) { StoreBadge(isCustom: game.isCustom) } Text(game.title) .font(.geist(12, relativeTo: .caption)) .lineLimit(2) .foregroundStyle(.secondary) } } +} - private var storeBadge: some View { - Text(game.isCustom ? "Custom" : "Steam") +/// The store-provenance badge (Steam vs. a user-curated custom entry) overlaid on a poster — +/// shared by the touch grid's `GameCard` and the gamepad coverflow's cover cell. +struct StoreBadge: View { + let isCustom: Bool + + var body: some View { + Text(isCustom ? "Custom" : "Steam") .font(.geist(11, .semibold, relativeTo: .caption2)) .padding(.horizontal, 6) .padding(.vertical, 3) @@ -162,31 +218,62 @@ private struct GameCard: View { } } -/// Sequentially tries cover-art URLs, advancing past any that fail to load, then a placeholder. -private struct PosterImage: View { +#if canImport(UIKit) +private typealias PlatformImage = UIImage +#elseif canImport(AppKit) +private typealias PlatformImage = NSImage +#endif + +private extension Image { + init(platformImage: PlatformImage) { + #if canImport(UIKit) + self.init(uiImage: platformImage) + #elseif canImport(AppKit) + self.init(nsImage: platformImage) + #endif + } +} + +/// Sequentially tries cover-art URLs over `session` (so a paired client can reach the host's own +/// art proxy, not just public CDNs — see `LibraryImageLoader`), advancing past any that fail to +/// load, then a placeholder. The loaded image is hard-clipped to fill the card's actual frame +/// regardless of its own aspect ratio: a portrait capsule fills it as intended, but a fallback +/// banner (wide hero/header art, used when a title has no portrait capsule) would otherwise report +/// a much wider intrinsic size than the card and overflow into neighboring cards. Not `private` — +/// the gamepad coverflow (`LibraryCoverflowView`) reuses it directly rather than re-fetching art. +struct PosterImage: View { let candidates: [URL] let title: String + let session: URLSession? @State private var index = 0 + @State private var image: PlatformImage? 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 - } + Group { + if let image { + Image(platformImage: image) + .resizable() + .scaledToFill() + } else if index < candidates.count { + ZStack { placeholder; ProgressView() } + } else { + placeholder } - .id(index) // recreate AsyncImage so it loads the newly-selected URL - } else { - placeholder } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .clipped() + .task(id: index) { await loadCurrent() } + } + + private func loadCurrent() async { + guard index < candidates.count else { return } + guard let session, let data = try? await session.data(from: candidates[index]).0, + let loaded = PlatformImage(data: data) + else { + index += 1 // advance to the next candidate (or past the end → placeholder) + return + } + image = loaded } private var placeholder: some View { diff --git a/clients/apple/Sources/PunktfunkClient/SettingsView.swift b/clients/apple/Sources/PunktfunkClient/SettingsView.swift index f21b34b..e6aef36 100644 --- a/clients/apple/Sources/PunktfunkClient/SettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/SettingsView.swift @@ -38,6 +38,7 @@ struct SettingsView: View { #endif #if os(iOS) @AppStorage(DefaultsKey.pointerCapture) private var pointerCapture = true + @AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true // The sidebar selection drives the detail pane on iPad and the pushed sub-page on iPhone. // Width class decides the initial value: nil on iPhone (show the category list first), // General on iPad (a two-column layout should never open with an empty detail). @@ -738,6 +739,9 @@ struct SettingsView: View { Text(option.label).tag(option.tag) } } + #if os(iOS) + Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled) + #endif #if DEBUG && !os(tvOS) Button("Test Controller…") { showControllerTest = true } .disabled(gamepads.active == nil) @@ -746,9 +750,17 @@ struct SettingsView: View { } header: { Text("Controllers") } footer: { - Text(Self.controllersFooter) - .font(.geist(12, relativeTo: .caption)) - .foregroundStyle(.secondary) + // The iOS-only gamepad-UI blurb is appended here, not merged into the shared + // `controllersFooter` constant — tvOS's `tvBody` reuses that exact string (line ~348) + // for its own footer and has no such toggle to describe. + VStack(alignment: .leading, spacing: 6) { + Text(Self.controllersFooter) + #if os(iOS) + Text(Self.gamepadUIFooter) + #endif + } + .font(.geist(12, relativeTo: .caption)) + .foregroundStyle(.secondary) } } @@ -856,6 +868,15 @@ struct SettingsView: View { + "from the next session. Two identical controllers may swap a manual selection " + "after reconnecting." + #if os(iOS) + private static let gamepadUIFooter = + "When a controller is connected, the host list and game library switch to a " + + "controller-friendly layout — larger focus targets and a swipeable cover browser " + + "for the library. Turn this off to always use the touch layout. (The system may " + + "still move basic focus with a controller connected even with this off — that's " + + "outside the app's control.)" + #endif + /// "Use controller" choices: Automatic, every forwardable controller, and — so a stale /// pin stays visible instead of leaving the Picker selection tag-less — any pinned id /// that is NOT among the selectable (extended) entries, present-but-unusable included. diff --git a/clients/apple/Sources/PunktfunkKit/ClientTLS.swift b/clients/apple/Sources/PunktfunkKit/ClientTLS.swift index f0e0b35..28bdc49 100644 --- a/clients/apple/Sources/PunktfunkKit/ClientTLS.swift +++ b/clients/apple/Sources/PunktfunkKit/ClientTLS.swift @@ -4,10 +4,16 @@ // // To present that identity, URLSession needs a SecIdentity (cert + private key pair). The client // stores its identity as PEM (rcgen ECDSA P-256, PKCS#8 key). We rebuild a SecIdentity natively: -// CryptoKit parses the key → its X9.63 form → a SecKey, the cert PEM → a SecCertificate, and -// SecIdentityCreateWithCertificate pairs them via the Keychain. This is macOS-only -// (SecIdentityCreateWithCertificate is unavailable on iOS — that path will need a PKCS#12); the -// client library is macOS-first today. +// CryptoKit parses the key → its X9.63 form → a SecKey, the cert PEM → a SecCertificate. From +// there the two platform families diverge because `SecIdentityCreateWithCertificate` — the +// straight-line "pair these two" API — is macOS-only: +// - macOS: SecIdentityCreateWithCertificate does the pairing directly once the key is in the +// Keychain (a plain `SecItemAdd`). +// - iOS/tvOS: that API is unavailable. Instead, add BOTH the key and the certificate to the +// Keychain (under the same application tag) and query `kSecClassIdentity` — the system +// correlates a stored cert against a stored key with a matching public key and vends the pair +// as one `SecIdentity`, no PKCS#12 needed. This is the standard non-macOS technique for +// "I already have a raw cert + key, not a .p12". import CryptoKit import Foundation @@ -18,15 +24,12 @@ private let tlsLog = Logger(subsystem: "io.unom.punktfunk", category: "library-t enum ClientTLS { enum TLSError: LocalizedError { - case unsupportedPlatform case badKey(String) case badCert case identity(String) var errorDescription: String? { switch self { - case .unsupportedPlatform: - return "Library mTLS is supported on macOS only right now." case .badKey(let why): return "Couldn't load the client key: \(why)" case .badCert: return "Couldn't load the client certificate." case .identity(let why): return "Couldn't build the client identity: \(why)" @@ -45,9 +48,8 @@ enum ClientTLS { } /// Build a `SecIdentity` from the client's PEM cert + PKCS#8 P-256 key. Pairs them via the - /// Keychain (the key is stored once under a stable tag, so repeat calls reuse it). + /// Keychain (stored once under a stable tag, so repeat calls reuse it). static func makeIdentity(certPEM: String, keyPEM: String) throws -> SecIdentity { - #if os(macOS) // Key: CryptoKit accepts the SEC1 or PKCS#8 PEM; its x963 form is what SecKey wants. let priv: P256.Signing.PrivateKey do { @@ -71,9 +73,11 @@ enum ClientTLS { let cert = SecCertificateCreateWithData(nil, certDER as CFData) else { throw TLSError.badCert } + let tag = Data("io.unom.punktfunk.library-client-key".utf8) + + #if os(macOS) // The key must live in a Keychain for SecIdentityCreateWithCertificate to pair it with the // cert. Add it under a stable tag; a duplicate just means a previous fetch already did. - let tag = Data("io.unom.punktfunk.library-client-key".utf8) let add: [CFString: Any] = [ kSecClass: kSecClassKey, kSecAttrApplicationTag: tag, @@ -81,7 +85,7 @@ enum ClientTLS { ] let status = SecItemAdd(add as CFDictionary, nil) guard status == errSecSuccess || status == errSecDuplicateItem else { - throw TLSError.identity("keychain add failed (OSStatus \(status))") + throw TLSError.identity("keychain add key failed (OSStatus \(status))") } var identity: SecIdentity? @@ -91,20 +95,64 @@ enum ClientTLS { } return identity #else - throw TLSError.unsupportedPlatform + // Add the key (tagged) and the certificate (matched to it by public key) separately — + // a duplicate of either just means a previous fetch already added it. + let addKey: [CFString: Any] = [ + kSecClass: kSecClassKey, + kSecAttrApplicationTag: tag, + kSecValueRef: secKey, + ] + let keyStatus = SecItemAdd(addKey as CFDictionary, nil) + guard keyStatus == errSecSuccess || keyStatus == errSecDuplicateItem else { + throw TLSError.identity("keychain add key failed (OSStatus \(keyStatus))") + } + + let addCert: [CFString: Any] = [ + kSecClass: kSecClassCertificate, + kSecValueRef: cert, + ] + let certStatus = SecItemAdd(addCert as CFDictionary, nil) + guard certStatus == errSecSuccess || certStatus == errSecDuplicateItem else { + throw TLSError.identity("keychain add certificate failed (OSStatus \(certStatus))") + } + + // The system correlates the just-added cert against the tagged key (matching public key) + // and vends the pair as a kSecClassIdentity — the tag filter here matches the KEY half. + var identityRef: CFTypeRef? + let query: [CFString: Any] = [ + kSecClass: kSecClassIdentity, + kSecAttrApplicationTag: tag, + kSecReturnRef: true, + ] + let idStatus = SecItemCopyMatching(query as CFDictionary, &identityRef) + guard idStatus == errSecSuccess, let identityRef else { + throw TLSError.identity("SecItemCopyMatching(kSecClassIdentity) (OSStatus \(idStatus))") + } + // Safe: a kSecClassIdentity query with kSecReturnRef always vends a SecIdentity. + return (identityRef as! SecIdentity) // swiftlint:disable:this force_cast #endif } } /// URLSession delegate that pins the host's self-signed cert (by the fingerprint the client -/// already trusts) and presents the client identity for the mTLS client-cert challenge. +/// already trusts) and presents the client identity for the mTLS client-cert challenge — but ONLY +/// for challenges from `host`:`port` (the punktfunk host itself). A session built with this +/// delegate is safe to reuse for OTHER origins too (e.g. a GOG/Heroic/Xbox cover-art CDN): a +/// non-matching origin falls through to `.performDefaultHandling`, i.e. normal system trust +/// evaluation and no client cert — exactly what `URLSession.shared` would have done. Without the +/// host scoping, pinning would reject every external origin's cert (its fingerprint never matches +/// the host's) and the client identity would leak to servers that didn't ask for it. final class LibraryTLSDelegate: NSObject, URLSessionDelegate { private let identity: SecIdentity private let pinnedHostFingerprint: Data? // SHA-256 of the host cert DER; nil = accept any (TOFU) + private let host: String + private let port: Int - init(identity: SecIdentity, pinnedHostFingerprint: Data?) { + init(identity: SecIdentity, pinnedHostFingerprint: Data?, host: String, port: UInt16) { self.identity = identity self.pinnedHostFingerprint = pinnedHostFingerprint + self.host = host + self.port = Int(port) } func urlSession( @@ -112,11 +160,16 @@ final class LibraryTLSDelegate: NSObject, URLSessionDelegate { didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void ) { - switch challenge.protectionSpace.authenticationMethod { + let space = challenge.protectionSpace + guard space.host == host, space.port == port else { + completionHandler(.performDefaultHandling, nil) + return + } + switch space.authenticationMethod { case NSURLAuthenticationMethodServerTrust: // Pin the host cert by fingerprint — the host is self-signed (the client trusts it the // same way the QUIC session does). No pin yet (TOFU) → accept the presented leaf. - guard let trust = challenge.protectionSpace.serverTrust, + guard let trust = space.serverTrust, let leaf = (SecTrustCopyCertificateChain(trust) as? [SecCertificate])?.first else { completionHandler(.cancelAuthenticationChallenge, nil) diff --git a/clients/apple/Sources/PunktfunkKit/DefaultsKeys.swift b/clients/apple/Sources/PunktfunkKit/DefaultsKeys.swift index 642a42d..9b85529 100644 --- a/clients/apple/Sources/PunktfunkKit/DefaultsKeys.swift +++ b/clients/apple/Sources/PunktfunkKit/DefaultsKeys.swift @@ -48,4 +48,8 @@ public enum DefaultsKey { /// Which corner the statistics overlay sits in — a `HUDPlacement` raw value /// ("topLeading"/"topTrailing"/"bottomLeading"/"bottomTrailing"). Default top-trailing. public static let hudPlacement = "punktfunk.hudPlacement" + /// iOS/iPadOS: switch the host list and game library to a controller-friendly layout + /// (larger focus targets, a coverflow-style library) whenever a gamepad is connected. On by + /// default; see `GamepadUIEnvironment.isActive`. + public static let gamepadUIEnabled = "punktfunk.gamepadUIEnabled" } diff --git a/clients/apple/Sources/PunktfunkKit/GamepadMenuInput.swift b/clients/apple/Sources/PunktfunkKit/GamepadMenuInput.swift new file mode 100644 index 0000000..212652e --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/GamepadMenuInput.swift @@ -0,0 +1,149 @@ +// Explicit left-stick/dpad-driven menu navigation for the gamepad UI's host carousel and library +// coverflow (iOS/iPadOS only — see GamepadUIEnvironment). +// +// Polls the active controller at 60 Hz rather than installing `valueChangedHandler`/ +// `pressedChangedHandler` callbacks — mirroring `ControllerTestView`'s "Input" card (see its own +// comment: "Poll the live controller ... — no handlers installed"), the one thing in this codebase +// already confirmed on real hardware to read a controller reliably outside a streaming session. Two +// earlier versions of this class both installed handlers directly (first reading the dpad's combined +// `.xAxis`/`.yAxis`, then its discrete `.isPressed` states, matching `GamepadCapture`'s pattern) and +// neither one's callbacks fired on-device even though the SAME controller's input showed up correctly +// in `ControllerTestView`'s poll-based readout — so polling isn't just a style choice here, it's the +// only approach confirmed to actually work outside a stream. Being read-only, it also can't conflict +// with `GamepadCapture` installing its own handlers once a stream starts — there's nothing to hand +// off or race over. +// +// The button set mirrors a console launcher: A confirms, B backs out, Y is a screen's secondary +// action, and the shoulders (L1/R1) are optional fast "jump" steps. Directional moves auto-repeat +// on a held stick/dpad after an initial delay; every button is edge-triggered (fires once per press). + +import Foundation +import GameController + +@MainActor +public final class GamepadMenuInput { + public enum Direction: Equatable, Sendable { + case up, down, left, right + } + + private let manager: GamepadManager + private var pollTimer: Timer? + private var isActive = false + private var currentDirection: Direction? + private var repeatTimer: Timer? + private var wasConfirmPressed = false + private var wasSecondaryPressed = false + private var wasBackPressed = false + private var wasLeftShoulderPressed = false + private var wasRightShoulderPressed = false + + /// Discrete directional move — already debounced (fires once on a fresh press, then repeats + /// on a hold after an initial delay, like a standard menu). + public var onMove: ((Direction) -> Void)? + /// Button A (or equivalent primary action) — edge-triggered, fires once per press. + public var onConfirm: (() -> Void)? + /// Button Y (or equivalent secondary action, e.g. "open library") — edge-triggered. + public var onSecondary: (() -> Void)? + /// Button B (or equivalent back/dismiss) — edge-triggered. + public var onBack: (() -> Void)? + /// Shoulder buttons (L1 `false` / R1 `true`) — edge-triggered fast-jump steps, optional per + /// screen. Unset ⇒ the shoulders do nothing. + public var onShoulder: ((Bool) -> Void)? + + /// Stick magnitude below this reads as neutral (dead zone). + private let deadzone: Float = 0.5 + private let initialRepeatDelay: TimeInterval = 0.38 + private let repeatInterval: TimeInterval = 0.16 + private let pollInterval: TimeInterval = 1.0 / 60.0 + + public init(manager: GamepadManager) { + self.manager = manager + } + + public func start() { + guard !isActive else { return } + isActive = true + let timer = Timer(timeInterval: pollInterval, repeats: true) { [weak self] _ in + Task { @MainActor in self?.poll() } + } + RunLoop.main.add(timer, forMode: .common) + pollTimer = timer + } + + public func stop() { + isActive = false + pollTimer?.invalidate() + pollTimer = nil + repeatTimer?.invalidate() + repeatTimer = nil + currentDirection = nil + wasConfirmPressed = false + wasSecondaryPressed = false + wasBackPressed = false + wasLeftShoulderPressed = false + wasRightShoulderPressed = false + } + + /// Reads `manager.active` fresh every tick (no persistent binding to a specific controller + /// needed) — a disconnect/reconnect or a controller switch is just picked up on the next poll. + private func poll() { + guard isActive, let gamepad = manager.active?.controller.extendedGamepad else { return } + + edge(gamepad.buttonA.isPressed, &wasConfirmPressed) { onConfirm?() } + edge(gamepad.buttonY.isPressed, &wasSecondaryPressed) { onSecondary?() } + edge(gamepad.buttonB.isPressed, &wasBackPressed) { onBack?() } + edge(gamepad.leftShoulder.isPressed, &wasLeftShoulderPressed) { onShoulder?(false) } + edge(gamepad.rightShoulder.isPressed, &wasRightShoulderPressed) { onShoulder?(true) } + + updateDirection(directionFrom(gamepad)) + } + + /// Fire `action` on the rising edge of `pressed`, tracking the last state in `was`. + private func edge(_ pressed: Bool, _ was: inout Bool, _ action: () -> Void) { + if pressed, !was { action() } + was = pressed + } + + /// The current requested direction: the left stick is the primary/natural input; the dpad is an + /// alternative. Read via discrete `.isPressed` / analog `.value` (never the dpad's combined axis + /// — the first version of this class did that and it silently never registered a press on-device). + private func directionFrom(_ gamepad: GCExtendedGamepad) -> Direction? { + let stick = gamepad.leftThumbstick + let x = stick.xAxis.value + let y = stick.yAxis.value + if abs(x) > abs(y), abs(x) > deadzone { + return x > 0 ? .right : .left + } else if abs(y) > deadzone { + return y > 0 ? .up : .down + } + let dpad = gamepad.dpad + if dpad.left.isPressed { return .left } + if dpad.right.isPressed { return .right } + if dpad.up.isPressed { return .up } + if dpad.down.isPressed { return .down } + return nil + } + + private func updateDirection(_ direction: Direction?) { + guard direction != currentDirection else { return } + repeatTimer?.invalidate() + repeatTimer = nil + currentDirection = direction + guard let direction else { return } + onMove?(direction) + // First repeat after a longer delay (so a quick tap doesn't double-move), then steady. + let timer = Timer(timeInterval: initialRepeatDelay, repeats: false) { [weak self] _ in + Task { @MainActor in + guard let self else { return } + self.repeatTimer?.invalidate() + let repeating = Timer(timeInterval: self.repeatInterval, repeats: true) { [weak self] _ in + Task { @MainActor in self?.onMove?(direction) } + } + RunLoop.main.add(repeating, forMode: .common) + self.repeatTimer = repeating + } + } + RunLoop.main.add(timer, forMode: .common) + repeatTimer = timer + } +} diff --git a/clients/apple/Sources/PunktfunkKit/GamepadUIEnvironment.swift b/clients/apple/Sources/PunktfunkKit/GamepadUIEnvironment.swift new file mode 100644 index 0000000..753a804 --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/GamepadUIEnvironment.swift @@ -0,0 +1,20 @@ +// Whether the iOS/iPadOS UI should be in its controller-friendly mode (larger focus targets on +// the host grid, the coverflow library browser instead of the plain grid). A pure function, not a +// singleton: the reactivity comes from callers already observing `GamepadManager.shared` and the +// `DefaultsKey.gamepadUIEnabled` @AppStorage themselves (the same local-read pattern SettingsView +// already uses for GamepadManager), so this stays the single place the two combine without adding +// a second ObservableObject or an environment key nobody else needs. + +import Foundation + +public enum GamepadUIEnvironment { + /// `enabledSetting` is the user's Settings toggle (`DefaultsKey.gamepadUIEnabled`); + /// `gamepadConnected` is `GamepadManager.shared.active != nil` — active only once a usable + /// controller is actually attached (a non-extended-profile device leaves `active` nil, which + /// keeps the touch UI). A `Bool` rather than the `DiscoveredController` itself: this function's + /// whole job is the AND, so there's nothing else to inspect, and it keeps the helper testable + /// without a real `GCController` (which XCTest can't construct). + public static func isActive(gamepadConnected: Bool, enabledSetting: Bool) -> Bool { + enabledSetting && gamepadConnected + } +} diff --git a/clients/apple/Sources/PunktfunkKit/LibraryClient.swift b/clients/apple/Sources/PunktfunkKit/LibraryClient.swift index e268d21..d02e0d3 100644 --- a/clients/apple/Sources/PunktfunkKit/LibraryClient.swift +++ b/clients/apple/Sources/PunktfunkKit/LibraryClient.swift @@ -92,7 +92,8 @@ public enum LibraryClient { throw LibraryError.unreachable( (error as? LocalizedError)?.errorDescription ?? error.localizedDescription) } - let delegate = LibraryTLSDelegate(identity: identity, pinnedHostFingerprint: hostFingerprint) + let delegate = LibraryTLSDelegate( + identity: identity, pinnedHostFingerprint: hostFingerprint, host: address, port: port) let session = URLSession(configuration: .ephemeral, delegate: delegate, delegateQueue: nil) defer { session.finishTasksAndInvalidate() } @@ -108,7 +109,16 @@ public enum LibraryClient { } switch http.statusCode { case 200: - return try JSONDecoder().decode([GameEntry].self, from: data) + var games = try JSONDecoder().decode([GameEntry].self, from: data) + // Steam art now comes back as host-relative proxy paths (`/api/v1/library/art/...`, + // see the host's `library::steam_art`) so they work the same regardless of which + // interface/port the client reached the host on. Resolve them against THIS host now, + // so every other consumer just sees ordinary absolute URLs. + let base = url + for i in games.indices { + games[i].art = games[i].art.resolved(against: base) + } + return games case 401: throw LibraryError.unauthorized default: @@ -116,3 +126,43 @@ public enum LibraryClient { } } } + +extension Artwork { + /// Rewrite any host-relative field (one starting with `/`) into an absolute URL against `base`. + /// External CDN URLs (GOG/Heroic/Xbox) and `data:` URLs (Lutris) already don't start with `/`, + /// so they pass through unchanged. `internal` (not `fileprivate`) so `LibraryClientTests` can + /// exercise it directly without a live host. + func resolved(against base: URL) -> Artwork { + func abs(_ s: String?) -> String? { + guard let s, s.hasPrefix("/") else { return s } + return URL(string: s, relativeTo: base)?.absoluteString ?? s + } + var a = self + a.portrait = abs(a.portrait) + a.hero = abs(a.hero) + a.logo = abs(a.logo) + a.header = abs(a.header) + return a + } +} + +/// Builds the authenticated `URLSession` the library UI uses to fetch cover-art images — the same +/// paired identity + host pinning as [`LibraryClient.fetch`], reused across a whole grid's worth of +/// poster loads (this session is NOT one-shot: callers own its lifetime and should invalidate it +/// when the view goes away). Safe to use for every candidate URL a `GameEntry`'s `Artwork` carries: +/// `LibraryTLSDelegate` only pins/presents-cert for the host itself, deferring to normal system +/// trust + no client cert for any other origin (an external CDN URL). +public enum LibraryImageLoader { + public static func session( + address: String, + port: UInt16 = punktfunkDefaultMgmtPort, + certPEM: String, + keyPEM: String, + hostFingerprint: Data? + ) throws -> URLSession { + let identity = try ClientTLS.makeIdentity(certPEM: certPEM, keyPEM: keyPEM) + let delegate = LibraryTLSDelegate( + identity: identity, pinnedHostFingerprint: hostFingerprint, host: address, port: port) + return URLSession(configuration: .default, delegate: delegate, delegateQueue: nil) + } +} diff --git a/clients/apple/Sources/PunktfunkKit/MenuHaptics.swift b/clients/apple/Sources/PunktfunkKit/MenuHaptics.swift new file mode 100644 index 0000000..870eb68 --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/MenuHaptics.swift @@ -0,0 +1,119 @@ +// Controller-side haptic feedback for the gamepad menu UI (the host launcher + the library +// coverflow). The couch case is the whole point: the user is holding a game controller, not the +// iPhone/iPad, so a device-only `.sensoryFeedback` tick never reaches their hands — this plays a +// short CoreHaptics transient on the ACTIVE controller instead, so a dpad move / launch / end-stop +// is felt on the pad. (The views pair this with `.sensoryFeedback` so a touch/handheld user still +// gets the device Taptic tick; the two are independent channels, and both firing is intended.) +// +// This is menu-only — it never runs during a stream (the session's own GamepadFeedback owns the +// controller then), so there's no contention over the pad's haptic engine. Like GamepadMenuInput, +// it reads `GamepadManager.shared.active` fresh and rebuilds its engine when the controller +// changes, so a hot-swapped pad just starts buzzing on the next tick. Everything is best-effort: +// a pad with no haptics (many Xbox pads on iOS, a Siri Remote) silently no-ops. + +import CoreHaptics +import Foundation +import GameController + +@MainActor +public final class MenuHaptics { + private let manager: GamepadManager + /// The engine for the controller it was built against — dropped and rebuilt when `active` + /// changes (identity compare) or after a stop/reset handler fires. + private var engine: CHHapticEngine? + private weak var boundController: GCController? + + public init(manager: GamepadManager) { + self.manager = manager + } + + /// A light, crisp detent — one per menu step. Deliberately tiny so a held direction repeating + /// at ~5 Hz reads as a smooth ratchet rather than a jackhammer. + public func move() { + play(intensity: 0.45, sharpness: 0.75, duration: 0.02) + } + + /// A fuller, rounder pulse on confirm/launch — the "you did the thing" thunk. + public func confirm() { + play(intensity: 1.0, sharpness: 0.55, duration: 0.055) + } + + /// A soft, dull bump when a move is refused at the end of a non-wrapping list — low sharpness so + /// it feels like hitting a wall, distinct from the crisp `move()` detent. + public func boundary() { + play(intensity: 0.7, sharpness: 0.18, duration: 0.06) + } + + /// Release the engine and forget the controller — call on the menu screen's disappear so the + /// pad's haptic engine isn't held open while streaming or on the touch UI. + public func stop() { + engine?.stop(completionHandler: nil) + engine = nil + boundController = nil + } + + /// Fire a single transient. Rebuilds the engine against the current active controller if it + /// changed; swallows every failure (a pad without a haptics engine, a transient XPC hiccup) — + /// menu haptics are a nicety, never a correctness path. + private func play(intensity: Float, sharpness: Float, duration: TimeInterval) { + guard let controller = manager.active?.controller else { + // No pad (or a non-forwardable one): nothing to buzz. Drop any stale engine. + if boundController != nil { stop() } + return + } + guard let engine = engine(for: controller) else { return } + let event = CHHapticEvent( + eventType: .hapticTransient, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: intensity), + CHHapticEventParameter(parameterID: .hapticSharpness, value: sharpness), + ], + relativeTime: 0, + duration: duration) + do { + let player = try engine.makePlayer(with: CHHapticPattern(events: [event], parameters: [])) + try player.start(atTime: CHHapticTimeImmediate) + } catch { + // The engine went stale between builds (stopped/reset). Drop it; the next tick rebuilds. + self.engine = nil + boundController = nil + } + } + + /// The started engine for `controller`, (re)built on first use or after a controller swap. + private func engine(for controller: GCController) -> CHHapticEngine? { + if let engine, boundController === controller { return engine } + engine?.stop(completionHandler: nil) + engine = nil + boundController = nil + guard let built = controller.haptics?.createEngine(withLocality: .default) else { return nil } + // Menu ticks carry no audio — keep the engine out of the app's audio session (the same + // discipline the session RumbleRenderer uses). + built.playsHapticsOnly = true + // The haptic server can pull the engine out from under us (backgrounding, an audio + // interruption, a controller drop); drop our reference so the next tick lazily rebuilds + // rather than throwing forever. + built.stoppedHandler = { [weak self] _ in + Task { @MainActor in self?.dropEngine(if: controller) } + } + built.resetHandler = { [weak self] in + Task { @MainActor in self?.dropEngine(if: controller) } + } + do { + try built.start() + } catch { + return nil + } + engine = built + boundController = controller + return built + } + + /// Drop the cached engine only if it's still the one for `controller` — a handler firing after a + /// swap must not clobber the freshly built engine for the new pad. + private func dropEngine(if controller: GCController) { + guard boundController === controller else { return } + engine = nil + boundController = nil + } +} diff --git a/clients/apple/Sources/PunktfunkKit/SessionAudio.swift b/clients/apple/Sources/PunktfunkKit/SessionAudio.swift index c723fec..6268248 100644 --- a/clients/apple/Sources/PunktfunkKit/SessionAudio.swift +++ b/clients/apple/Sources/PunktfunkKit/SessionAudio.swift @@ -154,12 +154,17 @@ private func wireChannelLayout(channels: Int) -> AVAudioChannelLayout? { layout.pointee.mChannelLayoutTag = kAudioChannelLayoutTag_UseChannelDescriptions layout.pointee.mChannelBitmap = AudioChannelBitmap(rawValue: 0) layout.pointee.mNumberChannelDescriptions = UInt32(labels.count) - let descs = UnsafeMutableBufferPointer( - start: &layout.pointee.mChannelDescriptions, count: labels.count) - for (i, lbl) in labels.enumerated() { - descs[i] = AudioChannelDescription( - mChannelLabel: lbl, mChannelFlags: AudioChannelFlags(rawValue: 0), - mCoordinates: (0, 0, 0)) + // `mChannelDescriptions` is the C variable-length tail array (declared `[1]`, over-allocated + // above). Scope the pointer with `withUnsafeMutablePointer` — taking `&…mChannelDescriptions` + // inline yields a pointer valid only for that expression, so building a buffer from it that + // outlives the call is a dangling-pointer bug. Inside the closure it stays valid while we fill it. + withUnsafeMutablePointer(to: &layout.pointee.mChannelDescriptions) { tail in + let descs = UnsafeMutableBufferPointer(start: tail, count: labels.count) + for (i, lbl) in labels.enumerated() { + descs[i] = AudioChannelDescription( + mChannelLabel: lbl, mChannelFlags: AudioChannelFlags(rawValue: 0), + mCoordinates: (0, 0, 0)) + } } return AVAudioChannelLayout(layout: layout) } diff --git a/clients/apple/Tests/PunktfunkKitTests/GamepadUIEnvironmentTests.swift b/clients/apple/Tests/PunktfunkKitTests/GamepadUIEnvironmentTests.swift new file mode 100644 index 0000000..ae44e99 --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/GamepadUIEnvironmentTests.swift @@ -0,0 +1,14 @@ +// GamepadUIEnvironment.isActive is a pure AND — table-tested exhaustively over its 2x2 inputs. + +import XCTest + +@testable import PunktfunkKit + +final class GamepadUIEnvironmentTests: XCTestCase { + func testActiveOnlyWhenEnabledAndConnected() { + XCTAssertTrue(GamepadUIEnvironment.isActive(gamepadConnected: true, enabledSetting: true)) + XCTAssertFalse(GamepadUIEnvironment.isActive(gamepadConnected: true, enabledSetting: false)) + XCTAssertFalse(GamepadUIEnvironment.isActive(gamepadConnected: false, enabledSetting: true)) + XCTAssertFalse(GamepadUIEnvironment.isActive(gamepadConnected: false, enabledSetting: false)) + } +} diff --git a/clients/apple/Tests/PunktfunkKitTests/LibraryClientTests.swift b/clients/apple/Tests/PunktfunkKitTests/LibraryClientTests.swift index cb7cab0..055a6d7 100644 --- a/clients/apple/Tests/PunktfunkKitTests/LibraryClientTests.swift +++ b/clients/apple/Tests/PunktfunkKitTests/LibraryClientTests.swift @@ -60,4 +60,22 @@ final class LibraryClientTests: XCTestCase { XCTAssertTrue(Artwork().posterCandidates.isEmpty) } + + func testArtworkResolvedRewritesOnlyHostRelativePaths() { + let base = URL(string: "https://192.168.1.70:47990/api/v1/library")! + // Steam art now comes back as host-relative proxy paths; external CDN URLs (GOG/Heroic/Xbox) + // and `data:` URLs (Lutris) are untouched. + let art = Artwork( + portrait: "/api/v1/library/art/steam:3527290/portrait", + hero: "https://cdn.example.com/hero.jpg", + logo: nil, + header: "/api/v1/library/art/steam:3527290/header") + let resolved = art.resolved(against: base) + XCTAssertEqual( + resolved.portrait, "https://192.168.1.70:47990/api/v1/library/art/steam:3527290/portrait") + XCTAssertEqual( + resolved.header, "https://192.168.1.70:47990/api/v1/library/art/steam:3527290/header") + XCTAssertEqual(resolved.hero, "https://cdn.example.com/hero.jpg") // unchanged + XCTAssertNil(resolved.logo) + } } diff --git a/crates/punktfunk-host/Cargo.toml b/crates/punktfunk-host/Cargo.toml index 33f4bec..d24d941 100644 --- a/crates/punktfunk-host/Cargo.toml +++ b/crates/punktfunk-host/Cargo.toml @@ -62,6 +62,8 @@ utoipa-scalar = { version = "0.3", features = ["axum"] } # Drive the management API router in-process (no socket) in the handler tests. tower = { version = "0.5", features = ["util"] } http-body-util = "0.1" +# Disposable directory fixtures for the Steam local-librarycache scan tests (library.rs). +tempfile = "3" # Opus encode for the host->client audio plane — stereo (`opus::Encoder`) AND 5.1/7.1 surround # (`opus::MSEncoder`, the safe multistream API the crate exposes; no `audiopus_sys` needed). The diff --git a/crates/punktfunk-host/src/library.rs b/crates/punktfunk-host/src/library.rs index 5cc1305..582e035 100644 --- a/crates/punktfunk-host/src/library.rs +++ b/crates/punktfunk-host/src/library.rs @@ -114,18 +114,129 @@ impl LibraryProvider for SteamProvider { } } -/// The Steam CDN poster/hero/logo/header for an appid (public, no auth). Not every appid has a +/// Steam art, keyed to one of the four [`Artwork`] fields. Newer/recently-updated titles serve +/// their CDN assets from a per-asset-hash path the client can't predict (e.g. +/// `.../apps///header.jpg`), so the flat legacy URL [`steam_art`] guesses 404s for them — +/// [`steam_art_bytes`] is the robust resolver: local Steam cache (exact, no guessing) first, the +/// flat CDN URL as a fallback (still correct for the many titles that haven't been re-hashed). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ArtKind { + Portrait, + Hero, + Logo, + Header, +} + +impl ArtKind { + pub fn parse(s: &str) -> Option { + match s { + "portrait" => Some(Self::Portrait), + "hero" => Some(Self::Hero), + "logo" => Some(Self::Logo), + "header" => Some(Self::Header), + _ => None, + } + } + + /// Filenames Steam itself caches this kind under in `appcache/librarycache///`, + /// tried in order (the 2x portrait, when present, is the sharper asset). + fn local_filenames(self) -> &'static [&'static str] { + match self { + Self::Portrait => &["library_600x900_2x.jpg", "library_600x900.jpg"], + Self::Hero => &["library_hero.jpg"], + Self::Logo => &["logo.png"], + // Steam's local cache names the header asset differently from the store CDN's + // `header.jpg` (see `cdn_filename`). + Self::Header => &["library_header.jpg"], + } + } + + /// The legacy flat-URL filename on the public Steam CDN (works for any title the CDN hasn't + /// migrated to a per-asset hash path). + fn cdn_filename(self) -> &'static str { + match self { + Self::Portrait => "library_600x900.jpg", + Self::Hero => "library_hero.jpg", + Self::Logo => "logo.png", + Self::Header => "header.jpg", + } + } +} + +/// The Steam CDN poster/hero/logo/header for an appid — relative proxy paths the *client* resolves +/// against the host it just talked to (so they work the same whichever interface/port the client +/// reached the host on), backed by [`steam_art_bytes`] on the way out. Not every appid has a /// 600×900 capsule, but `header.jpg` is effectively universal — the client falls back to it. fn steam_art(appid: u32) -> Artwork { - let base = format!("https://cdn.cloudflare.steamstatic.com/steam/apps/{appid}"); + let url = |kind: &str| Some(format!("/api/v1/library/art/steam:{appid}/{kind}")); Artwork { - portrait: Some(format!("{base}/library_600x900.jpg")), - hero: Some(format!("{base}/library_hero.jpg")), - logo: Some(format!("{base}/logo.png")), - header: Some(format!("{base}/header.jpg")), + portrait: url("portrait"), + hero: url("hero"), + logo: url("logo"), + header: url("header"), } } +/// Resolve one Steam cover-art kind to bytes: the host's own local Steam cache first (exact — it's +/// literally what the user's Steam client already shows for this title), the legacy flat CDN URL +/// as a fallback. `None` when neither has it (the client then falls through to its next art +/// candidate). Blocking (disk + network) — call off the async runtime. +pub fn steam_art_bytes(appid: u32, kind: ArtKind) -> Option<(Vec, String)> { + steam_local_art_bytes(appid, kind).or_else(|| { + let url = format!( + "https://cdn.cloudflare.steamstatic.com/steam/apps/{appid}/{}", + kind.cdn_filename() + ); + fetch_image(&url) + }) +} + +/// Cap on a local librarycache file we'll read into memory — generous for a Steam-quality JPEG/PNG +/// (these run well under 2 MiB in practice) while bounding a pathological file. +const LOCAL_ART_MAX_BYTES: u64 = 8 * 1024 * 1024; + +/// `appcache/librarycache///` across every Steam root, for whichever +/// `` subdirectory actually has this kind's file (Steam reuses one hash dir per asset +/// version, so there's normally exactly one candidate per kind). +fn steam_local_art_bytes(appid: u32, kind: ArtKind) -> Option<(Vec, String)> { + steam_roots() + .into_iter() + .find_map(|root| find_local_art_file(&root, appid, kind)) + .and_then(|path| { + let bytes = std::fs::read(&path).ok()?; + let ctype = if path.extension().is_some_and(|e| e == "png") { + "image/png" + } else { + "image/jpeg" + }; + Some((bytes, ctype.to_string())) + }) +} + +/// Find this kind's cached file under one Steam root's `appcache/librarycache///`, +/// trying each hash subdirectory (normally just one) and each candidate filename in priority +/// order. Pure path lookup — no env/HOME dependency — so it's unit-testable against a plain +/// directory fixture. +fn find_local_art_file(root: &Path, appid: u32, kind: ArtKind) -> Option { + let cache_dir = root + .join("appcache") + .join("librarycache") + .join(appid.to_string()); + let hash_dirs = std::fs::read_dir(&cache_dir).ok()?; + for hash_dir in hash_dirs.flatten() { + for name in kind.local_filenames() { + let path = hash_dir.path().join(name); + let Ok(meta) = std::fs::metadata(&path) else { + continue; + }; + if meta.len() > 0 && meta.len() <= LOCAL_ART_MAX_BYTES { + return Some(path); + } + } + } + None +} + /// Candidate Steam roots (classic, Flatpak, Deck) that actually exist, canonicalized + deduped. #[cfg(not(target_os = "windows"))] fn steam_roots() -> Vec { @@ -1166,6 +1277,22 @@ fn fetch_image(url: &str) -> Option<(Vec, String)> { /// `(bytes, content-type)`. Resolves the id against the host's OWN library. Blocking — call off the /// async runtime (e.g. `spawn_blocking`). pub fn fetch_box_art(id: &str) -> Option<(Vec, String)> { + // Steam's `Artwork` fields are now relative proxy paths (see `steam_art`) the *client* resolves + // against the host — meaningless to `fetch_image`, which expects an absolute URL. Resolve + // those kinds directly instead of going through the URL fields. + if let Some(appid) = id + .strip_prefix("steam:") + .and_then(|s| s.parse::().ok()) + { + return [ + ArtKind::Portrait, + ArtKind::Header, + ArtKind::Hero, + ArtKind::Logo, + ] + .into_iter() + .find_map(|kind| steam_art_bytes(appid, kind)); + } let g = all_games().into_iter().find(|g| g.id == id)?; [g.art.portrait, g.art.header, g.art.hero, g.art.logo] .into_iter() @@ -1635,13 +1762,59 @@ mod tests { } #[test] - fn steam_art_uses_cdn_by_appid() { + fn steam_art_points_at_the_host_art_proxy() { let art = steam_art(570); assert_eq!( art.portrait.as_deref(), - Some("https://cdn.cloudflare.steamstatic.com/steam/apps/570/library_600x900.jpg") + Some("/api/v1/library/art/steam:570/portrait") ); - assert!(art.header.unwrap().ends_with("/570/header.jpg")); + assert_eq!( + art.header.as_deref(), + Some("/api/v1/library/art/steam:570/header") + ); + } + + #[test] + fn art_kind_parses_known_names_only() { + assert_eq!(ArtKind::parse("portrait"), Some(ArtKind::Portrait)); + assert_eq!(ArtKind::parse("hero"), Some(ArtKind::Hero)); + assert_eq!(ArtKind::parse("logo"), Some(ArtKind::Logo)); + assert_eq!(ArtKind::parse("header"), Some(ArtKind::Header)); + assert_eq!(ArtKind::parse("background"), None); + } + + #[test] + fn find_local_art_file_matches_the_hashed_librarycache_layout() { + let dir = tempfile::tempdir().unwrap(); + let cache = dir + .path() + .join("appcache/librarycache/3527290/480bd879ac737921bfa2529a6fea15961267ad21"); + std::fs::create_dir_all(&cache).unwrap(); + std::fs::write(cache.join("library_600x900.jpg"), b"not really a jpeg").unwrap(); + + let found = find_local_art_file(dir.path(), 3527290, ArtKind::Portrait).unwrap(); + assert_eq!(found, cache.join("library_600x900.jpg")); + // A kind with no cached file, and an appid with no cache dir at all, both miss cleanly. + assert_eq!( + find_local_art_file(dir.path(), 3527290, ArtKind::Hero), + None + ); + assert_eq!( + find_local_art_file(dir.path(), 570, ArtKind::Portrait), + None + ); + } + + #[test] + fn find_local_art_file_prefers_the_2x_portrait() { + let dir = tempfile::tempdir().unwrap(); + let cache = dir.path().join("appcache/librarycache/570/somehash"); + std::fs::create_dir_all(&cache).unwrap(); + std::fs::write(cache.join("library_600x900.jpg"), b"1x").unwrap(); + std::fs::write(cache.join("library_600x900_2x.jpg"), b"2x").unwrap(); + + let found = find_local_art_file(dir.path(), 570, ArtKind::Portrait).unwrap(); + assert_eq!(found, cache.join("library_600x900_2x.jpg")); } #[cfg(not(windows))] diff --git a/crates/punktfunk-host/src/mgmt.rs b/crates/punktfunk-host/src/mgmt.rs index 1341711..1258273 100644 --- a/crates/punktfunk-host/src/mgmt.rs +++ b/crates/punktfunk-host/src/mgmt.rs @@ -171,6 +171,7 @@ fn api_router_parts() -> (Router>, utoipa::openapi::OpenApi) { .routes(routes!(get_library)) .routes(routes!(create_custom_game)) .routes(routes!(update_custom_game, delete_custom_game)) + .routes(routes!(get_library_art)) .routes(routes!(stats_capture_start)) .routes(routes!(stats_capture_stop)) .routes(routes!(stats_capture_status)) @@ -544,7 +545,7 @@ async fn require_auth(State(st): State>, req: Request, next: Next /// edit the library). `/health` is handled separately (always open). fn cert_may_access(method: &Method, path: &str) -> bool { method == Method::GET - && matches!( + && (matches!( path, "/api/v1/host" | "/api/v1/compositors" @@ -555,7 +556,7 @@ fn cert_may_access(method: &Method, path: &str) -> bool { // library MUTATIONS (POST/PUT/DELETE /library/custom) stay token-only via the exact // GET-path match above. | "/api/v1/library" - ) + ) || path.starts_with("/api/v1/library/art/")) } /// Compare SHA-256 digests instead of the strings — constant-time with respect to the @@ -1276,6 +1277,45 @@ async fn delete_custom_game(Path(id): Path) -> Response { } } +/// Fetch one cover-art image for a library entry +/// +/// Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams +/// the image bytes. For a Steam title, the host's own local Steam cache is tried first (exact — +/// it's what the user's Steam client already shows for it), the public Steam CDN's flat URL +/// convention as a fallback (newer titles' CDN assets can live at a per-asset-hash path the host +/// can't predict, in which case this 404s and the client falls through to its next art candidate). +/// Only Steam ids are backed today; any other store 404s. +#[utoipa::path( + get, + path = "/library/art/{id}/{kind}", + tag = "library", + operation_id = "getLibraryArt", + params( + ("id" = String, Path, description = "The store-qualified library id, e.g. `steam:570`"), + ("kind" = String, Path, description = "`portrait` | `hero` | `logo` | `header`"), + ), + responses( + (status = OK, description = "Image bytes", content_type = "image/jpeg"), + (status = UNAUTHORIZED, description = "Missing or invalid credentials", body = ApiError), + (status = NOT_FOUND, description = "No art of that kind for that id", body = ApiError), + ) +)] +async fn get_library_art(Path((id, kind)): Path<(String, String)>) -> Response { + let Some(kind) = crate::library::ArtKind::parse(&kind) else { + return api_error(StatusCode::NOT_FOUND, "unknown art kind"); + }; + let Some(appid) = id + .strip_prefix("steam:") + .and_then(|s| s.parse::().ok()) + else { + return api_error(StatusCode::NOT_FOUND, "no art proxy for this store"); + }; + match tokio::task::spawn_blocking(move || crate::library::steam_art_bytes(appid, kind)).await { + Ok(Some((bytes, ctype))) => ([(header::CONTENT_TYPE, ctype)], bytes).into_response(), + _ => api_error(StatusCode::NOT_FOUND, "no art of that kind for this title"), + } +} + // --------------------------------------------------------------------------------------- // Streaming stats capture (design/stats-capture-plan.md §2) // --------------------------------------------------------------------------------------- @@ -1694,6 +1734,21 @@ mod tests { StatusCode::UNAUTHORIZED, "a paired cert must reach the library from a LAN peer" ); + + // The per-image art proxy (`/api/v1/library/art/{id}/{kind}`) is a prefix match in + // `cert_may_access`, not an exact one (dynamic id/kind segments) — exercise it directly. An + // unknown `kind` 404s before any disk/network I/O, so this stays a fast, deterministic check + // of the auth gate (not of art resolution, which `library::tests` covers). + let mut req = get_req("/api/v1/library/art/steam:570/not-a-real-kind"); + req.extensions_mut().insert(PeerAddr(lan)); + req.extensions_mut() + .insert(PeerCertFingerprint(Some(fp.to_string()))); + assert_eq!( + app.clone().oneshot(req).await.expect("infallible").status(), + StatusCode::NOT_FOUND, + "a paired cert must reach the per-image library art proxy from a LAN peer \ + (and an unknown kind 404s, rather than ever being rejected as unauthorized)" + ); } #[tokio::test] diff --git a/scripts/build-xcframework.sh b/scripts/build-xcframework.sh old mode 100644 new mode 100755