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:
2026-07-02 11:05:10 +02:00
parent e925d00194
commit 133e25849d
84 changed files with 4231 additions and 2698 deletions
@@ -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)
}
}