1b610d6bf5
ci / web (push) Successful in 31s
ci / docs-site (push) Successful in 31s
apple / swift (push) Successful in 1m15s
ci / rust (push) Successful in 2m4s
ci / bench (push) Successful in 1m38s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
deb / build-publish (push) Successful in 2m23s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 4m55s
docker / deploy-docs (push) Successful in 17s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 4m28s
Plan step 3 — the Apple client surfaces the host's game library, behind a feature flag (`DefaultsKey.libraryEnabled`, default OFF). Browsing only; launching a chosen title is step 4. - PunktfunkKit `LibraryClient`: Codable GameEntry/Artwork/LaunchSpec mirroring crates/punktfunk-host/src/library.rs, and an async fetch of GET /api/v1/library with a bearer token. Typed LibraryError guides setup (the common case is "needs a --mgmt-token"). `Artwork.posterCandidates` = portrait → header → hero. - `LibraryView`: cross-platform poster grid (LazyVGrid, AsyncImage that walks the art candidates past load failures to a text placeholder), a store badge, and an inline Connection form (mgmt port + token) that surfaces when the API is unreachable / 401 / no token set. Read-only. - StoredHost gains `mgmtPort`/`mgmtToken` (the mgmt API is a distinct port from the data plane and needs a token off-loopback). Both OPTIONAL — synthesized Decodable ignores property defaults but treats a missing Optional as nil, so older saved hosts decode unchanged (a defaulted non-optional would wipe the list). HostStore.setMgmt. - Entry point: a flag-gated "Browse Library…" host-card context action → LibraryView (sheet on macOS/iOS, pushed on tvOS), mirroring the pair/speed-test plumbing. Plus a Settings "Experimental" toggle. Can't compile Swift on the Linux dev box; CI (apple.yml: swift build + swift test on the mac mini) verifies the macOS path. Added LibraryClientTests (decode + art order) for `swift test`. iOS/tvOS-only branches mirror existing patterns. Live-verify on the Mac pending. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
174 lines
6.8 KiB
Swift
174 lines
6.8 KiB
Swift
// The host grid's cards: a saved host (tap to connect, context menu) and an mDNS-discovered
|
|
// host (tap to save + connect). Both share the same platform-tuned sizing.
|
|
|
|
import PunktfunkKit
|
|
import SwiftUI
|
|
|
|
/// Shared host-card sizing — touch-first on iOS, compact on macOS/tvOS.
|
|
private struct CardMetrics {
|
|
let iconSize: CGFloat
|
|
let iconBox: CGFloat
|
|
let cardPadding: CGFloat
|
|
let nameFont: Font
|
|
|
|
static var current: CardMetrics {
|
|
#if os(iOS)
|
|
CardMetrics(iconSize: 56, iconBox: 76, cardPadding: 28, nameFont: .title3.weight(.semibold))
|
|
#else
|
|
CardMetrics(iconSize: 42, iconBox: 56, cardPadding: 18, nameFont: .headline)
|
|
#endif
|
|
}
|
|
}
|
|
|
|
/// A saved host. The accent ring marks the most-recently-connected one; the context menu
|
|
/// pairs / speed-tests / forgets / removes. Disabled while a session is busy.
|
|
struct HostCardView: View {
|
|
let host: StoredHost
|
|
/// Currently advertising on the LAN (matched against live mDNS discovery). False means
|
|
/// "not seen on this network" — off, or a remote/cross-subnet host we can't observe.
|
|
let isOnline: Bool
|
|
let isConnecting: Bool
|
|
let isMostRecent: Bool
|
|
let isBusy: Bool
|
|
let onConnect: () -> Void
|
|
let onPair: () -> Void
|
|
let onSpeedTest: () -> Void
|
|
let onForget: () -> Void
|
|
let onRemove: () -> Void
|
|
/// Open the experimental library browser — nil (no menu item) unless the feature flag is on.
|
|
var onBrowseLibrary: (() -> Void)? = nil
|
|
|
|
var body: some View {
|
|
let m = CardMetrics.current
|
|
return Button(action: onConnect) {
|
|
VStack(spacing: 10) {
|
|
ZStack {
|
|
Image(systemName: "play.display")
|
|
.font(.system(size: m.iconSize, weight: .light))
|
|
.foregroundStyle(.tint)
|
|
.opacity(isConnecting ? 0.3 : 1)
|
|
if isConnecting {
|
|
ProgressView()
|
|
}
|
|
}
|
|
.frame(height: m.iconBox)
|
|
VStack(spacing: 2) {
|
|
HStack(spacing: 6) {
|
|
// Presence dot: green = advertising on the LAN now; grey = not seen.
|
|
Circle()
|
|
.fill(isOnline ? Color.green : Color.secondary.opacity(0.35))
|
|
.frame(width: 7, height: 7)
|
|
.accessibilityLabel(isOnline ? "Online" : "Offline")
|
|
Text(host.displayName)
|
|
.font(m.nameFont)
|
|
.lineLimit(1)
|
|
}
|
|
HStack(spacing: 4) {
|
|
if host.pinnedSHA256 != nil {
|
|
Image(systemName: "lock.fill")
|
|
.font(.system(size: 9))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Text("\(host.address):\(String(host.port))")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
if let last = host.lastConnected {
|
|
Text("Connected \(last, format: .relative(presentation: .named))")
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, m.cardPadding)
|
|
.padding(.horizontal, 12)
|
|
#if !os(tvOS)
|
|
// tvOS: the .card button style owns platter + focus motion — extra chrome
|
|
// inside it mutes the grow/tilt. Material + accent ring are for pointer UIs.
|
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
|
|
.overlay {
|
|
if isMostRecent {
|
|
RoundedRectangle(cornerRadius: 14)
|
|
.strokeBorder(Color.accentColor.opacity(0.35), lineWidth: 1.5)
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
#if os(tvOS)
|
|
.buttonStyle(.card)
|
|
#else
|
|
.buttonStyle(.plain)
|
|
#endif
|
|
.disabled(isBusy)
|
|
.contextMenu {
|
|
Button("Pair with PIN…", action: onPair)
|
|
Button("Test Network Speed…", action: onSpeedTest)
|
|
if let onBrowseLibrary {
|
|
Button("Browse Library…", action: onBrowseLibrary)
|
|
}
|
|
if host.pinnedSHA256 != nil {
|
|
Button("Forget Identity", action: onForget)
|
|
}
|
|
Button("Remove", role: .destructive, action: onRemove)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A host found on the LAN but not yet saved. A dashed ring distinguishes it from saved cards;
|
|
/// tapping saves it and connects (or pairs, if the host requires it).
|
|
struct DiscoveredCardView: View {
|
|
let discovered: DiscoveredHost
|
|
let isBusy: Bool
|
|
let onConnect: () -> Void
|
|
|
|
var body: some View {
|
|
let m = CardMetrics.current
|
|
return Button(action: onConnect) {
|
|
VStack(spacing: 10) {
|
|
Image(systemName: "play.display")
|
|
.font(.system(size: m.iconSize, weight: .light))
|
|
.foregroundStyle(.tint)
|
|
.frame(height: m.iconBox)
|
|
VStack(spacing: 2) {
|
|
Text(discovered.name)
|
|
.font(m.nameFont)
|
|
.lineLimit(1)
|
|
HStack(spacing: 4) {
|
|
Image(systemName: discovered.requiresPairing ? "lock.fill" : "wifi")
|
|
.font(.system(size: 9))
|
|
.foregroundStyle(.secondary)
|
|
Text("\(discovered.host):\(String(discovered.port))")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
Text(discovered.requiresPairing ? "Pairing required" : "Discovered")
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, m.cardPadding)
|
|
.padding(.horizontal, 12)
|
|
#if !os(tvOS)
|
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 14)
|
|
.strokeBorder(
|
|
Color.secondary.opacity(0.25),
|
|
style: StrokeStyle(lineWidth: 1, dash: [4, 3]))
|
|
}
|
|
#endif
|
|
}
|
|
#if os(tvOS)
|
|
.buttonStyle(.card)
|
|
#else
|
|
.buttonStyle(.plain)
|
|
#endif
|
|
.disabled(isBusy)
|
|
}
|
|
}
|