// 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? @State private var libraryTarget: 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) } .sheet(item: $libraryTarget) { host in NavigationStack { LibraryView(store: store, host: host) } } #endif } private var home: some View { #if os(macOS) HomeView( store: store, model: model, discovery: discovery, showAddHost: $showAddHost, pairingTarget: $pairingTarget, speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget, connect: connect, connectDiscovered: connectDiscovered, onPaired: handlePaired) #else HomeView( store: store, model: model, discovery: discovery, showAddHost: $showAddHost, pairingTarget: $pairingTarget, speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget, 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) } }