feat(apple): gamepad ui
apple / swift (push) Successful in 1m5s
audit / cargo-audit (push) Successful in 1m19s
android / android (push) Successful in 4m21s
ci / web (push) Successful in 58s
ci / docs-site (push) Successful in 58s
ci / rust (push) Successful in 8m40s
release / apple (push) Successful in 9m10s
ci / bench (push) Successful in 4m39s
decky / build-publish (push) Successful in 14s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 29s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
deb / build-publish (push) Successful in 3m50s
apple / screenshots (push) Successful in 5m38s
flatpak / build-publish (push) Successful in 4m12s
windows-host / package (push) Successful in 19m17s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m15s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m5s
docker / deploy-docs (push) Successful in 18s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m1s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m53s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 3m24s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 3m30s
apple / swift (push) Successful in 1m5s
audit / cargo-audit (push) Successful in 1m19s
android / android (push) Successful in 4m21s
ci / web (push) Successful in 58s
ci / docs-site (push) Successful in 58s
ci / rust (push) Successful in 8m40s
release / apple (push) Successful in 9m10s
ci / bench (push) Successful in 4m39s
decky / build-publish (push) Successful in 14s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 29s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
deb / build-publish (push) Successful in 3m50s
apple / screenshots (push) Successful in 5m38s
flatpak / build-publish (push) Successful in 4m12s
windows-host / package (push) Successful in 19m17s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m15s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m5s
docker / deploy-docs (push) Successful in 18s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m1s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m53s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 3m24s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 3m30s
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Item: Identifiable, Card: View>: 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
|
||||
@@ -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<GCExtendedGamepad, GCControllerButtonInput>, 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
|
||||
@@ -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<GCExtendedGamepad, GCControllerButtonInput>, fallback: String
|
||||
) -> String {
|
||||
GamepadManager.shared.active?.controller.extendedGamepad?[keyPath: button].sfSymbolsName
|
||||
?? fallback
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user