133e25849d
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>
189 lines
7.8 KiB
Swift
189 lines
7.8 KiB
Swift
// LAN auto-discovery of punktfunk/1 hosts over mDNS — the client side of the host's
|
|
// `crate::discovery` advert (`_punktfunk._udp`). Browses with NWBrowser (TXT rides in the
|
|
// result metadata), resolves each service to a connectable IP:port with a throwaway
|
|
// NWConnection, and publishes the live set.
|
|
//
|
|
// The advertised `fp` (host cert SHA-256) is ADVISORY: mDNS is unauthenticated, so TOFU /
|
|
// pinning still verifies the host on connect — it's surfaced only so a picker can show it and
|
|
// pre-fill. `pair=required` lets the UI route straight to the pairing ceremony.
|
|
//
|
|
// iOS/tvOS gate Bonjour browsing on Info.plist `NSBonjourServices` listing `_punktfunk._udp`
|
|
// (Config/Info.plist) — without it the system blocks the browse and nothing is returned.
|
|
|
|
#if canImport(Network)
|
|
import Foundation
|
|
import Network
|
|
|
|
/// A punktfunk/1 host found on the LAN. `fingerprintHex` is advisory (see file header).
|
|
public struct DiscoveredHost: Identifiable, Sendable, Equatable {
|
|
/// Stable host id (mDNS `id` TXT); falls back to the Bonjour instance name.
|
|
public let id: String
|
|
/// Bonjour instance name (the host's chosen label).
|
|
public let name: String
|
|
/// Resolved address to hand to `PunktfunkConnection`.
|
|
public let host: String
|
|
public let port: UInt16
|
|
/// Host cert SHA-256 (lowercase hex) the host advertised, or nil if absent.
|
|
public let fingerprintHex: String?
|
|
/// The host advertised `pair=required` — a client must pair before it can stream.
|
|
public let requiresPairing: Bool
|
|
/// The host EXPLICITLY advertised `pair=optional` — only then may the client offer the
|
|
/// reduced-security TOFU "Trust" path. A missing/unknown `pair` field is NOT optional:
|
|
/// pairing is mandatory unless this is true (the policy authority is the host's advert).
|
|
public let allowsTofu: Bool
|
|
}
|
|
|
|
@MainActor
|
|
public final class HostDiscovery: ObservableObject {
|
|
/// Currently-visible hosts, deduped by `id`, sorted by name. Main-actor.
|
|
@Published public private(set) var hosts: [DiscoveredHost] = []
|
|
|
|
private var browser: NWBrowser?
|
|
/// Keyed by the service endpoint's description (a stable, Sendable handle we can capture
|
|
/// into the resolve callbacks without smuggling non-Sendable Network types across hops).
|
|
private var resolved: [String: DiscoveredHost] = [:]
|
|
private var connections: [String: NWConnection] = [:]
|
|
|
|
public init() {}
|
|
|
|
/// Start browsing `_punktfunk._udp`. Idempotent — a second call while live is a no-op.
|
|
public func start() {
|
|
guard browser == nil else { return }
|
|
let browser = NWBrowser(
|
|
for: .bonjourWithTXTRecord(type: "_punktfunk._udp", domain: nil),
|
|
using: NWParameters())
|
|
browser.browseResultsChangedHandler = { results, _ in
|
|
MainActor.assumeIsolated { [weak self] in self?.reconcile(results) }
|
|
}
|
|
browser.stateUpdateHandler = { state in
|
|
// A failed browser never recovers on its own; tear down and re-arm so transient
|
|
// network changes (Wi-Fi flip, VPN) don't leave discovery silently dead.
|
|
MainActor.assumeIsolated { [weak self] in
|
|
if case .failed = state { self?.restart() }
|
|
}
|
|
}
|
|
self.browser = browser
|
|
browser.start(queue: .main)
|
|
}
|
|
|
|
/// Stop browsing and drop all discovered state.
|
|
public func stop() {
|
|
browser?.cancel()
|
|
browser = nil
|
|
for conn in connections.values { conn.cancel() }
|
|
connections.removeAll()
|
|
resolved.removeAll()
|
|
if !hosts.isEmpty { hosts = [] }
|
|
}
|
|
|
|
deinit {
|
|
browser?.cancel()
|
|
for conn in connections.values { conn.cancel() }
|
|
}
|
|
|
|
private func restart() {
|
|
stop()
|
|
start()
|
|
}
|
|
|
|
/// Diff the browser's current result set against what we're tracking: drop departed
|
|
/// services, resolve newly-seen ones.
|
|
private func reconcile(_ results: Set<NWBrowser.Result>) {
|
|
let live = Set(results.map { Self.key($0) })
|
|
for key in resolved.keys where !live.contains(key) { resolved[key] = nil }
|
|
for key in connections.keys where !live.contains(key) {
|
|
connections[key]?.cancel()
|
|
connections[key] = nil
|
|
}
|
|
for result in results {
|
|
let key = Self.key(result)
|
|
if resolved[key] == nil, connections[key] == nil { resolve(result) }
|
|
}
|
|
publish()
|
|
}
|
|
|
|
/// Resolve one service to IP:port via a short UDP connection (it reaches `.ready` once the
|
|
/// path is established — no data is sent), reading the TXT up front so the callback only
|
|
/// captures Sendable values + the endpoint key.
|
|
private func resolve(_ result: NWBrowser.Result) {
|
|
let key = Self.key(result)
|
|
let name = Self.instanceName(result.endpoint)
|
|
var fp: String?
|
|
var pair: String?
|
|
var id: String?
|
|
if case let .bonjour(txt) = result.metadata {
|
|
fp = Self.entry(txt, "fp")
|
|
pair = Self.entry(txt, "pair")
|
|
id = Self.entry(txt, "id")
|
|
}
|
|
let conn = NWConnection(to: result.endpoint, using: .udp)
|
|
connections[key] = conn
|
|
conn.stateUpdateHandler = { state in
|
|
MainActor.assumeIsolated { [weak self] in
|
|
guard let self, let conn = self.connections[key] else { return }
|
|
switch state {
|
|
case .ready:
|
|
if case let .hostPort(host, port)? = conn.currentPath?.remoteEndpoint,
|
|
let address = Self.hostString(host) {
|
|
self.resolved[key] = DiscoveredHost(
|
|
id: (id?.isEmpty == false) ? id! : name,
|
|
name: name, host: address, port: port.rawValue,
|
|
fingerprintHex: fp, requiresPairing: pair == "required",
|
|
allowsTofu: pair == "optional")
|
|
self.publish()
|
|
}
|
|
conn.cancel()
|
|
self.connections[key] = nil
|
|
case .failed, .cancelled:
|
|
self.connections[key] = nil
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
conn.start(queue: .main)
|
|
}
|
|
|
|
/// Publish the resolved set, deduped by `id` (a host on several interfaces / re-advertising
|
|
/// collapses to one row), sorted by name.
|
|
private func publish() {
|
|
var byID: [String: DiscoveredHost] = [:]
|
|
for host in resolved.values { byID[host.id] = host }
|
|
let next = byID.values.sorted {
|
|
$0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
|
|
}
|
|
if next != hosts { hosts = next }
|
|
}
|
|
|
|
private static func key(_ result: NWBrowser.Result) -> String {
|
|
"\(result.endpoint)"
|
|
}
|
|
|
|
private static func instanceName(_ endpoint: NWEndpoint) -> String {
|
|
if case let .service(name, _, _, _) = endpoint { return name }
|
|
return "punktfunk host"
|
|
}
|
|
|
|
private static func entry(_ txt: NWTXTRecord, _ field: String) -> String? {
|
|
if case let .string(value) = txt.getEntry(for: field), !value.isEmpty { return value }
|
|
return nil
|
|
}
|
|
|
|
/// A resolved `NWEndpoint.Host` → a plain address string for `PunktfunkConnection` (the
|
|
/// scope id on a link-local address is stripped — the host+port pair is resolved again on
|
|
/// the Rust side, which can't parse the `%iface` suffix).
|
|
private static func hostString(_ host: NWEndpoint.Host) -> String? {
|
|
switch host {
|
|
case .ipv4(let address):
|
|
return "\(address)".split(separator: "%").first.map(String.init)
|
|
case .ipv6(let address):
|
|
return "\(address)".split(separator: "%").first.map(String.init)
|
|
case .name(let name, _):
|
|
return name
|
|
@unknown default:
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
#endif
|