refactor(apple): decompose ContentView (735 -> 272 lines)
ci / web (push) Failing after 35s
ci / rust (push) Successful in 54s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 3s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / docs-site (push) Failing after 40s
docker / deploy-docs (push) Successful in 16s
apple / swift (push) Successful in 1m20s
ci / web (push) Failing after 35s
ci / rust (push) Successful in 54s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 3s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / docs-site (push) Failing after 40s
docker / deploy-docs (push) Successful in 16s
apple / swift (push) Successful in 1m20s
Split the monolithic ContentView into focused view files — a pure structural refactor with no behavior change (verified: builds macOS/iOS/tvOS, the test suite is green, and a fidelity review against the original found no discrepancies): - ContentView (272): the coordinator — owns the session model / host store / discovery, switches home<->session, holds the connect logic (it reads @AppStorage) + the dev hooks, and the stream builder (whose stable identity across awaiting-trust->streaming must NOT move — it stays here). - HomeView (251): the hosts grid + navigation + toolbar + sheets + "On this network" discovery section + empty state. - HostCards (158): HostCardView + DiscoveredCardView, sharing a CardMetrics struct (dedupes the platform-tuned sizing the two cards had copy-pasted). - TrustCardView (80): the TOFU prompt + fingerprint formatting. - StreamHUDView (67): the streaming overlay HUD. State flows idiomatically: @StateObject (ContentView) -> @ObservedObject in subviews, @State -> @Binding; the connect logic is passed as closures. Sheet placement is preserved — the pairing/speed-test sheets stay on the outer body so they survive the trust->home transition. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,32 +1,30 @@
|
||||
// Hosts grid ⇄ trust prompt ⇄ live stream.
|
||||
// Hosts grid ⇄ trust prompt ⇄ live stream. ContentView is the coordinator: it owns the session
|
||||
// model, host store, and LAN discovery; switches between the home grid (HomeView) and the live
|
||||
// session; and holds the connect logic (it reads the @AppStorage stream mode). The grid + cards
|
||||
// (HomeView/HostCards), the trust prompt (TrustCardView), and the HUD (StreamHUDView) live in
|
||||
// their own files.
|
||||
//
|
||||
// Home is a grid of saved hosts (click to connect); "+" in the toolbar adds one; the
|
||||
// stream mode lives in Settings (⌘,). Two ways to establish trust on first contact:
|
||||
// the TOFU prompt (host fingerprint over the live-but-blurred stream, user compares it
|
||||
// with the host's log) or the PIN pairing ceremony (right-click a card → "Pair with
|
||||
// PIN…", or from the trust prompt itself) — pairing verifies both sides at once and is
|
||||
// the only way into hosts running --require-pairing. Once pinned, reconnects are silent
|
||||
// and a changed host identity refuses to connect.
|
||||
// Two ways to establish trust on first contact: the TOFU prompt (host fingerprint over the
|
||||
// live-but-blurred stream, compared with the host's log) or the PIN pairing ceremony — pairing
|
||||
// verifies both sides at once and is the only way into hosts running --require-pairing. Once
|
||||
// pinned, reconnects are silent and a changed host identity refuses to connect.
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(tvOS)
|
||||
import SwiftUINavigationTransitions
|
||||
#endif
|
||||
|
||||
struct ContentView: View {
|
||||
@StateObject private var model = SessionModel()
|
||||
@StateObject private var store = HostStore()
|
||||
@StateObject private var discovery = HostDiscovery()
|
||||
@AppStorage("punktfunk.width") private var width = 1920
|
||||
@AppStorage("punktfunk.height") private var height = 1080
|
||||
@AppStorage("punktfunk.hz") private var hz = 60
|
||||
@AppStorage("punktfunk.compositor") private var compositor = 0
|
||||
@AppStorage("punktfunk.gamepadType") private var gamepadType = 0
|
||||
@AppStorage("punktfunk.bitrateKbps") private var bitrateKbps = 0
|
||||
@AppStorage(DefaultsKey.streamWidth) private var width = 1920
|
||||
@AppStorage(DefaultsKey.streamHeight) private var height = 1080
|
||||
@AppStorage(DefaultsKey.streamHz) private var hz = 60
|
||||
@AppStorage(DefaultsKey.compositor) private var compositor = 0
|
||||
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
|
||||
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
|
||||
@State private var showAddHost = false
|
||||
@State private var pairingTarget: StoredHost?
|
||||
@State private var speedTestTarget: StoredHost?
|
||||
@@ -64,15 +62,7 @@ struct ContentView: View {
|
||||
// is sequential, a pairing connection would queue behind the live session).
|
||||
#if !os(tvOS)
|
||||
.sheet(item: $pairingTarget) { host in
|
||||
PairSheet(host: host) { fingerprint in
|
||||
// Backstop against a stale ceremony surfacing after dismissal (PairSheet
|
||||
// also self-discards those): only act while this host's sheet is up.
|
||||
guard pairingTarget?.id == host.id else { return }
|
||||
store.pin(host.id, fingerprint: fingerprint)
|
||||
var pinned = host
|
||||
pinned.pinnedSHA256 = fingerprint
|
||||
connect(pinned)
|
||||
}
|
||||
PairSheet(host: host) { fingerprint in handlePaired(host, fingerprint: fingerprint) }
|
||||
}
|
||||
.sheet(item: $speedTestTarget) { host in
|
||||
SpeedTestSheet(host: host)
|
||||
@@ -80,6 +70,24 @@ struct ContentView: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
private var home: some View {
|
||||
#if os(macOS)
|
||||
HomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
showAddHost: $showAddHost, pairingTarget: $pairingTarget,
|
||||
speedTestTarget: $speedTestTarget,
|
||||
connect: connect, connectDiscovered: connectDiscovered, onPaired: handlePaired)
|
||||
#else
|
||||
HomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
showAddHost: $showAddHost, pairingTarget: $pairingTarget,
|
||||
speedTestTarget: $speedTestTarget, showSettings: $showSettings,
|
||||
connect: connect, connectDiscovered: connectDiscovered, onPaired: handlePaired)
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Session
|
||||
|
||||
private var sessionView: some View {
|
||||
let pendingFingerprint: Data? = {
|
||||
if case .awaitingTrust(let fp) = model.phase { return fp }
|
||||
@@ -94,7 +102,20 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
if let fp = pendingFingerprint {
|
||||
trustCard(fp)
|
||||
TrustCardView(
|
||||
fingerprint: fp,
|
||||
hostName: model.activeHost?.displayName ?? "host",
|
||||
onCancel: { model.rejectTrust() },
|
||||
onTrust: {
|
||||
if let fp = model.confirmTrust(), let host = model.activeHost {
|
||||
store.pin(host.id, fingerprint: fp)
|
||||
}
|
||||
},
|
||||
onPairInstead: {
|
||||
let host = model.activeHost
|
||||
model.rejectTrust()
|
||||
pairingTarget = host
|
||||
})
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
@@ -118,483 +139,6 @@ struct ContentView: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Home (hosts grid)
|
||||
|
||||
private var home: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if store.hosts.isEmpty && discoveredUnsaved.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
ScrollView {
|
||||
if !store.hosts.isEmpty {
|
||||
LazyVGrid(columns: gridColumns, spacing: gridSpacing) {
|
||||
ForEach(store.hosts) { host in
|
||||
hostCard(host)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
if !discoveredUnsaved.isEmpty {
|
||||
discoveredSection
|
||||
}
|
||||
#if os(tvOS)
|
||||
// Actions live below the hosts, not between them.
|
||||
HStack(spacing: 32) {
|
||||
Button {
|
||||
showAddHost = true
|
||||
} label: {
|
||||
Label("Add Host", systemImage: "plus")
|
||||
}
|
||||
Button {
|
||||
showSettings = true
|
||||
} label: {
|
||||
Label("Settings", systemImage: "gearshape")
|
||||
}
|
||||
}
|
||||
.padding(.top, 24)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Punktfunkempfänger")
|
||||
// Browse the LAN for advertised hosts only while the grid is up — not during a
|
||||
// session. The home appears/disappears as the stream swaps in and out.
|
||||
.onAppear { discovery.start() }
|
||||
.onDisappear { discovery.stop() }
|
||||
#if os(tvOS)
|
||||
// Pushed routes — the Settings-app navigation feel (push animation, Menu
|
||||
// pops) instead of modal overlays.
|
||||
.navigationDestination(isPresented: $showAddHost) {
|
||||
AddHostSheet { store.add($0) }
|
||||
}
|
||||
.navigationDestination(isPresented: $showSettings) {
|
||||
SettingsView()
|
||||
}
|
||||
.navigationDestination(item: $pairingTarget) { host in
|
||||
PairSheet(host: host) { fingerprint in
|
||||
guard pairingTarget?.id == host.id else { return }
|
||||
store.pin(host.id, fingerprint: fingerprint)
|
||||
var pinned = host
|
||||
pinned.pinnedSHA256 = fingerprint
|
||||
connect(pinned)
|
||||
}
|
||||
}
|
||||
.navigationDestination(item: $speedTestTarget) { host in
|
||||
SpeedTestSheet(host: host)
|
||||
}
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
.toolbar {
|
||||
#if os(iOS)
|
||||
// Adjacent trailing items share one glass pill (the system default).
|
||||
ToolbarItem(placement: .topBarTrailing) { settingsButton }
|
||||
ToolbarItem(placement: .topBarTrailing) { addHostButton }
|
||||
#else
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
addHostButton
|
||||
.help("Add a host")
|
||||
}
|
||||
ToolbarItem {
|
||||
SettingsLink {
|
||||
Label("Settings", systemImage: "gearshape")
|
||||
}
|
||||
.help("Stream mode and settings")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 480, minHeight: 360)
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
// The Settings-app slide for every push in this stack (top-level routes AND
|
||||
// the pickers' drill-ins) — SwiftUI's default on tvOS is a bare crossfade.
|
||||
// Spring-driven (UISpringTimingParameters): ~0.87 damping ratio — settles fast
|
||||
// with just a hint of life, no visible overshoot ping-pong.
|
||||
.customNavigationTransition(
|
||||
.slide.animation(.interpolatingSpring(stiffness: 300, damping: 30)))
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
.sheet(isPresented: $showAddHost) {
|
||||
AddHostSheet { store.add($0) }
|
||||
}
|
||||
#if os(iOS)
|
||||
.sheet(isPresented: $showSettings) {
|
||||
NavigationStack {
|
||||
SettingsView()
|
||||
.navigationTitle("Settings")
|
||||
.toolbar {
|
||||
Button("Done") { showSettings = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
.alert(
|
||||
"Connection failed",
|
||||
isPresented: Binding(
|
||||
get: { model.errorMessage != nil },
|
||||
set: { if !$0 { model.errorMessage = nil } }
|
||||
)
|
||||
) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text(model.errorMessage ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
/// macOS caps card width (a huge window shouldn't yield huge cards); on iOS the
|
||||
/// columns FILL the width so the cards stay edge-aligned with the title and bars —
|
||||
/// sized touch-first: one column on iPhone portrait, 3–4 generous cards on iPad.
|
||||
private var gridColumns: [GridItem] {
|
||||
#if os(macOS)
|
||||
[GridItem(.adaptive(minimum: 180, maximum: 240), spacing: 16)]
|
||||
#elseif os(tvOS)
|
||||
[GridItem(.adaptive(minimum: 320), spacing: 48)]
|
||||
#else
|
||||
[GridItem(.adaptive(minimum: 280), spacing: 16)]
|
||||
#endif
|
||||
}
|
||||
|
||||
private var gridSpacing: CGFloat {
|
||||
#if os(tvOS)
|
||||
48 // the focused card scales up — give it room instead of overlapping siblings
|
||||
#else
|
||||
16
|
||||
#endif
|
||||
}
|
||||
|
||||
private var addHostButton: some View {
|
||||
Button {
|
||||
showAddHost = true
|
||||
} label: {
|
||||
Label("Add Host", systemImage: "plus")
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(macOS)
|
||||
private var settingsButton: some View {
|
||||
Button {
|
||||
showSettings = true
|
||||
} label: {
|
||||
Label("Settings", systemImage: "gearshape")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private var emptyState: some View {
|
||||
ContentUnavailableView {
|
||||
Label("No Hosts", systemImage: "rectangle.connected.to.line.below")
|
||||
} description: {
|
||||
Text("Add your punktfunk host with the + button.")
|
||||
} actions: {
|
||||
Button("Add Host") { showAddHost = true }
|
||||
.buttonStyle(.borderedProminent)
|
||||
#if os(iOS)
|
||||
.controlSize(.large)
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
Button("Settings") { showSettings = true }
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private func hostCard(_ host: StoredHost) -> some View {
|
||||
let isConnecting = model.phase == .connecting && model.activeHost?.id == host.id
|
||||
#if os(iOS)
|
||||
let iconSize: CGFloat = 56
|
||||
let iconBox: CGFloat = 76
|
||||
let cardPadding: CGFloat = 28
|
||||
let nameFont = Font.title3.weight(.semibold)
|
||||
#else
|
||||
let iconSize: CGFloat = 42
|
||||
let iconBox: CGFloat = 56
|
||||
let cardPadding: CGFloat = 18
|
||||
let nameFont = Font.headline
|
||||
#endif
|
||||
return Button {
|
||||
connect(host)
|
||||
} label: {
|
||||
VStack(spacing: 10) {
|
||||
ZStack {
|
||||
Image(systemName: "play.display")
|
||||
.font(.system(size: iconSize, weight: .light))
|
||||
.foregroundStyle(.tint)
|
||||
.opacity(isConnecting ? 0.3 : 1)
|
||||
if isConnecting {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
.frame(height: iconBox)
|
||||
VStack(spacing: 2) {
|
||||
Text(host.displayName)
|
||||
.font(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, 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 host.id == mostRecentHostID {
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.strokeBorder(Color.accentColor.opacity(0.35), lineWidth: 1.5)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#if os(tvOS)
|
||||
.buttonStyle(.card)
|
||||
#else
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
.disabled(model.isBusy)
|
||||
.contextMenu {
|
||||
Button("Pair with PIN…") {
|
||||
guard !model.isBusy else { return }
|
||||
pairingTarget = host
|
||||
}
|
||||
Button("Test Network Speed…") {
|
||||
guard !model.isBusy else { return }
|
||||
speedTestTarget = host
|
||||
}
|
||||
if host.pinnedSHA256 != nil {
|
||||
Button("Forget Identity") { store.forgetIdentity(host) }
|
||||
}
|
||||
Button("Remove", role: .destructive) { store.remove(host) }
|
||||
}
|
||||
}
|
||||
|
||||
/// First run on iOS: default the stream mode to this device's native screen so the
|
||||
/// video fills the display instead of letterboxing 1920×1080 onto a 4:3 iPad. (The
|
||||
/// compiled-in AppStorage defaults only apply until any value is saved; macOS keeps
|
||||
/// 1080p — a desktop window is not the screen.)
|
||||
private func seedDefaultModeIfNeeded() {
|
||||
#if !os(macOS)
|
||||
let defaults = UserDefaults.standard
|
||||
guard defaults.object(forKey: "punktfunk.width") == nil else { return }
|
||||
let bounds = UIScreen.main.nativeBounds // portrait-oriented pixels
|
||||
defaults.set(Int(max(bounds.width, bounds.height)), forKey: "punktfunk.width")
|
||||
defaults.set(Int(min(bounds.width, bounds.height)), forKey: "punktfunk.height")
|
||||
defaults.set(UIScreen.main.maximumFramesPerSecond, forKey: "punktfunk.hz")
|
||||
#endif
|
||||
}
|
||||
|
||||
/// The host of the most recent session — its card carries the accent ring.
|
||||
private var mostRecentHostID: UUID? {
|
||||
store.hosts
|
||||
.compactMap { host in host.lastConnected.map { (host.id, $0) } }
|
||||
.max { $0.1 < $1.1 }?.0
|
||||
}
|
||||
|
||||
private func connect(_ host: StoredHost) {
|
||||
// The gamepad-type setting resolves NOW (Automatic → match the active physical
|
||||
// controller): the host's virtual pad backend is fixed per session.
|
||||
model.connect(
|
||||
to: host,
|
||||
width: UInt32(clamping: width), height: UInt32(clamping: height),
|
||||
hz: UInt32(clamping: hz),
|
||||
compositor: PunktfunkConnection.Compositor(
|
||||
rawValue: UInt32(clamping: compositor)) ?? .auto,
|
||||
gamepad: GamepadManager.shared.resolveType(
|
||||
setting: PunktfunkConnection.GamepadType(
|
||||
rawValue: UInt32(clamping: gamepadType)) ?? .auto),
|
||||
bitrateKbps: UInt32(clamping: bitrateKbps))
|
||||
}
|
||||
|
||||
// MARK: - LAN discovery (mDNS)
|
||||
|
||||
/// Discovered hosts not already saved (matched by address+port) — the saved grid shows
|
||||
/// the rest, so this section only surfaces genuinely-new hosts on the network.
|
||||
private var discoveredUnsaved: [DiscoveredHost] {
|
||||
discovery.hosts.filter { d in
|
||||
!store.hosts.contains { $0.address == d.host && $0.port == d.port }
|
||||
}
|
||||
}
|
||||
|
||||
private var discoveredSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Label("On this network", systemImage: "antenna.radiowaves.left.and.right")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal)
|
||||
LazyVGrid(columns: gridColumns, spacing: gridSpacing) {
|
||||
ForEach(discoveredUnsaved) { discoveredCard($0) }
|
||||
}
|
||||
}
|
||||
.padding([.horizontal, .bottom])
|
||||
.padding(.top, store.hosts.isEmpty ? 0 : 8)
|
||||
}
|
||||
|
||||
private func discoveredCard(_ d: DiscoveredHost) -> some View {
|
||||
#if os(iOS)
|
||||
let iconSize: CGFloat = 56
|
||||
let iconBox: CGFloat = 76
|
||||
let cardPadding: CGFloat = 28
|
||||
let nameFont = Font.title3.weight(.semibold)
|
||||
#else
|
||||
let iconSize: CGFloat = 42
|
||||
let iconBox: CGFloat = 56
|
||||
let cardPadding: CGFloat = 18
|
||||
let nameFont = Font.headline
|
||||
#endif
|
||||
return Button {
|
||||
connectDiscovered(d)
|
||||
} label: {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "play.display")
|
||||
.font(.system(size: iconSize, weight: .light))
|
||||
.foregroundStyle(.tint)
|
||||
.frame(height: iconBox)
|
||||
VStack(spacing: 2) {
|
||||
Text(d.name)
|
||||
.font(nameFont)
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: d.requiresPairing ? "lock.fill" : "wifi")
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("\(d.host):\(String(d.port))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Text(d.requiresPairing ? "Pairing required" : "Discovered")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, cardPadding)
|
||||
.padding(.horizontal, 12)
|
||||
#if !os(tvOS)
|
||||
// A dashed ring distinguishes a not-yet-saved discovered host from saved cards.
|
||||
.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(model.isBusy)
|
||||
}
|
||||
|
||||
/// Tap a discovered host: save it (so the session has a stored identity and the trust pin
|
||||
/// persists), then connect — TOFU shows the fingerprint, which should match the advertised
|
||||
/// `fp`. A `pair=required` host goes straight to the pairing ceremony instead.
|
||||
private func connectDiscovered(_ d: DiscoveredHost) {
|
||||
guard !model.isBusy else { return }
|
||||
let host = StoredHost(name: d.name, address: d.host, port: d.port)
|
||||
store.add(host)
|
||||
if d.requiresPairing {
|
||||
pairingTarget = host
|
||||
} else {
|
||||
connect(host)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Trust on first use
|
||||
|
||||
private func trustCard(_ fingerprint: Data) -> some View {
|
||||
VStack(spacing: 14) {
|
||||
Image(systemName: "lock.shield")
|
||||
.font(.system(size: 36, weight: .light))
|
||||
.foregroundStyle(.tint)
|
||||
Text("Verify \(model.activeHost?.displayName ?? "host")")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("First connection. Compare this fingerprint with the one "
|
||||
+ "punktfunk-host logged at startup (\u{201C}clients pin this "
|
||||
+ "fingerprint\u{201D}):")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Text(Self.format(fingerprint: fingerprint))
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
#if !os(tvOS)
|
||||
.textSelection(.enabled)
|
||||
#endif
|
||||
.padding(10)
|
||||
.background(.quaternary, in: RoundedRectangle(cornerRadius: 8))
|
||||
HStack(spacing: 12) {
|
||||
Button("Cancel", role: .cancel) { model.rejectTrust() }
|
||||
#if !os(tvOS)
|
||||
.keyboardShortcut(.cancelAction)
|
||||
#endif
|
||||
Button("Trust & Connect") {
|
||||
if let fp = model.confirmTrust(), let host = model.activeHost {
|
||||
store.pin(host.id, fingerprint: fp)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
#if !os(tvOS)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
#endif
|
||||
}
|
||||
#if os(iOS)
|
||||
.controlSize(.large)
|
||||
#endif
|
||||
// The verified alternative to eyeballing hex: drop this session (the host
|
||||
// serves one connection at a time) and run the SPAKE2 PIN ceremony instead.
|
||||
Button("Pair with PIN instead…") {
|
||||
let host = model.activeHost
|
||||
model.rejectTrust()
|
||||
pairingTarget = host
|
||||
}
|
||||
#if os(macOS)
|
||||
.buttonStyle(.link)
|
||||
#else
|
||||
.buttonStyle(.borderless)
|
||||
#endif
|
||||
.font(.callout)
|
||||
}
|
||||
.padding(28)
|
||||
.frame(maxWidth: 440)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18))
|
||||
}
|
||||
|
||||
/// 64 hex chars → four groups per line, two lines — easy to eyeball against the log.
|
||||
private static func format(fingerprint: Data) -> String {
|
||||
let hex = fingerprint.map { String(format: "%02x", $0) }.joined()
|
||||
let groups = stride(from: 0, to: hex.count, by: 8).map { i -> String in
|
||||
let start = hex.index(hex.startIndex, offsetBy: i)
|
||||
let end = hex.index(start, offsetBy: min(8, hex.count - i))
|
||||
return String(hex[start..<end])
|
||||
}
|
||||
return groups.chunks(of: 4).map { $0.joined(separator: " ") }.joined(separator: "\n")
|
||||
}
|
||||
|
||||
// MARK: - Stream
|
||||
|
||||
private func stream(captureEnabled: Bool) -> some View {
|
||||
Group {
|
||||
if let conn = model.connection {
|
||||
@@ -614,70 +158,69 @@ struct ContentView: View {
|
||||
presentMeter: model.presentLatency
|
||||
)
|
||||
.overlay(alignment: .topTrailing) {
|
||||
if captureEnabled { hud(conn) }
|
||||
if captureEnabled { StreamHUDView(model: model, connection: conn) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func hud(_ conn: PunktfunkConnection) -> some View {
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(Color.accentColor)
|
||||
.frame(width: 7, height: 7)
|
||||
Text("\(conn.width)×\(conn.height)@\(conn.refreshHz) \(model.fps) fps \(model.mbps, specifier: "%.1f") Mb/s")
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
}
|
||||
if model.latencyValid {
|
||||
// Capture→client-receipt (skew-corrected); excludes the layer's decode+present —
|
||||
// see LatencyMeter. "(same-host)" when the host didn't answer the skew handshake.
|
||||
Text("capture→client \(model.latencyP50Ms, specifier: "%.1f")/\(model.latencyP95Ms, specifier: "%.1f") ms p50/p95\(model.latencySkewCorrected ? "" : " (same-host)")")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if model.presentLatencyValid {
|
||||
// Capture→present (glass-to-glass, modulo host render→capture) — stage-2 presenter
|
||||
// only; stage-1's layer presents internally with no per-frame stamp.
|
||||
Text("capture→present \(model.presentLatencyP50Ms, specifier: "%.1f")/\(model.presentLatencyP95Ms, specifier: "%.1f") ms p50/p95\(model.presentLatencySkewCorrected ? "" : " (same-host)")")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
// While captured the cursor is hidden+frozen, so the button is keyboard-only
|
||||
// (⌘⎋ or Cmd+Tab release the cursor; released, it's clickable again).
|
||||
#if os(macOS)
|
||||
Text(model.mouseCaptured
|
||||
? "⌘⎋ releases the mouse"
|
||||
: "Click the stream to capture input")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
#elseif os(iOS)
|
||||
// Touch always plays directly; ⌘⎋ (hardware keyboard) toggles kb/mouse.
|
||||
Text(model.mouseCaptured
|
||||
? "⌘⎋ releases keyboard & mouse"
|
||||
: "⌘⎋ captures keyboard & mouse")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
// No focusable control during play: a focusable button steals the controller's
|
||||
// A press (the focus engine consumes it before the host sees it). Disconnect is
|
||||
// the Siri Remote's Menu button (.onExitCommand on the stream) — just hint it.
|
||||
Text("Press Menu to disconnect")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
#else
|
||||
Button("Disconnect (⌘D)") { model.disconnect() }
|
||||
.font(.caption)
|
||||
.keyboardShortcut("d", modifiers: .command)
|
||||
#endif
|
||||
}
|
||||
.padding(10)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10))
|
||||
.padding(10)
|
||||
// MARK: - Connect
|
||||
|
||||
private func connect(_ host: StoredHost) {
|
||||
// The gamepad-type setting resolves NOW (Automatic → match the active physical
|
||||
// controller): the host's virtual pad backend is fixed per session.
|
||||
model.connect(
|
||||
to: host,
|
||||
width: UInt32(clamping: width), height: UInt32(clamping: height),
|
||||
hz: UInt32(clamping: hz),
|
||||
compositor: PunktfunkConnection.Compositor(
|
||||
rawValue: UInt32(clamping: compositor)) ?? .auto,
|
||||
gamepad: GamepadManager.shared.resolveType(
|
||||
setting: PunktfunkConnection.GamepadType(
|
||||
rawValue: UInt32(clamping: gamepadType)) ?? .auto),
|
||||
bitrateKbps: UInt32(clamping: bitrateKbps))
|
||||
}
|
||||
|
||||
// MARK: - Dev hook
|
||||
/// Tap a discovered host: save it (so the session has a stored identity and the trust pin
|
||||
/// persists), then connect — TOFU shows the fingerprint, which should match the advertised
|
||||
/// `fp`. A `pair=required` host goes straight to the pairing ceremony instead.
|
||||
private func connectDiscovered(_ d: DiscoveredHost) {
|
||||
guard !model.isBusy else { return }
|
||||
let host = StoredHost(name: d.name, address: d.host, port: d.port)
|
||||
store.add(host)
|
||||
if d.requiresPairing {
|
||||
pairingTarget = host
|
||||
} else {
|
||||
connect(host)
|
||||
}
|
||||
}
|
||||
|
||||
/// Pairing ceremony succeeded — pin the host and connect. The guard backstops a stale
|
||||
/// ceremony surfacing after dismissal (PairSheet also self-discards those).
|
||||
private func handlePaired(_ host: StoredHost, fingerprint: Data) {
|
||||
guard pairingTarget?.id == host.id else { return }
|
||||
store.pin(host.id, fingerprint: fingerprint)
|
||||
var pinned = host
|
||||
pinned.pinnedSHA256 = fingerprint
|
||||
connect(pinned)
|
||||
}
|
||||
|
||||
// MARK: - First-run + dev hooks
|
||||
|
||||
/// First run on iOS: default the stream mode to this device's native screen so the
|
||||
/// video fills the display instead of letterboxing 1920×1080 onto a 4:3 iPad. (The
|
||||
/// compiled-in AppStorage defaults only apply until any value is saved; macOS keeps
|
||||
/// 1080p — a desktop window is not the screen.)
|
||||
private func seedDefaultModeIfNeeded() {
|
||||
#if !os(macOS)
|
||||
let defaults = UserDefaults.standard
|
||||
guard defaults.object(forKey: DefaultsKey.streamWidth) == nil else { return }
|
||||
let bounds = UIScreen.main.nativeBounds // portrait-oriented pixels
|
||||
defaults.set(Int(max(bounds.width, bounds.height)), forKey: DefaultsKey.streamWidth)
|
||||
defaults.set(Int(min(bounds.width, bounds.height)), forKey: DefaultsKey.streamHeight)
|
||||
defaults.set(UIScreen.main.maximumFramesPerSecond, forKey: DefaultsKey.streamHz)
|
||||
#endif
|
||||
}
|
||||
|
||||
/// PUNKTFUNK_AUTOCONNECT=host[:port] connects immediately (trust-on-first-use,
|
||||
/// auto-confirmed — dev only) at the saved or PUNKTFUNK_MODE=WxHxHz mode, without
|
||||
@@ -727,9 +270,3 @@ struct ContentView: View {
|
||||
autoTrust: true)
|
||||
}
|
||||
}
|
||||
|
||||
private extension Array {
|
||||
func chunks(of size: Int) -> [[Element]] {
|
||||
stride(from: 0, to: count, by: size).map { Array(self[$0..<Swift.min($0 + size, count)]) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
// The home screen: a grid of saved hosts + an "On this network" section of mDNS-discovered
|
||||
// hosts, with the add/settings toolbar and the pairing / speed-test / add / settings
|
||||
// navigation. The connect logic lives in ContentView (it reads the @AppStorage stream mode) and
|
||||
// is passed in as closures.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(tvOS)
|
||||
import SwiftUINavigationTransitions
|
||||
#endif
|
||||
|
||||
struct HomeView: View {
|
||||
@ObservedObject var store: HostStore
|
||||
@ObservedObject var model: SessionModel
|
||||
@ObservedObject var discovery: HostDiscovery
|
||||
@Binding var showAddHost: Bool
|
||||
@Binding var pairingTarget: StoredHost?
|
||||
@Binding var speedTestTarget: StoredHost?
|
||||
#if !os(macOS)
|
||||
@Binding var showSettings: Bool
|
||||
#endif
|
||||
let connect: (StoredHost) -> Void
|
||||
let connectDiscovered: (DiscoveredHost) -> Void
|
||||
/// Pairing succeeded (tvOS PairSheet route) — pin + connect (ContentView guards staleness).
|
||||
let onPaired: (StoredHost, Data) -> Void
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if store.hosts.isEmpty && discoveredUnsaved.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
ScrollView {
|
||||
if !store.hosts.isEmpty {
|
||||
LazyVGrid(columns: gridColumns, spacing: gridSpacing) {
|
||||
ForEach(store.hosts) { host in
|
||||
hostCard(host)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
if !discoveredUnsaved.isEmpty {
|
||||
discoveredSection
|
||||
}
|
||||
#if os(tvOS)
|
||||
// Actions live below the hosts, not between them.
|
||||
HStack(spacing: 32) {
|
||||
Button {
|
||||
showAddHost = true
|
||||
} label: {
|
||||
Label("Add Host", systemImage: "plus")
|
||||
}
|
||||
Button {
|
||||
showSettings = true
|
||||
} label: {
|
||||
Label("Settings", systemImage: "gearshape")
|
||||
}
|
||||
}
|
||||
.padding(.top, 24)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Punktfunkempfänger")
|
||||
// Browse the LAN for advertised hosts only while the grid is up — not during a
|
||||
// session. The home appears/disappears as the stream swaps in and out.
|
||||
.onAppear { discovery.start() }
|
||||
.onDisappear { discovery.stop() }
|
||||
#if os(tvOS)
|
||||
// Pushed routes — the Settings-app navigation feel (push animation, Menu
|
||||
// pops) instead of modal overlays.
|
||||
.navigationDestination(isPresented: $showAddHost) {
|
||||
AddHostSheet { store.add($0) }
|
||||
}
|
||||
.navigationDestination(isPresented: $showSettings) {
|
||||
SettingsView()
|
||||
}
|
||||
.navigationDestination(item: $pairingTarget) { host in
|
||||
PairSheet(host: host) { fingerprint in onPaired(host, fingerprint) }
|
||||
}
|
||||
.navigationDestination(item: $speedTestTarget) { host in
|
||||
SpeedTestSheet(host: host)
|
||||
}
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
.toolbar {
|
||||
#if os(iOS)
|
||||
// Adjacent trailing items share one glass pill (the system default).
|
||||
ToolbarItem(placement: .topBarTrailing) { settingsButton }
|
||||
ToolbarItem(placement: .topBarTrailing) { addHostButton }
|
||||
#else
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
addHostButton
|
||||
.help("Add a host")
|
||||
}
|
||||
ToolbarItem {
|
||||
SettingsLink {
|
||||
Label("Settings", systemImage: "gearshape")
|
||||
}
|
||||
.help("Stream mode and settings")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 480, minHeight: 360)
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
// The Settings-app slide for every push in this stack (top-level routes AND
|
||||
// the pickers' drill-ins) — SwiftUI's default on tvOS is a bare crossfade.
|
||||
// Spring-driven (UISpringTimingParameters): ~0.87 damping ratio — settles fast
|
||||
// with just a hint of life, no visible overshoot ping-pong.
|
||||
.customNavigationTransition(
|
||||
.slide.animation(.interpolatingSpring(stiffness: 300, damping: 30)))
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
.sheet(isPresented: $showAddHost) {
|
||||
AddHostSheet { store.add($0) }
|
||||
}
|
||||
#if os(iOS)
|
||||
.sheet(isPresented: $showSettings) {
|
||||
NavigationStack {
|
||||
SettingsView()
|
||||
.navigationTitle("Settings")
|
||||
.toolbar {
|
||||
Button("Done") { showSettings = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
.alert(
|
||||
"Connection failed",
|
||||
isPresented: Binding(
|
||||
get: { model.errorMessage != nil },
|
||||
set: { if !$0 { model.errorMessage = nil } }
|
||||
)
|
||||
) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text(model.errorMessage ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cards
|
||||
|
||||
private func hostCard(_ host: StoredHost) -> some View {
|
||||
HostCardView(
|
||||
host: host,
|
||||
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
|
||||
isMostRecent: host.id == mostRecentHostID,
|
||||
isBusy: model.isBusy,
|
||||
onConnect: { connect(host) },
|
||||
onPair: { if !model.isBusy { pairingTarget = host } },
|
||||
onSpeedTest: { if !model.isBusy { speedTestTarget = host } },
|
||||
onForget: { store.forgetIdentity(host) },
|
||||
onRemove: { store.remove(host) })
|
||||
}
|
||||
|
||||
private var discoveredSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Label("On this network", systemImage: "antenna.radiowaves.left.and.right")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal)
|
||||
LazyVGrid(columns: gridColumns, spacing: gridSpacing) {
|
||||
ForEach(discoveredUnsaved) { discovered in
|
||||
DiscoveredCardView(
|
||||
discovered: discovered, isBusy: model.isBusy,
|
||||
onConnect: { connectDiscovered(discovered) })
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding([.horizontal, .bottom])
|
||||
.padding(.top, store.hosts.isEmpty ? 0 : 8)
|
||||
}
|
||||
|
||||
/// Discovered hosts not already saved (matched by address+port) — the saved grid shows the
|
||||
/// rest, so this section only surfaces genuinely-new hosts on the network.
|
||||
private var discoveredUnsaved: [DiscoveredHost] {
|
||||
discovery.hosts.filter { d in
|
||||
!store.hosts.contains { $0.address == d.host && $0.port == d.port }
|
||||
}
|
||||
}
|
||||
|
||||
/// The host of the most recent session — its card carries the accent ring.
|
||||
private var mostRecentHostID: UUID? {
|
||||
store.hosts
|
||||
.compactMap { host in host.lastConnected.map { (host.id, $0) } }
|
||||
.max { $0.1 < $1.1 }?.0
|
||||
}
|
||||
|
||||
// MARK: - Chrome
|
||||
|
||||
private var emptyState: some View {
|
||||
ContentUnavailableView {
|
||||
Label("No Hosts", systemImage: "rectangle.connected.to.line.below")
|
||||
} description: {
|
||||
Text("Add your punktfunk host with the + button.")
|
||||
} actions: {
|
||||
Button("Add Host") { showAddHost = true }
|
||||
.buttonStyle(.borderedProminent)
|
||||
#if os(iOS)
|
||||
.controlSize(.large)
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
Button("Settings") { showSettings = true }
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private var addHostButton: some View {
|
||||
Button {
|
||||
showAddHost = true
|
||||
} label: {
|
||||
Label("Add Host", systemImage: "plus")
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(macOS)
|
||||
private var settingsButton: some View {
|
||||
Button {
|
||||
showSettings = true
|
||||
} label: {
|
||||
Label("Settings", systemImage: "gearshape")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// macOS caps card width (a huge window shouldn't yield huge cards); on iOS the columns FILL
|
||||
/// the width so the cards stay edge-aligned with the title and bars — sized touch-first: one
|
||||
/// column on iPhone portrait, 3–4 generous cards on iPad.
|
||||
private var gridColumns: [GridItem] {
|
||||
#if os(macOS)
|
||||
[GridItem(.adaptive(minimum: 180, maximum: 240), spacing: 16)]
|
||||
#elseif os(tvOS)
|
||||
[GridItem(.adaptive(minimum: 320), spacing: 48)]
|
||||
#else
|
||||
[GridItem(.adaptive(minimum: 280), spacing: 16)]
|
||||
#endif
|
||||
}
|
||||
|
||||
private var gridSpacing: CGFloat {
|
||||
#if os(tvOS)
|
||||
48 // the focused card scales up — give it room instead of overlapping siblings
|
||||
#else
|
||||
16
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// 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
|
||||
let isConnecting: Bool
|
||||
let isMostRecent: Bool
|
||||
let isBusy: Bool
|
||||
let onConnect: () -> Void
|
||||
let onPair: () -> Void
|
||||
let onSpeedTest: () -> Void
|
||||
let onForget: () -> Void
|
||||
let onRemove: () -> Void
|
||||
|
||||
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) {
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// The streaming overlay HUD: mode + fps/throughput, the capture→client (and, under the stage-2
|
||||
// presenter, capture→present) latency lines, the platform input hint, and disconnect.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
struct StreamHUDView: View {
|
||||
@ObservedObject var model: SessionModel
|
||||
let connection: PunktfunkConnection
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(Color.accentColor)
|
||||
.frame(width: 7, height: 7)
|
||||
Text("\(connection.width)×\(connection.height)@\(connection.refreshHz) \(model.fps) fps \(model.mbps, specifier: "%.1f") Mb/s")
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
}
|
||||
if model.latencyValid {
|
||||
// Capture→client-receipt (skew-corrected); excludes the layer's decode+present —
|
||||
// see LatencyMeter. "(same-host)" when the host didn't answer the skew handshake.
|
||||
Text("capture→client \(model.latencyP50Ms, specifier: "%.1f")/\(model.latencyP95Ms, specifier: "%.1f") ms p50/p95\(model.latencySkewCorrected ? "" : " (same-host)")")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if model.presentLatencyValid {
|
||||
// Capture→present (glass-to-glass, modulo host render→capture) — stage-2 presenter
|
||||
// only; stage-1's layer presents internally with no per-frame stamp.
|
||||
Text("capture→present \(model.presentLatencyP50Ms, specifier: "%.1f")/\(model.presentLatencyP95Ms, specifier: "%.1f") ms p50/p95\(model.presentLatencySkewCorrected ? "" : " (same-host)")")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
// While captured the cursor is hidden+frozen, so the button is keyboard-only
|
||||
// (⌘⎋ or Cmd+Tab release the cursor; released, it's clickable again).
|
||||
#if os(macOS)
|
||||
Text(model.mouseCaptured
|
||||
? "⌘⎋ releases the mouse"
|
||||
: "Click the stream to capture input")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
#elseif os(iOS)
|
||||
// Touch always plays directly; ⌘⎋ (hardware keyboard) toggles kb/mouse.
|
||||
Text(model.mouseCaptured
|
||||
? "⌘⎋ releases keyboard & mouse"
|
||||
: "⌘⎋ captures keyboard & mouse")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
// No focusable control during play: a focusable button steals the controller's
|
||||
// A press (the focus engine consumes it before the host sees it). Disconnect is
|
||||
// the Siri Remote's Menu button (.onExitCommand on the stream) — just hint it.
|
||||
Text("Press Menu to disconnect")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
#else
|
||||
Button("Disconnect (⌘D)") { model.disconnect() }
|
||||
.font(.caption)
|
||||
.keyboardShortcut("d", modifiers: .command)
|
||||
#endif
|
||||
}
|
||||
.padding(10)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10))
|
||||
.padding(10)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Trust-on-first-use prompt: shown over the live-but-blurred stream when connecting to an
|
||||
// unpinned host. The user compares the fingerprint with the one the host logged at startup,
|
||||
// or drops this and runs the PIN pairing ceremony instead.
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct TrustCardView: View {
|
||||
let fingerprint: Data
|
||||
let hostName: String
|
||||
let onCancel: () -> Void
|
||||
let onTrust: () -> Void
|
||||
let onPairInstead: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 14) {
|
||||
Image(systemName: "lock.shield")
|
||||
.font(.system(size: 36, weight: .light))
|
||||
.foregroundStyle(.tint)
|
||||
Text("Verify \(hostName)")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("First connection. Compare this fingerprint with the one "
|
||||
+ "punktfunk-host logged at startup (\u{201C}clients pin this "
|
||||
+ "fingerprint\u{201D}):")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Text(Self.format(fingerprint: fingerprint))
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
#if !os(tvOS)
|
||||
.textSelection(.enabled)
|
||||
#endif
|
||||
.padding(10)
|
||||
.background(.quaternary, in: RoundedRectangle(cornerRadius: 8))
|
||||
HStack(spacing: 12) {
|
||||
Button("Cancel", role: .cancel, action: onCancel)
|
||||
#if !os(tvOS)
|
||||
.keyboardShortcut(.cancelAction)
|
||||
#endif
|
||||
Button("Trust & Connect", action: onTrust)
|
||||
.buttonStyle(.borderedProminent)
|
||||
#if !os(tvOS)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
#endif
|
||||
}
|
||||
#if os(iOS)
|
||||
.controlSize(.large)
|
||||
#endif
|
||||
// The verified alternative to eyeballing hex: drop this session (the host
|
||||
// serves one connection at a time) and run the SPAKE2 PIN ceremony instead.
|
||||
Button("Pair with PIN instead…", action: onPairInstead)
|
||||
#if os(macOS)
|
||||
.buttonStyle(.link)
|
||||
#else
|
||||
.buttonStyle(.borderless)
|
||||
#endif
|
||||
.font(.callout)
|
||||
}
|
||||
.padding(28)
|
||||
.frame(maxWidth: 440)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18))
|
||||
}
|
||||
|
||||
/// 64 hex chars → four groups per line, two lines — easy to eyeball against the log.
|
||||
private static func format(fingerprint: Data) -> String {
|
||||
let hex = fingerprint.map { String(format: "%02x", $0) }.joined()
|
||||
let groups = stride(from: 0, to: hex.count, by: 8).map { i -> String in
|
||||
let start = hex.index(hex.startIndex, offsetBy: i)
|
||||
let end = hex.index(start, offsetBy: min(8, hex.count - i))
|
||||
return String(hex[start..<end])
|
||||
}
|
||||
return groups.chunks(of: 4).map { $0.joined(separator: " ") }.joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
|
||||
private extension Array {
|
||||
func chunks(of size: Int) -> [[Element]] {
|
||||
stride(from: 0, to: count, by: size).map { Array(self[$0..<Swift.min($0 + size, count)]) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user