Files
punktfunk/clients/apple/Sources/PunktfunkClient/HostStore.swift
T
enricobuehler 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
feat(apple/library): experimental game-library browser (flagged off)
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>
2026-06-14 14:28:16 +00:00

114 lines
4.6 KiB
Swift

// Saved hosts + their pinned identities, persisted as JSON in UserDefaults.
//
// Trust model (client side of punktfunk/1): the host serves a persistent certificate and
// logs its SHA-256 fingerprint at startup. The pin lands here one of two ways the
// trust-on-first-use prompt (user compares the observed fingerprint against the host's
// log) or the SPAKE2 PIN pairing ceremony (PairSheet; mutually verified, and the host
// stores our identity from ClientIdentityStore in return). Every later connect passes
// the pin into punktfunk-core, which refuses a host whose identity changed. Hosts running
// --require-pairing only admit paired clients, so for them pairing is the only way in.
import Foundation
import PunktfunkKit
import SwiftUI
struct StoredHost: Identifiable, Codable, Hashable {
var id = UUID()
var name: String
var address: String
var port: UInt16 = 9777
/// SHA-256 of the host's certificate, set after the user explicitly trusted it.
var pinnedSHA256: Data?
/// Last time a streaming session actually started (nil until the first one).
var lastConnected: Date?
/// Management-API port for the experimental library browser (distinct from the data-plane
/// `port`). Optional (NOT a defaulted non-optional) so older saved hosts whose JSON lacks
/// this key still decode: synthesized Decodable ignores property defaults but treats a
/// missing Optional as nil. Resolve via `effectiveMgmtPort`.
var mgmtPort: UInt16?
/// Bearer token for the management API (the host's `--mgmt-token`). Required for any
/// non-loopback mgmt bind; nil until the user enters it.
var mgmtToken: String?
var displayName: String { name.isEmpty ? address : name }
var effectiveMgmtPort: UInt16 { mgmtPort ?? punktfunkDefaultMgmtPort }
}
extension StoredHost {
/// True when a live mDNS advert (`DiscoveredHost`) describes THIS saved host drives the
/// "online" indicator and de-dupes the discovered section. Matched by certificate
/// fingerprint when both sides carry it (so it survives a DHCP address change), otherwise
/// by address:port. Online detection is LAN-scoped: a host not advertising on this network
/// (off, or a remote/cross-subnet address) simply won't match "not seen", not proven off.
func matches(_ discovered: DiscoveredHost) -> Bool {
if let pin = pinnedSHA256, let fp = discovered.fingerprintHex,
pin.hexLower == fp.lowercased() {
return true
}
return address == discovered.host && port == discovered.port
}
}
private extension Data {
/// Lowercase hex, no separators to compare a pinned fingerprint against the mDNS `fp`.
var hexLower: String { map { String(format: "%02x", $0) }.joined() }
}
@MainActor
final class HostStore: ObservableObject {
private static let key = DefaultsKey.hosts
@Published var hosts: [StoredHost] {
didSet { persist() }
}
init() {
if let data = UserDefaults.standard.data(forKey: Self.key),
let decoded = try? JSONDecoder().decode([StoredHost].self, from: data) {
hosts = decoded
} else {
hosts = []
}
}
func add(_ host: StoredHost) {
hosts.append(host)
}
func remove(_ host: StoredHost) {
hosts.removeAll { $0.id == host.id }
}
func markConnected(_ hostID: UUID) {
guard let i = hosts.firstIndex(where: { $0.id == hostID }) else { return }
hosts[i].lastConnected = Date()
}
func pin(_ hostID: UUID, fingerprint: Data) {
guard let i = hosts.firstIndex(where: { $0.id == hostID }) else { return }
hosts[i].pinnedSHA256 = fingerprint
}
/// Drop the pinned identity (e.g. after a legitimate host reinstall) the next
/// connect goes through the trust prompt again.
func forgetIdentity(_ host: StoredHost) {
guard let i = hosts.firstIndex(where: { $0.id == host.id }) else { return }
hosts[i].pinnedSHA256 = nil
}
/// Persist the management-API endpoint for the (experimental) library browser. An empty
/// token is stored as nil (no credential).
func setMgmt(_ hostID: UUID, port: UInt16, token: String) {
guard let i = hosts.firstIndex(where: { $0.id == hostID }) else { return }
hosts[i].mgmtPort = port
let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
hosts[i].mgmtToken = trimmed.isEmpty ? nil : trimmed
}
private func persist() {
if let data = try? JSONEncoder().encode(hosts) {
UserDefaults.standard.set(data, forKey: Self.key)
}
}
}