feat(apple): gamepad UI v2 — controller settings + add host, aurora, macOS
Sources reorganized (client: Home/Session/Settings/Stores/Support/Trust; kit: Audio/Connection/Gamepad/Input/Support/Video/Views) with the big files split along the same seams. The gamepad mode is couch-complete, and now on macOS too (the living-room Mac case), not just iOS/iPadOS: - GamepadSettingsView: a console-style, fully controller-navigable settings screen (X from the launcher) — up/down moves focus, left/right steps values (clamped, boundary thud), A cycles/toggles, B closes; the focused row shows a one-line description. Backed by GamepadMenuList, the vertical sibling of GamepadCarousel, and SettingsOptions — the option lists hoisted out of SettingsView statics and shared by the touch, tvOS and gamepad settings. - GamepadAddHostView + GamepadKeyboard: register a host end to end with a pad — field rows open an on-screen controller keyboard (dpad grid, A types, X backspaces, B done); the launcher carousel ends in an Add Host tile, so the dead-end "add one with touch first" empty state is gone. - Launcher polish: contextual hint bar with the pad's real button glyphs, controller name + battery chip, one shared console chrome. - GamepadScreenBackground: an animated aurora (TimelineView-driven drifting blobs in the brand's violet family, breathing radii, slow hue shift, legibility scrim; freezes under Reduce Motion). Pure SwiftUI on purpose — a .metal library only bundles reliably in one of the two build systems (SPM vs the xcodeproj's synced folders) these sources compile under. - macOS port: settings/add-host/library present as sized sheets (a macOS sheet takes its content's IDEAL size, and the GeometryReader-driven screens collapsed to nothing), NSScreen-based mode lists, scroll indicators .never (the "always show scroll bars" setting overrides .hidden), tray scrims so scrolled rows dim under the pinned title/hints, extra title clearance, and a PUNKTFUNK_FORCE_GAMEPAD_UI=1 dev hook — launcher/settings/add-host/keyboard/ library render-verified live on a real Mac + LAN hosts. - GamepadMenuInput: X button support, and (re)start now snapshots held buttons so a controller handoff press never fires twice (the B that closed the keyboard no longer also cancels the screen underneath). - Cleanups: one "Connection failed" alert in ContentView instead of one per home screen; HostDiscovery.advertises/unsaved shared by both home screens. - host: can_encode_444 stub for the non-Linux/Windows host build (the macOS synthetic-source loopback used by the Swift tests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
// This client's persistent punktfunk/1 identity: a self-signed certificate + key (PEM),
|
||||
// generated once and stored in the data-protection Keychain (with a legacy file-keychain
|
||||
// fallback for unsigned builds — see `query(dataProtection:)`). The certificate's fingerprint is how
|
||||
// hosts recognize this client after PIN pairing — losing the key un-pairs this Mac from
|
||||
// every host, so the pair is presented on every connect but never regenerated once
|
||||
// stored. That invariant drives the error handling below: a Keychain that *refuses
|
||||
// access* (locked, ACL denied) is an error, not a first run — minting a replacement
|
||||
// would silently shadow the durable identity and break every existing pairing.
|
||||
|
||||
import Foundation
|
||||
import PunktfunkKit
|
||||
import Security
|
||||
|
||||
final class ClientIdentityStore: @unchecked Sendable {
|
||||
static let shared = ClientIdentityStore()
|
||||
|
||||
enum IdentityError: Error {
|
||||
/// The Keychain refused access (locked, ACL denied, …) — an identity may exist.
|
||||
case keychain(OSStatus)
|
||||
/// The identity lives only in memory (Keychain write failed); good enough to
|
||||
/// present on a connect, not good enough to pair against.
|
||||
case notPersisted
|
||||
}
|
||||
|
||||
private let lock = NSLock()
|
||||
private var cached: (identity: ClientIdentity, persisted: Bool)?
|
||||
|
||||
/// The identity to present when connecting, generating + persisting it on first run.
|
||||
/// `persisted == false` means the Keychain write failed and it lives only in memory —
|
||||
/// fine for a session, see `loadForPairing()` for the strict variant. Blocking
|
||||
/// (Keychain + key generation) — call off the main actor.
|
||||
func load() throws -> (identity: ClientIdentity, persisted: Bool) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
if let cached { return cached }
|
||||
|
||||
switch copyStored() {
|
||||
case .found(let identity):
|
||||
let hit = (identity, true)
|
||||
cached = hit
|
||||
return hit
|
||||
case .absent:
|
||||
break // genuine first run — mint below
|
||||
case .corrupt:
|
||||
// Our own item, undecodable: the pairings it backed are unusable either
|
||||
// way, so deliberately self-heal by replacing it (both keychains, best-effort).
|
||||
SecItemDelete(Self.query(dataProtection: true) as CFDictionary)
|
||||
SecItemDelete(Self.query(dataProtection: false) as CFDictionary)
|
||||
case .denied(let status):
|
||||
throw IdentityError.keychain(status)
|
||||
}
|
||||
|
||||
let fresh = try generateIdentity()
|
||||
let entry: (ClientIdentity, Bool)
|
||||
switch add(fresh) {
|
||||
case errSecSuccess:
|
||||
entry = (fresh, true)
|
||||
case errSecDuplicateItem:
|
||||
// Lost a first-run race with another instance — the stored identity is the
|
||||
// durable one, never overwrite it.
|
||||
if case .found(let identity) = copyStored() {
|
||||
entry = (identity, true)
|
||||
} else {
|
||||
entry = (fresh, false)
|
||||
}
|
||||
default:
|
||||
entry = (fresh, false)
|
||||
}
|
||||
cached = entry
|
||||
return entry
|
||||
}
|
||||
|
||||
/// Pairing variant: the host is about to durably trust this identity, so it must be
|
||||
/// durable on our side too — a memory-only identity would evaporate on relaunch and
|
||||
/// strand the pairing.
|
||||
func loadForPairing() throws -> ClientIdentity {
|
||||
let (identity, persisted) = try load()
|
||||
guard persisted else { throw IdentityError.notPersisted }
|
||||
return identity
|
||||
}
|
||||
|
||||
private struct Stored: Codable {
|
||||
var certPEM: String
|
||||
var keyPEM: String
|
||||
}
|
||||
|
||||
private enum ReadResult {
|
||||
case found(ClientIdentity)
|
||||
case absent
|
||||
case corrupt
|
||||
case denied(OSStatus)
|
||||
}
|
||||
|
||||
/// Item coordinates. We prefer the DATA-PROTECTION keychain: with the app's
|
||||
/// `keychain-access-groups` entitlement, items there are gated by the app's identity
|
||||
/// (team + bundle id) instead of a per-binary ACL — so a SIGNED build reads them across
|
||||
/// rebuilds with NO Keychain prompt (a per-binary ACL re-prompts on every resign, which
|
||||
/// is why an ad-hoc-signed app asked every launch). An ad-hoc / unsigned build (e.g.
|
||||
/// `swift run`) has no such entitlement — `SecItem*` returns `errSecMissingEntitlement`
|
||||
/// there, and we fall back to the legacy file keychain (still works, with the old prompt).
|
||||
private static func query(dataProtection: Bool) -> [String: Any] {
|
||||
var q: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: "io.unom.punktfunk",
|
||||
kSecAttrAccount as String: "client-identity",
|
||||
]
|
||||
if dataProtection { q[kSecUseDataProtectionKeychain as String] = true }
|
||||
return q
|
||||
}
|
||||
|
||||
private func copyStored() -> ReadResult {
|
||||
let result = read(dataProtection: true)
|
||||
// No entitlement (ad-hoc / unsigned build): the data-protection keychain is
|
||||
// unavailable — read the legacy file keychain instead.
|
||||
if case .denied(errSecMissingEntitlement) = result {
|
||||
return read(dataProtection: false)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func read(dataProtection: Bool) -> ReadResult {
|
||||
var query = Self.query(dataProtection: dataProtection)
|
||||
query[kSecReturnData as String] = true
|
||||
var out: CFTypeRef?
|
||||
switch SecItemCopyMatching(query as CFDictionary, &out) {
|
||||
case errSecSuccess:
|
||||
guard let data = out as? Data,
|
||||
let stored = try? JSONDecoder().decode(Stored.self, from: data)
|
||||
else { return .corrupt }
|
||||
return .found(ClientIdentity(certPEM: stored.certPEM, keyPEM: stored.keyPEM))
|
||||
case errSecItemNotFound:
|
||||
return .absent
|
||||
case let status:
|
||||
return .denied(status)
|
||||
}
|
||||
}
|
||||
|
||||
private func add(_ identity: ClientIdentity) -> OSStatus {
|
||||
guard let data = try? JSONEncoder().encode(
|
||||
Stored(certPEM: identity.certPEM, keyPEM: identity.keyPEM))
|
||||
else { return errSecParam }
|
||||
var add = Self.query(dataProtection: true)
|
||||
add[kSecValueData as String] = data
|
||||
// After-first-unlock so a background reconnect can still read it; the access-group
|
||||
// entitlement (not a per-binary ACL) gates it, so it survives rebuilds prompt-free.
|
||||
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
||||
let status = SecItemAdd(add as CFDictionary, nil)
|
||||
guard status == errSecMissingEntitlement else { return status }
|
||||
// Ad-hoc / unsigned build: persist to the legacy file keychain instead.
|
||||
var legacy = Self.query(dataProtection: false)
|
||||
legacy[kSecValueData as String] = data
|
||||
return SecItemAdd(legacy as CFDictionary, nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// 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 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`. (Auth is mTLS by the pinned identity — no token.)
|
||||
var mgmtPort: UInt16?
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// The two joins of live mDNS discovery against the saved-host store, shared by the touch grid
|
||||
/// (HomeView) and the gamepad launcher (GamepadHomeView) so both screens classify hosts the same
|
||||
/// way. LAN-scoped like the underlying match: a host that isn't advertising here is "not seen",
|
||||
/// not proven off.
|
||||
extension HostDiscovery {
|
||||
/// A saved host is "online" iff a live advert currently matches it (see `StoredHost.matches`).
|
||||
/// Recomputed on every discovery change (the @Published set), so it tracks hosts
|
||||
/// appearing/leaving the network live.
|
||||
func advertises(_ host: StoredHost) -> Bool {
|
||||
hosts.contains { host.matches($0) }
|
||||
}
|
||||
|
||||
/// Discovered hosts not already saved — the saved list shows the rest, so this only surfaces
|
||||
/// genuinely-new hosts on the network. Same match as `advertises`, so a saved host whose IP
|
||||
/// changed (still fingerprint-matched) doesn't also appear as a stranger.
|
||||
func unsaved(among saved: [StoredHost]) -> [DiscoveredHost] {
|
||||
hosts.filter { d in !saved.contains { $0.matches(d) } }
|
||||
}
|
||||
}
|
||||
|
||||
@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). This does NOT downgrade
|
||||
/// to TOFU: the next connect re-pairs via the PIN ceremony, unless the host advertises
|
||||
/// `pair=optional` (the only case the connect path still offers the trust prompt).
|
||||
func forgetIdentity(_ host: StoredHost) {
|
||||
guard let i = hosts.firstIndex(where: { $0.id == host.id }) else { return }
|
||||
hosts[i].pinnedSHA256 = nil
|
||||
}
|
||||
|
||||
|
||||
private func persist() {
|
||||
if let data = try? JSONEncoder().encode(hosts) {
|
||||
UserDefaults.standard.set(data, forKey: Self.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user