// 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. // // Ways to establish trust on first contact: the TOFU prompt (host fingerprint over the // live-but-blurred stream, compared with the host's log; only for a host advertising pair=optional), // the PIN pairing ceremony (verifies both sides at once), or — for a host that requires pairing — // delegated approval ("Request Access": a plain identified connect the host parks until the operator // approves this device in its console, no PIN). 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 @AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2 @AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true @AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true @AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue @State private var showAddHost = false @State private var pairingTarget: StoredHost? /// A fresh `pair=required`/unknown host the user tapped: drives the choice between no-PIN /// delegated approval ("Request Access") and the SPAKE2 PIN ceremony (rule 3b). @State private var approvalChoice: ApprovalRequest? /// A delegated-approval connect is in flight (host parks it until the operator approves): /// drives the cancelable "Waiting for approval" prompt and the pin-as-paired on success. @State private var awaitingApproval: ApprovalRequest? @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 switch phase { case .streaming: // A session actually started — remember it on the card ("Connected … ago" // plus the accent ring on the most recent host). guard let host = model.activeHost else { break } store.markConnected(host.id) // Delegated approval just succeeded: the operator let this device in, so pin the // host's observed fingerprint and remember it as paired — future connects are then // silent (rule 1), exactly like after a PIN/TOFU success. Dismisses the wait prompt. if awaitingApproval?.host.id == host.id { if let fp = model.connection?.hostFingerprint { store.pin(host.id, fingerprint: fp) } awaitingApproval = nil } case .idle: // The delegated-approval connect failed, timed out, or was cancelled — drop the // wait prompt (SessionModel surfaces any error via `errorMessage`). if awaitingApproval != nil { awaitingApproval = nil } default: break } } .onDisappear { model.disconnect() } // window closed mid-session (Cmd+N spawns more) // Expose the session to the Scene-level Stream menu (Disconnect ⌘D works even when // the HUD is hidden). tvOS has no such menu. #if !os(tvOS) .focusedSceneValue(\.sessionFocus, SessionFocus( isStreaming: model.connection != nil, disconnect: { model.disconnect() })) #endif #if os(macOS) // Fullscreen only while a session is up (incl. the trust prompt over the blurred stream), // windowed on the host list — so the picker isn't forced fullscreen. Opt-out in Settings. .background(FullscreenController(active: fullscreenWhileStreaming && model.connection != nil)) #endif // 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, onLaunch: { launchTitle(host, $0) }) } } #endif // Fresh pair=required / unknown host: offer the two ways in. An action sheet (not an // alert) so it never collides with the wait alert below. "Request Access" is the no-PIN // delegated-approval path; "Pair with PIN…" runs the SPAKE2 ceremony. The follow-on // presentation is deferred a tick so this dialog is fully dismissed first. .confirmationDialog( "Pairing required", isPresented: Binding( get: { approvalChoice != nil }, set: { if !$0 { approvalChoice = nil } }), titleVisibility: .visible, presenting: approvalChoice ) { req in Button("Request Access") { DispatchQueue.main.async { requestAccess(req) } } Button("Pair with PIN…") { DispatchQueue.main.async { pairingTarget = req.host } } Button("Cancel", role: .cancel) {} } message: { req in Text("\(req.host.displayName) requires pairing. Request access and approve this " + "device in the host's web console (port 3000 → Pairing) — no PIN needed. Or " + "pair with the 4-digit PIN it can display.") } // The delegated-approval wait: the host holds the connection open until the operator // approves it. Cancel returns the UI at once; the in-flight connect is left to time out // and its late result is discarded by SessionModel's connect guard (disconnect resets the // phase/host it checks). .alert( "Waiting for approval", isPresented: Binding( get: { awaitingApproval != nil }, set: { if !$0 { awaitingApproval = nil } }), presenting: awaitingApproval ) { _ in Button("Cancel", role: .cancel) { model.disconnect() } } message: { req in Text("Approve \u{201C}\(localDeviceName)\u{201D} in \(req.host.displayName)'s web " + "console (port 3000 → Pairing). This device connects automatically once you " + "approve it — no need to reconnect.") } } 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($0) }, connectDiscovered: connectDiscovered, onPaired: handlePaired, onLaunchTitle: launchTitle) #else HomeView( store: store, model: model, discovery: discovery, showAddHost: $showAddHost, pairingTarget: $pairingTarget, speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget, showSettings: $showSettings, connect: { connect($0) }, connectDiscovered: connectDiscovered, onPaired: handlePaired, onLaunchTitle: launchTitle) #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) // Fill the whole display in fullscreen, INCLUDING behind the camera housing (notch). // Without this the stream is laid out in the safe area below the notch, so an // aspect-fit video at the display's native mode scales down and leaves black borders. // A fullscreen video behind the notch (a thin top-center strip occluded) is the // expected behavior — same edge-to-edge intent as the iOS/tvOS branches below. Inert // in windowed mode (no notch safe-area inset on a titled window). .ignoresSafeArea() #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 { let placement = HUDPlacement(rawValue: hudPlacement) ?? .topTrailing return 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: placement.alignment) { if captureEnabled && hudEnabled { StreamHUDView(model: model, connection: conn, placement: placement) } } #if os(iOS) // Touch users have no menu / ⌘D, so when the HUD (and its Disconnect button) // is hidden, keep a minimal always-reachable exit in a corner. It rides a // material disc (like the HUD) so the glyph stays legible over a bright frame // — this is the sole touch disconnect path when stats are off. .overlay(alignment: .topLeading) { if captureEnabled && !hudEnabled { Button { model.disconnect() } label: { Image(systemName: "xmark") .font(.headline.weight(.semibold)) .frame(width: 36, height: 36) // Sole touch exit when the HUD is off — a floating glass disc // over the frame (26+, material fallback). interactive: the disc // IS the tap target, so the glass reacts to press. .glassBackground(Circle(), interactive: true) // Match the hit region to the visible disc so every tap also // triggers the interactive-glass press highlight. .contentShape(Circle()) } .buttonStyle(.plain) .padding(12) .accessibilityLabel("Disconnect") } } #endif } } } // MARK: - Connect private func connect(_ host: StoredHost, launchID: String? = nil, allowTofu: Bool? = nil) { // A pinned host connects on its stored fingerprint; an unpinned host may only TOFU when // the host's LIVE advert says `pair=optional` (rule 3a). When the caller doesn't already // know the policy (a saved-card tap / manual entry), resolve it from the current mDNS set: // an unpinned host with no matching `pair=optional` advert routes to the approval choice // (request access / pair with PIN) instead of silently entering the trust prompt (rules // 3b + 4). A pinned host ignores all of this. if host.pinnedSHA256 == nil { let tofuOK = allowTofu ?? discovery.hosts.contains { host.matches($0) && $0.allowsTofu } if !tofuOK { // pair=required / unknown policy / manual entry (rule 3b): never a silent // connect — offer no-PIN delegated approval or the PIN ceremony. approvalChoice = ApprovalRequest( host: host, advertisedFingerprint: advertisedFingerprint(for: host)) return } } startSession(host, launchID: launchID, allowTofu: host.pinnedSHA256 == nil) } /// Resolve the @AppStorage stream mode + input prefs and hand off to the session model. The /// gamepad-type setting resolves NOW (Automatic → match the active physical controller): the /// host's virtual pad backend is fixed per session. `requestAccess` opens the no-PIN /// delegated-approval connect (host parks it until the operator approves). private func startSession( _ host: StoredHost, launchID: String? = nil, allowTofu: Bool, requestAccess: Bool = false ) { 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), audioChannels: UInt8(clamping: audioChannels), launchID: launchID, allowTofu: allowTofu, requestAccess: requestAccess) } /// The no-PIN delegated-approval flow: open an identified connect the host parks until the /// operator approves it in the console, showing the cancelable "Waiting for approval" prompt /// meanwhile. On success the SAME connection is admitted (no reconnect) and the host is pinned /// as paired (see the `.streaming` branch of `onChange`). private func requestAccess(_ req: ApprovalRequest) { guard !model.isBusy else { return } awaitingApproval = req // Pin the advertised certificate for a discovered host (impostor defence during the long // wait); a manually-typed host has no advertised fingerprint, so trust-on-first-use. var host = req.host host.pinnedSHA256 = req.advertisedFingerprint startSession(host, allowTofu: false, requestAccess: true) } /// Picked a title in the (experimental) library: dismiss the browser and start a session that /// asks the host to launch it. private func launchTitle(_ host: StoredHost, _ id: String) { libraryTarget = nil connect(host, launchID: id) } /// Tap a discovered host: save it (so the session has a stored identity and the trust pin /// persists), then connect or pair per the host's advertised policy. The host is the policy /// authority — TOFU is offered ONLY when it explicitly advertised `pair=optional` (rule 3a); /// a `pair=required` host, or one with no/unknown `pair` field, gets the approval choice /// (request access / pair with PIN) (rule 3b). (A pinned discovered host connects silently /// inside `connect`.) 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.allowsTofu { connect(host, allowTofu: true) } else { // pair=required / unknown policy (rule 3b): offer no-PIN delegated approval or PIN. approvalChoice = ApprovalRequest( host: host, advertisedFingerprint: pinFingerprint(d.fingerprintHex)) } } /// 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) } /// The certificate fingerprint a live mDNS advert carries for this saved host (advisory — see /// `HostDiscovery`), to pin during a delegated-approval wait. nil if the host isn't currently /// advertising or advertised no/invalid `fp`. private func advertisedFingerprint(for host: StoredHost) -> Data? { pinFingerprint(discovery.hosts.first { host.matches($0) }?.fingerprintHex) } /// Parse an advertised cert fingerprint (lowercase hex) into the 32-byte pin the connect /// expects; nil unless it's exactly a 32-byte (SHA-256) value, so a malformed advert falls /// back to trust-on-first-use rather than failing the connect closed. private func pinFingerprint(_ hex: String?) -> Data? { guard let hex, let data = Data(hexString: hex), data.count == 32 else { return nil } return data } /// How the host lists this device in its approval prompt (matches PairSheet's client name). private var localDeviceName: String { #if os(macOS) Host.current().localizedName ?? "Mac" #else UIDevice.current.name #endif } // 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, audioChannels: UInt8(clamping: audioChannels), autoTrust: true) } } #if os(macOS) /// Drives the hosting window in/out of native fullscreen from SwiftUI state. Mounted invisibly in /// the view tree; on each `active` change it captures the window and toggles fullscreen only when /// the current state differs (so it never fights a toggle already in flight, and never touches a /// window the user fullscreened manually unless `active` says otherwise). private struct FullscreenController: NSViewRepresentable { let active: Bool func makeNSView(context: Context) -> NSView { NSView() } func updateNSView(_ view: NSView, context: Context) { let want = active DispatchQueue.main.async { guard let window = view.window else { return } let isFull = window.styleMask.contains(.fullScreen) if want != isFull { window.toggleFullScreen(nil) } } } } #endif /// A fresh `pair=required`/unknown host pending a trust decision: drives both the "request access /// vs. pair with PIN" choice and the subsequent approval wait. `advertisedFingerprint` is the /// discovered host's advertised cert (nil for a manually-typed host → trust-on-first-use). private struct ApprovalRequest { let host: StoredHost let advertisedFingerprint: Data? } private extension Data { /// Parse an even-length hex string into bytes; nil on any non-hex character or odd length. /// Used to turn an mDNS-advertised cert fingerprint into a connect pin. init?(hexString: String) { let chars = Array(hexString) guard chars.count.isMultiple(of: 2) else { return nil } var bytes = [UInt8]() bytes.reserveCapacity(chars.count / 2) var i = 0 while i < chars.count { guard let hi = chars[i].hexDigitValue, let lo = chars[i + 1].hexDigitValue else { return nil } bytes.append(UInt8(hi << 4 | lo)) i += 2 } self = Data(bytes) } }