Files
punktfunk/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift
T
enricobuehler 244cafe005 refactor(apple): move the mgmt API off URLSession so ATS can stay on
The previous commit bought the library back on VPN/remote hosts by declaring
NSAllowsArbitraryLoads, which works but is blunt: it drops ATS for ALL of the
app's URLSession traffic, and the only other traffic is third-party cover-art
CDN fetches -- the one surface we never wanted to open. It cost the TLS-version
floor, forward secrecy, and the cleartext-HTTP block on URLs the host supplies
at runtime (custom entries and scanner plugins carry arbitrary ones).

So take the host out of the URL loading system instead. MgmtTransport speaks
HTTPS over Network.framework, which ATS does not govern, and states the trust
rule we actually mean in a verify block: the leaf must hash to the fingerprint
pinned during PIN pairing. That is the same rule punktfunk-core has always
applied on the QUIC stream plane -- which is exactly why streaming kept working
over Tailscale while the library did not.

With that, the ATS dict is gone and ATS is fully enforced again. Cover-art CDN
fetches keep ordinary URLSession with full system trust evaluation and no client
certificate. LibraryTLSDelegate is deleted; nothing pins through URLSession now.

Also here:
- HTTPResponse: just enough HTTP/1.1 to read one GET -- status, headers,
  Content-Length and chunked framing (hyper streams the art proxy chunked). A
  body shorter than Content-Length throws instead of returning partial JSON,
  which would otherwise read as "this host has no games".
- LibraryError.pinMismatch, so a re-keyed host says "pair again" rather than
  sending someone to debug their network.
- 403 joins 401 as "unauthorized": both are the host declining the certificate.
- baseURL brackets IPv6 literals; the old string interpolation did not.
- 11 tests covering the framings hyper emits and the failure modes that would
  otherwise be silent.

Known trade-off: no connection reuse yet, so each poster costs its own
handshake where the pooled URLSession shared one. Fine on a LAN, worth revisiting
for large libraries over a high-latency link.
2026-08-08 01:09:04 +02:00

218 lines
10 KiB
Swift

// The gamepad-driven presentation of the game library (iOS/iPadOS/macOS/tvOS — 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) || os(macOS) || os(tvOS)
import GameController
struct LibraryCoverflowView: View {
@Environment(\.gamepadInk) private var ink
let games: [GameEntry]
let artLoader: LibraryArtLoader?
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)?
/// Whether the carousel owns the controller — the in-place shell gates it (mid-transition,
/// and under the connect takeover after A launches a title, where this coverflow used to
/// keep polling underneath). Cover/sheet presentations keep the default.
var controllerActive = true
@Environment(\.gamepadHostedInShell) private var hostedInShell
#if os(iOS)
/// `.compact` in a landscape phone window — drives a tighter poster so everything still fits.
@Environment(\.verticalSizeClass) private var vSizeClass
private var compact: Bool { vSizeClass == .compact }
#else
private let compact = false // no size classes on macOS
#endif
@State private var selection: String?
/// How many covers have settled (art loaded, or every candidate exhausted).
@State private var artSettled = 0
/// The backstop below has fired: play the entrance regardless of what the art is doing.
@State private var artWaitOver = false
/// Whether the strip may play its entrance yet. Cards swinging in as grey placeholders and
/// then filling with artwork afterwards is the whole effect wasted, so the entrance waits for
/// the first few covers — every poster is fetched in parallel, so those land together and
/// cover the visible strip. The wait is capped: a slow or artless library still animates.
private var contentReady: Bool {
artWaitOver || artSettled >= min(4, games.count)
}
var body: some View {
GeometryReader { geo in
content(for: geo.size)
}
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
GamepadHintBar(hints: hints)
.padding(.leading, 22)
.padding(.vertical, compact ? 6 : 10)
}
// Hosted in the shell, the field is the shell's own persistent aurora (the library is
// an aurora screen — the calm mix simply stays 0, so nothing even chases).
.background {
if !hostedInShell { GamepadScreenBackground() }
}
// Publish the palette's ink to this screen (text, glass, accent, scrims) — a
// pale palette flips all of them, and no leaf should have to read the setting.
.gamepadPaletteInk()
// The entrance's backstop (see `contentReady`).
.task {
try? await Task.sleep(for: .milliseconds(700))
artWaitOver = true
}
}
@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) + (showsGroupHeading ? 26 : 0)
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)
if showsGroupHeading {
groupHeading.padding(.bottom, 6)
}
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,
isActive: controllerActive,
contentReady: contentReady
) { game, entrance in
cover(game, width: coverWidth, height: coverHeight, entrance: entrance)
}
.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, entrance: CardEntrance
) -> some View {
PosterImage(
candidates: game.art.posterCandidates, title: game.title, loader: artLoader,
onLoaded: { artSettled += 1 })
.frame(width: width, height: height)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay(alignment: .topLeading) {
// `solid`: a frosted chip can't sample a backdrop through this card's own
// composited transform, so it would only show up on the centred card.
StoreBadge(label: game.storeLabel, isLauncher: game.isLauncher, solid: true)
}
.overlay {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(ink.fg(0.12), lineWidth: 1)
}
.shadow(color: ink.shadow(0.5), radius: 16, y: 12)
// Beneath the scroll transition, never around it — see CardEntrance.
.modifier(entrance)
.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)
}
}
/// Does this library have both groups? Only then does the heading earn its row — a
/// launcher-less library gets exactly the layout it had before design D4.
private var showsGroupHeading: Bool {
games.contains(where: \.isLauncher) && games.contains { !$0.isLauncher }
}
/// Which group the cursor is in. A coverflow is one-dimensional, so instead of a second focus
/// rail (a whole new up/down nav model for two or three tiles) the heading names the group and
/// changes as the selection crosses the boundary — the launcher entries lead the strip.
private var groupHeading: some View {
let selected = games.first { $0.id == selection }
return Text(selected?.isLauncher == true ? "LAUNCHERS" : "GAMES")
.font(.geist(11, .semibold, relativeTo: .caption2))
.tracking(1.4)
.foregroundStyle(ink.fg(0.45))
}
/// 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(ink.fg)
.lineLimit(1)
.minimumScaleFactor(0.75)
.multilineTextAlignment(.center)
if let game {
// main's richer store label, in the palette's ink.
Text(
game.isLauncher
? "\(game.storeLabel.uppercased()) · LAUNCHER" : game.storeLabel.uppercased()
)
.font(.geist(11, .semibold, relativeTo: .caption2))
.tracking(1.2)
.foregroundStyle(ink.fg(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 hints: [GamepadHint] {
var hints: [GamepadHint] = []
if onLaunch != nil {
// You *open* a launcher and *launch* a game — the hint follows the focused entry.
let opens = games.first { $0.id == selection }?.isLauncher == true
hints.append(
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: opens ? "Open" : "Launch"))
}
hints.append(.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Close"))
return hints
}
}
#endif