Files
punktfunk/clients/apple/Sources/PunktfunkClient/ContentView.swift
T
enricobuehler 9291568ce0
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
refactor(apple): decompose ContentView (735 -> 272 lines)
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>
2026-06-12 16:30:34 +02:00

273 lines
12 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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.
//
// 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
struct ContentView: View {
@StateObject private var model = SessionModel()
@StateObject private var store = HostStore()
@StateObject private var discovery = HostDiscovery()
@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?
#if !os(macOS)
@State private var showSettings = false
#endif
var body: some View {
Group {
// The stream view's structural identity MUST be stable across the
// awaiting-trust streaming transition: recreating it restarts the pump,
// which has then already missed the opening IDR (infinite GOP no other
// keyframe ever comes) and decodes nothing. So: one branch per connection,
// trust prompt as an overlay.
if model.connection != nil {
sessionView
} else {
home
}
}
.onAppear {
seedDefaultModeIfNeeded()
autoConnectIfAsked()
}
.onChange(of: model.phase) { _, phase in
// A session actually started remember it on the card ("Connected ago"
// plus the accent ring on the most recent host).
if case .streaming = phase, let host = model.activeHost {
store.markConnected(host.id)
}
}
.onDisappear { model.disconnect() } // window closed mid-session (Cmd+N spawns more)
// On the outer Group so the sheet survives the trust-prompt home transition
// (the "Pair with PIN instead" path disconnects first the host's accept loop
// is sequential, a pairing connection would queue behind the live session).
#if !os(tvOS)
.sheet(item: $pairingTarget) { host in
PairSheet(host: host) { fingerprint in handlePaired(host, fingerprint: fingerprint) }
}
.sheet(item: $speedTestTarget) { host in
SpeedTestSheet(host: host)
}
#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 }
return nil
}()
return ZStack {
stream(captureEnabled: pendingFingerprint == nil)
.blur(radius: pendingFingerprint != nil ? 32 : 0)
.overlay {
if pendingFingerprint != nil {
Color.black.opacity(0.45)
}
}
if let fp = pendingFingerprint {
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)
.frame(minWidth: 640, minHeight: 360)
.background(Color.black)
#elseif os(iOS)
// Streaming is immersive: edge-to-edge under the status bar and home
// indicator, both hidden for the session (they return with the hosts grid).
.background(Color.black)
.ignoresSafeArea()
.statusBarHidden(true)
.persistentSystemOverlays(.hidden)
#else
.background(Color.black)
.ignoresSafeArea()
// Siri Remote MENU = disconnect (the idiomatic tvOS "back"). With no focusable
// disconnect control during play, the controller's buttons flow to the host instead of
// driving the focus engine. NOTE: a game controller's Menu is also forwarded to the
// host as Start the Siri Remote is the intended disconnect path.
.onExitCommand { model.disconnect() }
#endif
}
private func stream(captureEnabled: Bool) -> some View {
Group {
if let conn = model.connection {
StreamView(
connection: conn,
captureEnabled: captureEnabled,
onCaptureChange: { [weak model] captured in
model?.mouseCaptured = captured
},
onFrame: { [meter = model.meter, latency = model.latency, offset = conn.clockOffsetNs] au in
meter.note(byteCount: au.data.count)
latency.record(ptsNs: au.ptsNs, offsetNs: offset)
},
onSessionEnd: { [weak model] in
Task { @MainActor in model?.sessionEnded() }
},
presentMeter: model.presentLatency
)
.overlay(alignment: .topTrailing) {
if captureEnabled { StreamHUDView(model: model, connection: conn) }
}
}
}
}
// 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))
}
/// 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
/// touching the saved host list. PUNKTFUNK_COMPOSITOR=kwin|gamescope| overrides the
/// compositor preference and PUNKTFUNK_REMOTE_GAMEPAD=xbox360|dualsense the virtual
/// pad type (same names as the host env knobs). (IPv4/hostname only.)
private func autoConnectIfAsked() {
guard let target = ProcessInfo.processInfo.environment["PUNKTFUNK_AUTOCONNECT"],
!target.isEmpty, model.phase == .idle
else { return }
let parts = target.split(separator: ":")
var host = StoredHost(name: "", address: String(parts[0]))
if parts.count == 2, let p = UInt16(parts[1]) { host.port = p }
if let mode = ProcessInfo.processInfo.environment["PUNKTFUNK_MODE"] {
let dims = mode.split(separator: "x").compactMap { Int($0) }
if dims.count == 3 {
width = dims[0]
height = dims[1]
hz = dims[2]
}
}
var pref = PunktfunkConnection.Compositor(
rawValue: UInt32(clamping: compositor)) ?? .auto
if let name = ProcessInfo.processInfo.environment["PUNKTFUNK_COMPOSITOR"],
let c = PunktfunkConnection.Compositor(name: name) {
pref = c
}
var pad = GamepadManager.shared.resolveType(
setting: PunktfunkConnection.GamepadType(
rawValue: UInt32(clamping: gamepadType)) ?? .auto)
if let name = ProcessInfo.processInfo.environment["PUNKTFUNK_REMOTE_GAMEPAD"],
let g = PunktfunkConnection.GamepadType(name: name) {
pad = g
}
var bitrate = UInt32(clamping: bitrateKbps)
if let kbps = ProcessInfo.processInfo.environment["PUNKTFUNK_BITRATE_KBPS"],
let v = UInt32(kbps) {
bitrate = v
}
model.connect(
to: host,
width: UInt32(clamping: width), height: UInt32(clamping: height),
hz: UInt32(clamping: hz),
compositor: pref,
gamepad: pad,
bitrateKbps: bitrate,
autoTrust: true)
}
}