The Apple half of settings profiles (design/client-settings-profiles.md §4) starts with the thing the desktop shells got for free from the shared Rust core: a model, and one place that resolves it. `SettingsOverlay`/`StreamProfile`/`ProfileCatalog` mirror `pf-client-core::profiles` field for field, unknown-key carry-through included — an older build that opens a profile a newer one wrote must not gut it on save. The catalog lives in the App Group suite beside the saved hosts, because the things that point at it are fields on the host record: `StoredHost` gains `profileID` and `pinnedProfileIDs`, both optional and appended last, because that JSON is a widget contract. `EffectiveSettings` is the resolution — globals with the session's overlay on top, computed once at connect and latched in `SessionSettings` for the rest of it. That latch is the point. Ten sites across the app AND the kit read `UserDefaults` directly mid-session (the presenter's priority and VRR, the vsync flip, the match-window follower, the scroll sign, the touch model, the mouse model, 4:4:4, the audio endpoints); a profile that reached some of them and not others would be worse than no feature at all. They now read the latch, which off-session is the plain globals — byte for byte what they saw before. The deep-link grammar grows up with it: `DeepLink` becomes a full port of `deeplink.rs` — routes, host-ref forms, `fp`/`host` recovery, one-off `profile`, and every refusal code — and the Swift suite now runs `clients/shared/deeplink-vectors.json` itself, read from the source tree so there is no second copy to drift. All 44 cases green. The router refuses what it can't honor by name: a profile that doesn't exist, an ambiguous one, a fingerprint that contradicts the pin. A shortcut that streams with the wrong settings is worse than one that explains itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
790 lines
44 KiB
Swift
790 lines
44 KiB
Swift
// Session state for the app shell: owns the connection, the input capture, the trust
|
||
// handshake phase, and the pump-thread → main-actor stats relay.
|
||
|
||
import Foundation
|
||
import os
|
||
import PunktfunkKit
|
||
import SwiftUI
|
||
|
||
#if canImport(AppKit)
|
||
import AppKit
|
||
#elseif canImport(UIKit)
|
||
import UIKit
|
||
#endif
|
||
#if os(tvOS)
|
||
import AVFoundation // AVPlayer.eligibleForHDRPlayback — the TV-capability HDR gate
|
||
#endif
|
||
|
||
/// 1 Hz latency-stage line mirrored to the unified log so the stages can be read WITHOUT the
|
||
/// on-screen HUD (Console.app, wirelessly on an iPad/Apple TV). The HUD is not a neutral
|
||
/// instrument: any visible overlay forces the metal layer through the compositor, which costs a
|
||
/// refresh period on the vsync-latched platforms — this is how to measure with it off.
|
||
private let statsLog = Logger(subsystem: "io.unom.punktfunk", category: "stats")
|
||
|
||
/// Pump-thread-side frame counters; a 1 Hz main-actor timer drains them into @Published
|
||
/// values. NSLock instead of an actor — the writer is the (non-async) pump thread.
|
||
final class FrameMeter: @unchecked Sendable {
|
||
private let lock = NSLock()
|
||
private var frames = 0
|
||
private var bytes = 0
|
||
private var totalFrames = 0
|
||
|
||
func note(byteCount: Int) {
|
||
lock.lock()
|
||
frames += 1
|
||
bytes += byteCount
|
||
totalFrames += 1
|
||
lock.unlock()
|
||
}
|
||
|
||
/// Returns and resets the per-interval counters (the running total stays).
|
||
func drain() -> (frames: Int, bytes: Int, total: Int) {
|
||
lock.lock()
|
||
defer {
|
||
frames = 0
|
||
bytes = 0
|
||
lock.unlock()
|
||
}
|
||
return (frames, bytes, totalFrames)
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
final class SessionModel: ObservableObject {
|
||
enum Phase: Equatable {
|
||
case idle
|
||
case connecting
|
||
/// Connected to an unpinned host: the stream is live (and pumping — the opening
|
||
/// IDR must not be missed) but input/cursor capture wait for the user to confirm
|
||
/// the observed fingerprint.
|
||
case awaitingTrust(fingerprint: Data)
|
||
case streaming
|
||
}
|
||
|
||
@Published private(set) var phase: Phase = .idle
|
||
@Published private(set) var connection: PunktfunkConnection?
|
||
/// The host this session is for (a value copy; identity = id).
|
||
@Published private(set) var activeHost: StoredHost?
|
||
/// The settings THIS session runs on — the globals with its profile overlaid, resolved once at
|
||
/// connect (design/client-settings-profiles.md §4.2). Also mirrored into `SessionSettings` for
|
||
/// the readers that live in PunktfunkKit and can't see this model.
|
||
@Published private(set) var settings = EffectiveSettings()
|
||
/// The stats-overlay tier for this session: the resolved one at connect, then whatever the
|
||
/// live cycle surfaces (⌃⌥⇧S, the three-finger tap) move it to. Separate from the @AppStorage
|
||
/// global so a profile that overrides the tier actually gets it, without the cycle breaking.
|
||
@Published var statsVerbosity: StatsVerbosity = .normal
|
||
@Published var errorMessage: String?
|
||
@Published var fps = 0
|
||
@Published var mbps = 0.0
|
||
@Published var totalFrames = 0
|
||
/// The unified latency stages (design/stats-unification.md), ms per 1 s window. `host+network`
|
||
/// = capture→received, skew-corrected across machines via the connect-time clock offset: the
|
||
/// stage-2 HUD shows its p50 in the equation line; the stage-1 fallback shows p50/p95 as its
|
||
/// `capture→received` headline. `hostNetworkValid` is false until the first sample drains (and
|
||
/// whenever no host frames arrived in the last interval). `hostNetworkSkewCorrected` = the host
|
||
/// answered the skew handshake (the number is cross-machine valid, not just same-host).
|
||
@Published var hostNetworkP50Ms = 0.0
|
||
@Published var hostNetworkP95Ms = 0.0
|
||
@Published var hostNetworkValid = false
|
||
@Published var hostNetworkSkewCorrected = false
|
||
/// Phase 2 of the same stage: `host+network` split into its two terms via the host's per-AU
|
||
/// 0xCF timing reports (host = capture→fully-sent as the host measured it, network = the
|
||
/// remainder), matched to receipts by pts in `latencySplit`. `splitValid` is false whenever
|
||
/// no timing matched in the window — an old host that never emits the plane, or heavy 0xCF
|
||
/// loss — and the HUD then falls back to the combined `host+network` term.
|
||
@Published var hostP50Ms = 0.0
|
||
@Published var networkP50Ms = 0.0
|
||
@Published var splitValid = false
|
||
/// End-to-end = capture→on-glass, measured directly per frame (never summed from the stages) —
|
||
/// the HUD headline. Only the stage-2 presenter can stamp it (it owns decode + a
|
||
/// CAMetalLayer/display-link present); stays invalid under stage-1, where the layer presents
|
||
/// internally with no per-frame callback.
|
||
@Published var endToEndP50Ms = 0.0
|
||
@Published var endToEndP95Ms = 0.0
|
||
@Published var endToEndValid = false
|
||
@Published var endToEndSkewCorrected = false
|
||
/// The client-local stage terms of the HUD's equation line (single clock, no skew; p50 only):
|
||
/// decode = received→decoded, display = decoded→on-glass (ring wait + render + vsync — the
|
||
/// term the stage-2 presenter exists to shorten).
|
||
@Published var decodeP50Ms = 0.0
|
||
@Published var decodeValid = false
|
||
@Published var displayP50Ms = 0.0
|
||
@Published var displayValid = false
|
||
/// Client-queue wait: core reassembly receipt → the pump's pull (`AccessUnit.pulledNs −
|
||
/// receivedNs`, ABI v9 receipt split — the 2026-07 two-pair investigation). ~0 on a healthy
|
||
/// stream; a persistent value is a client-side standing backlog that used to hide inside
|
||
/// "network". Shown in the detailed tier only when it says something (≥ ~2 ms).
|
||
@Published var clientQueueP50Ms = 0.0
|
||
@Published var clientQueueValid = false
|
||
/// The measured OS present floor (design/apple-presentation-rebuild.md): the deadline
|
||
/// engine's vend→glass pipeline depth — an OS property no client can pace under (~2 refresh
|
||
/// intervals composited; would read ~1 under direct-to-display). The HUD subtracts it from
|
||
/// the shown display/e2e so the numbers describe Punktfunk's own pipeline; raw values stay
|
||
/// in the detailed tier + the stats log. Invalid (0) on macOS arrival (sync-off ≈ no floor)
|
||
/// and under stage-1.
|
||
@Published var osFloorP50Ms = 0.0
|
||
@Published var osFloorValid = false
|
||
|
||
/// The floor-shaved values every HUD tier displays (raw − floor, never below 0). Identical
|
||
/// to the raw values whenever no floor is measured.
|
||
var displayAdjP50Ms: Double { max(0, displayP50Ms - (osFloorValid ? osFloorP50Ms : 0)) }
|
||
var endToEndAdjP50Ms: Double { max(0, endToEndP50Ms - (osFloorValid ? osFloorP50Ms : 0)) }
|
||
var endToEndAdjP95Ms: Double { max(0, endToEndP95Ms - (osFloorValid ? osFloorP50Ms : 0)) }
|
||
/// Unrecoverable network frame drops in the last window (FEC couldn't rebuild them) and their
|
||
/// share of frames offered, `lost/(received+lost)`. The HUD hides the line while zero.
|
||
@Published var lostFrames = 0
|
||
@Published var lostPct = 0.0
|
||
/// Mirrors StreamView's capture state (it owns the input capture; this drives the
|
||
/// HUD's "click to capture" / "⌘⎋ releases" hint).
|
||
@Published var mouseCaptured = false
|
||
/// Resize overlay (design/midstream-resolution-resize.md — client resize UX): true from the
|
||
/// instant a Match-window resize starts steering toward a new size until a frame at that size
|
||
/// decodes (or a safety timeout). Drives the blur+spinner so the unavoidable host-rebuild delay
|
||
/// reads as a deliberate, acknowledged transition instead of a stutter. Pure state lives in
|
||
/// `ResizeIndicator`; this mirrors its `active` for SwiftUI.
|
||
@Published private(set) var resizing = false
|
||
/// START = follower steering (main actor), END = a new-mode IDR's coded dims (decode pump,
|
||
/// hopped to main), TIMEOUT = safety net for a rejected/capped switch that never yields a
|
||
/// differently-sized frame. Ticked from the 1 Hz stats timer.
|
||
private var resizeIndicator = ResizeIndicator()
|
||
|
||
let meter = FrameMeter()
|
||
/// Capture→received (the host+network stage), fed per AU at receipt by the stream view's
|
||
/// onFrame — under both presenters.
|
||
let latency = LatencyMeter()
|
||
/// The host/network split of that same stage: onFrame also records (pts, interval) receipts
|
||
/// here, and the 1 s stats tick drains the connection's 0xCF host timings into it — under
|
||
/// both presenters (the receipt path is presenter-independent).
|
||
let latencySplit = HostNetworkSplitter()
|
||
/// The stage-2 meters, passed to StreamView: end-to-end (capture→on-glass, stamped at
|
||
/// present), decode (received→decoded), display (decoded→on-glass).
|
||
let endToEnd = LatencyMeter()
|
||
let decodeStage = LatencyMeter()
|
||
let displayStage = LatencyMeter()
|
||
/// Client-queue sampler (see `clientQueueP50Ms`) — fed per AU by the stream view's onFrame,
|
||
/// drained by the same 1 s tick as the stage meters.
|
||
let clientQueue = LatencyMeter()
|
||
/// The OS present floor sampler (see `osFloorP50Ms`) — fed one sample per display-link
|
||
/// update by the deadline engine, drained by the same 1 s tick as the stage meters.
|
||
let presentFloor = LatencyMeter()
|
||
/// Cumulative reassembler-drop counter at the last stats drain (per-window `lost` delta).
|
||
private var lastFramesDropped: UInt64 = 0
|
||
private var statsTimer: Timer?
|
||
private var audio: SessionAudio?
|
||
private var gamepadCapture: GamepadCapture?
|
||
private var gamepadFeedback: GamepadFeedback?
|
||
#if os(macOS)
|
||
/// The live session's clipboard bridge (design/clipboard-and-file-transfer.md §5) — created
|
||
/// by `beginStreaming` when the per-host toggle is on and the host advertises
|
||
/// `HOST_CAP_CLIPBOARD`; stopped (off-main, drain joined) in `disconnect`.
|
||
private var clipboardSync: ClipboardSync?
|
||
#endif
|
||
/// Whether clipboard sync is live (host-acked `ClipState.enabled`) — drives the Stream menu
|
||
/// item's title and the settings footnote. Always false off-macOS.
|
||
@Published private(set) var clipboardEnabled = false
|
||
/// The host's last `ClipState.reason` (`CLIP_REASON_*`) — why an enable was refused
|
||
/// (backend unavailable / policy disabled / …); 0 = OK.
|
||
@Published private(set) var clipboardReason: UInt8 = 0
|
||
#if os(tvOS)
|
||
/// Siri Remote → host pointer while streaming (touch surface moves, press = left click,
|
||
/// Play/Pause = right click) + the remote's deliberate exit (hold Back ≥ 1 s). See
|
||
/// SiriRemotePointer — same trust gate/lifecycle as the gamepad capture above.
|
||
private var remotePointer: SiriRemotePointer?
|
||
#endif
|
||
|
||
var isBusy: Bool { phase != .idle }
|
||
|
||
/// True while a streaming session is running in the background under the opt-in keep-alive
|
||
/// (audio plays, video dropped, timeout armed). Drives the Live Activity's stage/countdown (M3)
|
||
/// and is cleared on foreground or teardown. iOS/iPadOS only in practice.
|
||
@Published private(set) var isBackgrounded = false
|
||
/// When the backgrounded keep-alive will auto-disconnect (nil unless backgrounded) — drives the
|
||
/// Live Activity countdown. Set alongside `backgroundTimer`.
|
||
@Published private(set) var backgroundDeadline: Date?
|
||
/// Bounded auto-disconnect for a backgrounded keep-alive session. Fires on `.main`.
|
||
private var backgroundTimer: DispatchSourceTimer?
|
||
|
||
/// Holds off display sleep (and, on macOS, the screen saver) for the life of a session —
|
||
/// nothing about watching a stream looks like user activity to the OS, least of all a
|
||
/// controller-only session. Acquired in `beginStreaming`, released in `disconnect`.
|
||
private let displaySleepGuard = DisplaySleepGuard()
|
||
|
||
/// `allowTofu` gates the trust-on-first-use prompt for an unpinned host: it is only true
|
||
/// when the host EXPLICITLY advertised `pair=optional` (rule 3a). For any other unpinned host
|
||
/// — `pair=required`, a manually-typed host, or a discovered host with no/unknown `pair`
|
||
/// field — TOFU is forbidden (rule 3b): the connect refuses rather than offering trust, and
|
||
/// the user is routed to PIN pairing by the caller. (A pinned host connects regardless: its
|
||
/// stored fingerprint is the trust decision.)
|
||
///
|
||
/// `requestAccess` is the no-PIN delegated-approval path: open an identified connect the host
|
||
/// PARKS until the operator clicks Approve in its console, then admits the SAME connection (no
|
||
/// reconnect). The handshake budget is widened to exceed the host's park window, and a
|
||
/// successful connect streams directly (the approval IS the trust decision) — the caller pins
|
||
/// the observed fingerprint as paired. `host.pinnedSHA256`, when set, pins the advertised cert
|
||
/// for the wait; nil = trust-on-first-use.
|
||
/// `onUnreachable`, when set, replaces the "could not connect" alert for a plain connect
|
||
/// failure: the caller takes over recovery (the Wake-on-LAN wait for a host that stopped
|
||
/// advertising). It never fires for the delegated-approval path, whose failure text carries
|
||
/// its own instructions.
|
||
/// `effective` is the whole stream mode + input/audio configuration for this session, already
|
||
/// resolved from the globals and the session's profile by the caller — the ONE place that
|
||
/// resolution happens (§4.4). It is latched into `SessionSettings` here so the kit-side
|
||
/// readers (the presenter, the input paths, the match-window follower) see the same values
|
||
/// this connect asked the host for, instead of re-reading the globals mid-session.
|
||
func connect(to host: StoredHost, effective: EffectiveSettings,
|
||
gamepad: PunktfunkConnection.GamepadType = .auto,
|
||
launchID: String? = nil,
|
||
allowTofu: Bool = false,
|
||
autoTrust: Bool = false,
|
||
requestAccess: Bool = false,
|
||
onUnreachable: (@MainActor () -> Void)? = nil) {
|
||
guard phase == .idle else { return }
|
||
phase = .connecting
|
||
activeHost = host
|
||
errorMessage = nil
|
||
settings = effective
|
||
statsVerbosity = StatsVerbosity(rawValue: effective.statsVerbosity) ?? .normal
|
||
SessionSettings.begin(effective)
|
||
let mode = RenderScale.apply(
|
||
baseWidth: effective.width, baseHeight: effective.height,
|
||
scale: effective.renderScale,
|
||
maxDimension: RenderScale.maxDimension(codec: effective.codec))
|
||
let (width, height) = (mode.width, mode.height)
|
||
let hz = UInt32(clamping: effective.refreshHz)
|
||
let compositor = PunktfunkConnection.Compositor(
|
||
rawValue: UInt32(clamping: effective.compositor)) ?? .auto
|
||
let bitrateKbps = UInt32(clamping: effective.bitrateKbps)
|
||
let audioChannels = UInt8(clamping: effective.audioChannels)
|
||
let hdrEnabled = effective.hdrEnabled
|
||
let preferredCodec = PunktfunkConnection.codecByte(effective.codec)
|
||
let pin = host.pinnedSHA256
|
||
// Capability gate (main-actor — screen APIs): only advertise HDR when this display can
|
||
// actually present it, so the host sends a proper SDR stream to an SDR display rather than
|
||
// BT.2020 PQ the panel would mis-tone-map. The display self-tone-maps HDR from the mastering
|
||
// metadata we apply (Step 2) when it IS HDR.
|
||
let displayHDR: Bool = {
|
||
#if os(macOS)
|
||
// POTENTIAL, not current, headroom: `maximumExtendedDynamicRangeColorComponentValue`
|
||
// is the CURRENTLY-ALLOCATED headroom, which macOS hands out on demand — on an idle
|
||
// SDR desktop it reads 1.0 even with HDR enabled and active (external HDR displays
|
||
// like the Samsung G95SC allocate EDR only when content asks). Gating on it means an
|
||
// HDR monitor never gets advertised at connect time. `maximumPotential…` is the
|
||
// mode-independent capability (the macOS analogue of the tvOS/iOS gates below).
|
||
return (NSScreen.main?.maximumPotentialExtendedDynamicRangeColorComponentValue ?? 1.0) > 1.0
|
||
#elseif os(tvOS)
|
||
// NOT the EDR headroom here: on tvOS that reflects the CURRENT output mode, and
|
||
// Apple's recommended setup runs an SDR home screen with Match Content — an
|
||
// HDR-capable TV would read 1.0 at connect time and never be advertised. The
|
||
// session switches the display to HDR10 itself once streaming (AVDisplayManager —
|
||
// see StreamViewIOS), so gate on the TV's mode-independent capability; if the
|
||
// switch never lands, the presenter's in-shader tone-map keeps PQ safe anyway.
|
||
return AVPlayer.eligibleForHDRPlayback
|
||
#else
|
||
return UIScreen.main.potentialEDRHeadroom > 1.0
|
||
#endif
|
||
}()
|
||
let hdrCapable = hdrEnabled && displayHDR
|
||
// 4:4:4 opt-IN (default off): full chroma is a per-client choice — a clear win for
|
||
// desktop/text work, but at a fixed bitrate it spends bits on chroma that game content
|
||
// doesn't visibly need, and the encode/decode pixel rate rises. The host allows it by
|
||
// default (PUNKTFUNK_444, default on), so this toggle is the one real switch; the
|
||
// hardware-decode probe below still gates what can actually be advertised.
|
||
let want444 = effective.enable444
|
||
Task.detached(priority: .userInitiated) {
|
||
// PunktfunkConnection.init blocks on the QUIC handshake — keep it off the main
|
||
// actor. The persistent identity is presented on every connect so a paired
|
||
// host recognizes this Mac (nil = anonymous, fine for hosts without
|
||
// --require-pairing; Keychain/generation failure must not block connecting).
|
||
let identity = (try? ClientIdentityStore.shared.load())?.identity
|
||
// Advertise 10-bit + HDR10 when enabled: the host upgrades to a BT.2020 PQ Main10 stream
|
||
// only for actual HDR content (its own gate); the VideoToolbox/Metal present path is
|
||
// HDR-capable (P010 + itur_2100_PQ + EDR). 0 keeps the 8-bit BT.709 SDR stream.
|
||
var videoCaps: UInt8 = hdrCapable
|
||
? (PunktfunkConnection.videoCap10Bit | PunktfunkConnection.videoCapHDR)
|
||
: 0
|
||
// Advertise full-chroma 4:4:4 only when allowed AND this device can HARDWARE-decode it
|
||
// (software 4:4:4 is too slow for real-time). The host content-gates depth, so an
|
||
// HDR-advertised session can still receive an 8-bit 4:4:4 stream (SDR content) — require
|
||
// BOTH depths there. Otherwise a no-op (the host emits 4:4:4 only if it too opted in);
|
||
// `chromaFormat` on the connection reflects what was actually resolved.
|
||
let canDecode444 =
|
||
hdrCapable
|
||
? (Stage444Probe.hwDecode444_8bit && Stage444Probe.hwDecode444_10bit)
|
||
: Stage444Probe.hwDecode444_8bit
|
||
if want444, canDecode444 {
|
||
videoCaps |= PunktfunkConnection.videoCap444
|
||
}
|
||
// This client's VideoToolbox path decodes H.264 and HEVC everywhere, and AV1 when
|
||
// this device has an AV1 hardware decoder (M3-class Macs, A17 Pro-class iPhones —
|
||
// VideoToolbox has no software AV1 decoder, so advertising it elsewhere would invite
|
||
// a stream that can't decode; see AV1.swift). The host resolves the emitted codec
|
||
// from these + the soft `preferredCodec`; `resolvedCodec` reflects what it chose.
|
||
var videoCodecs = PunktfunkConnection.codecH264 | PunktfunkConnection.codecHEVC
|
||
if AV1.hardwareDecodeSupported { videoCodecs |= PunktfunkConnection.codecAV1 }
|
||
// PyroWave (wired LAN) is a pure opt-in: picking it in the codec setting both
|
||
// advertises the bit and prefers it — the host never auto-selects it, and the
|
||
// picker only offers it when the Metal decode probe passed (simdgroup floor ≈ A13;
|
||
// every M-series Mac and the ATV 4K gen 3 pass). The decoder self-configures from
|
||
// the per-frame sequence header (4:2:0/4:4:4, SDR/PQ — design/pyrowave-444-hdr.md),
|
||
// so the session keeps the user's HDR/10-bit/4:4:4 caps exactly like HEVC/AV1.
|
||
if preferredCodec == PunktfunkConnection.codecPyroWave, MetalWaveletDecoder.supported {
|
||
videoCodecs |= PunktfunkConnection.codecPyroWave
|
||
}
|
||
// Cursor channel (remote-desktop-sweep M2, macOS): sessions STARTING in the desktop
|
||
// mouse model advertise local cursor rendering — the host then stops compositing
|
||
// the pointer and forwards shape/state, which StreamView draws as the real
|
||
// NSCursor. Capture-mode sessions keep today's composited pointer.
|
||
#if os(macOS)
|
||
let clientCaps: UInt8 =
|
||
(MouseInputMode(rawValue: effective.mouseMode) ?? .capture) == .desktop ? 0x01 : 0
|
||
#else
|
||
let clientCaps: UInt8 = 0
|
||
#endif
|
||
let result = Result { try PunktfunkConnection(
|
||
host: host.address, port: host.port,
|
||
width: width, height: height, refreshHz: hz,
|
||
pinSHA256: pin, identity: identity, compositor: compositor,
|
||
gamepad: gamepad, bitrateKbps: bitrateKbps, videoCaps: videoCaps,
|
||
audioChannels: audioChannels,
|
||
videoCodecs: videoCodecs, preferredCodec: preferredCodec,
|
||
clientCaps: clientCaps, launchID: launchID,
|
||
// Delegated approval: the host holds this connect open until the operator approves
|
||
// it (~180 s) — outwait that window so a slow approval still lands here. Normal
|
||
// connects keep the snappy default.
|
||
timeoutMs: requestAccess ? 185_000 : 10_000) }
|
||
await MainActor.run { [weak self] in
|
||
guard let self else { return }
|
||
// The user may have abandoned this attempt (window closed, another host
|
||
// clicked) while the handshake was in flight — don't resurrect a session
|
||
// for a dead window, and especially don't start its mic uplink.
|
||
guard self.phase == .connecting, self.activeHost?.id == host.id else {
|
||
if case .success(let conn) = result {
|
||
Task.detached { conn.close() } // joins Rust threads — off-main
|
||
}
|
||
return
|
||
}
|
||
switch result {
|
||
case .success(let conn):
|
||
if pin != nil || autoTrust || requestAccess {
|
||
// requestAccess: the operator approved this device on the host, so the
|
||
// session is trusted — stream directly (the caller pins it as paired).
|
||
self.connection = conn
|
||
self.startStatsTimer()
|
||
self.beginStreaming()
|
||
} else if allowTofu {
|
||
// Host advertised pair=optional — offer the reduced-security TOFU prompt
|
||
// over the live (blurred) stream (rule 3a).
|
||
self.connection = conn
|
||
self.startStatsTimer()
|
||
self.phase = .awaitingTrust(fingerprint: conn.hostFingerprint)
|
||
} else {
|
||
// Unpinned and TOFU not permitted (rule 3b): never let this silently
|
||
// become trustable. Drop the connection; the caller routes to pairing.
|
||
Task.detached { conn.close() } // joins Rust threads — off-main
|
||
self.phase = .idle
|
||
self.activeHost = nil
|
||
self.errorMessage = "\(host.displayName) is not paired yet. "
|
||
+ "Pair with its PIN before streaming."
|
||
}
|
||
case .failure(let error):
|
||
self.phase = .idle
|
||
self.activeHost = nil
|
||
if case PunktfunkClientError.rejected(let rejection) = error {
|
||
// The host answered and stated its reason (declined / approval timed
|
||
// out / busy / versions differ) — show that, and never wake-retry a
|
||
// host that is demonstrably awake.
|
||
self.errorMessage = "\(host.displayName): \(rejection.userMessage)"
|
||
} else if let onUnreachable, !requestAccess {
|
||
// The caller owns recovery (wake-and-retry) — no error alert here; its
|
||
// own overlay explains what's happening.
|
||
onUnreachable()
|
||
} else if requestAccess {
|
||
// The delegated-approval connect ended without being admitted: the
|
||
// operator didn't approve it before the host's park window elapsed (or
|
||
// the host was unreachable).
|
||
self.errorMessage = "\(host.displayName) didn't let this device in. "
|
||
+ "Approve it in the host's web console (port 47992 → Pairing), then "
|
||
+ "request access again — the request expires after a few minutes."
|
||
} else {
|
||
self.errorMessage = pin != nil
|
||
? "Could not connect to \(host.displayName) — host unreachable, "
|
||
+ "not running, its identity no longer matches the pinned "
|
||
+ "fingerprint, or it requires pairing and no longer "
|
||
+ "recognizes this Mac (right-click the host card to pair "
|
||
+ "again)."
|
||
: "Could not connect to \(host.displayName) — is punktfunk-host "
|
||
+ "running on \(host.address):\(host.port)? If it requires "
|
||
+ "pairing, right-click the host card and pair with its PIN "
|
||
+ "first."
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Background keep-alive (opt-in, iOS)
|
||
|
||
/// Enter the backgrounded keep-alive state: keep audio playing, DROP video decode (no GPU work
|
||
/// off-screen), mute the mic (privacy), and arm a bounded auto-disconnect. The caller
|
||
/// (ContentView's scenePhase driver) gates this on the setting + `.streaming`; a no-op otherwise.
|
||
/// The video-drop seam is read by both pumps every iteration (`connection.isVideoDropped`).
|
||
func enterBackground(timeoutMinutes: Int) {
|
||
guard phase == .streaming, let conn = connection, !isBackgrounded else { return }
|
||
isBackgrounded = true
|
||
conn.setVideoDropped(true)
|
||
audio?.setMicMuted(true)
|
||
// Non-deliberate on fire (keep the host linger) so a user who returns late reconnects fast,
|
||
// exactly like today's network-drop path. min 1 minute guards a nonsense setting.
|
||
let minutes = max(1, timeoutMinutes)
|
||
backgroundDeadline = Date().addingTimeInterval(TimeInterval(minutes * 60))
|
||
let timer = DispatchSource.makeTimerSource(queue: .main)
|
||
timer.schedule(deadline: .now() + .seconds(minutes * 60))
|
||
timer.setEventHandler { [weak self] in
|
||
// The timer fires on `.main`, so the actor's executor is the main thread here.
|
||
MainActor.assumeIsolated { self?.disconnect(deliberate: false) }
|
||
}
|
||
backgroundTimer?.cancel()
|
||
backgroundTimer = timer
|
||
timer.resume()
|
||
}
|
||
|
||
/// Return to foreground: cancel the timeout, resume mic + video, and force a clean re-anchor —
|
||
/// request a fresh IDR (infinite GOP: it won't come on its own) and let the pump's freeze gate
|
||
/// withhold the concealed frames until it lands (it auto-arms on the resumed frame-index gap).
|
||
func exitBackground() {
|
||
guard isBackgrounded else { return }
|
||
isBackgrounded = false
|
||
backgroundDeadline = nil
|
||
backgroundTimer?.cancel()
|
||
backgroundTimer = nil
|
||
audio?.setMicMuted(false)
|
||
if let conn = connection {
|
||
conn.setVideoDropped(false)
|
||
conn.requestKeyframe()
|
||
}
|
||
}
|
||
|
||
/// Follow a live stats-overlay cycle (⌃⌥⇧S, the three-finger tap, the Stream menu). Those
|
||
/// surfaces write the GLOBAL setting as they always have; this moves the session's own tier
|
||
/// with it, so cycling still works in a session a profile put on a different tier.
|
||
func setStatsVerbosity(_ tier: StatsVerbosity) {
|
||
guard statsVerbosity != tier else { return }
|
||
statsVerbosity = tier
|
||
settings.statsVerbosity = tier.rawValue
|
||
SessionSettings.setStatsVerbosity(tier.rawValue)
|
||
}
|
||
|
||
/// The user confirmed the fingerprint: returns it for pinning and enters streaming.
|
||
func confirmTrust() -> Data? {
|
||
guard case .awaitingTrust(let fingerprint) = phase else { return nil }
|
||
beginStreaming()
|
||
return fingerprint
|
||
}
|
||
|
||
func rejectTrust() {
|
||
disconnect()
|
||
}
|
||
|
||
/// Tear the session down. `deliberate` (the default) means a user-initiated quit — signal
|
||
/// `disconnectQuit()` so the host skips the keep-alive linger; `sessionEnded()` (a host-ended /
|
||
/// dropped session) passes `false` to leave the linger intact.
|
||
func disconnect(deliberate: Bool = true) {
|
||
statsTimer?.invalidate()
|
||
statsTimer = nil
|
||
// Release the session's resolved settings: from here every reader falls back to the plain
|
||
// globals, which is exactly what they saw before profiles existed.
|
||
SessionSettings.end()
|
||
// No-op when this session never reached `.streaming` (a refused/aborted connect).
|
||
displaySleepGuard.release()
|
||
// Drop any armed background keep-alive (incl. the timeout that just fired us).
|
||
backgroundTimer?.cancel()
|
||
backgroundTimer = nil
|
||
isBackgrounded = false
|
||
backgroundDeadline = nil
|
||
let audio = self.audio
|
||
self.audio = nil
|
||
// Gamepad capture is main-actor (releases held buttons on the wire while the
|
||
// connection is still up); the feedback drain joins off-main like audio.
|
||
gamepadCapture?.stop()
|
||
gamepadCapture = nil
|
||
#if os(tvOS)
|
||
remotePointer?.stop() // releases any held click while the connection is still up
|
||
remotePointer = nil
|
||
#endif
|
||
let feedback = gamepadFeedback
|
||
gamepadFeedback = nil
|
||
#if os(macOS)
|
||
let clipboard = clipboardSync
|
||
clipboardSync = nil
|
||
#endif
|
||
clipboardEnabled = false
|
||
clipboardReason = 0
|
||
if let conn = connection {
|
||
// Drain-thread teardown waits the pullers out and close() waits out in-flight
|
||
// polls + joins the Rust worker threads — keep all of it off the main actor,
|
||
// in this order (no poll left on any plane when the handle is freed).
|
||
Task.detached {
|
||
audio?.stop()
|
||
feedback?.stop()
|
||
#if os(macOS)
|
||
clipboard?.stop() // disables sync on the wire while the connection is still up
|
||
#endif
|
||
// Deliberate user quit → tell the host to skip the keep-alive linger (must precede close).
|
||
if deliberate { conn.disconnectQuit() }
|
||
conn.close()
|
||
}
|
||
} else {
|
||
Task.detached {
|
||
audio?.stop()
|
||
feedback?.stop()
|
||
#if os(macOS)
|
||
clipboard?.stop()
|
||
#endif
|
||
}
|
||
}
|
||
connection = nil
|
||
activeHost = nil
|
||
phase = .idle
|
||
fps = 0
|
||
mbps = 0
|
||
hostNetworkValid = false
|
||
splitValid = false
|
||
endToEndValid = false
|
||
decodeValid = false
|
||
displayValid = false
|
||
clientQueueValid = false
|
||
osFloorValid = false
|
||
lostFrames = 0
|
||
lostPct = 0
|
||
mouseCaptured = false
|
||
resizing = false
|
||
resizeIndicator = ResizeIndicator() // no stale target/timer into the next session
|
||
}
|
||
|
||
/// Called (via the main actor) when the pump hits end-of-session.
|
||
func sessionEnded() {
|
||
guard connection != nil else { return }
|
||
let name = activeHost?.displayName ?? "host"
|
||
disconnect(deliberate: false) // host/network ended it — keep the linger for a reconnect
|
||
errorMessage = "Session ended by \(name)."
|
||
}
|
||
|
||
/// Resize overlay START (main actor — from the Match-window follower's `onResizeTarget`): the
|
||
/// window began differing from the live mode, so a `Reconfigure` toward `(width, height)` is
|
||
/// imminent. Show the blur+spinner immediately, before the debounced request even leaves.
|
||
func resizeTargeted(width: UInt32, height: UInt32) {
|
||
resizeIndicator.steering(
|
||
width: width, height: height, now: Date().timeIntervalSinceReferenceDate)
|
||
resizing = resizeIndicator.active
|
||
}
|
||
|
||
/// Resize overlay END (main actor — hopped from the decode pump's `onDecodedSize`): a new-mode
|
||
/// IDR decoded at `(width, height)`. Clears the overlay only when that matches the size we're
|
||
/// steering to (a same-size loss-recovery IDR, or the initial connect IDR, is a no-op).
|
||
func resizeDecoded(width: Int, height: Int) {
|
||
resizeIndicator.decoded(width: UInt32(max(width, 0)), height: UInt32(max(height, 0)))
|
||
resizing = resizeIndicator.active
|
||
}
|
||
|
||
private func beginStreaming() {
|
||
guard let conn = connection else { return }
|
||
// Input capture itself is owned by StreamView (engaged by the captureEnabled
|
||
// flip this phase change causes, released/re-engaged by the user from there).
|
||
phase = .streaming
|
||
displaySleepGuard.acquire()
|
||
// Audio starts with streaming, not during the trust prompt — no host sound (or
|
||
// mic uplink!) before the user trusted the host. Devices and the mic switch come from the
|
||
// session's resolved settings ("" = system default), so a profile that turns the mic on
|
||
// for work calls applies to the uplink too.
|
||
let audio = SessionAudio(connection: conn)
|
||
audio.start(
|
||
speakerUID: settings.speakerUID,
|
||
micUID: settings.micUID,
|
||
micChannel: settings.micChannel,
|
||
micEnabled: settings.micEnabled)
|
||
self.audio = audio
|
||
// Gamepads: forward every controller GamepadManager selected — each on its own wire pad
|
||
// index (a pin forwards only one, Automatic forwards all) — and render the host's feedback
|
||
// back to the pad it's addressed to (rumble always; lightbar/player-LEDs/adaptive-triggers
|
||
// when a pad's virtual device is a DualSense). Same trust gate as audio — nothing is
|
||
// forwarded during the trust prompt.
|
||
let capture = GamepadCapture(connection: conn, manager: .shared)
|
||
// The cross-client escape chord (hold L1+R1+Start+Select 1.5 s) — on tvOS the only
|
||
// controller way out of a stream (B/Menu is swallowed during sessions; see ContentView).
|
||
capture.onDisconnectRequest = { [weak self] in self?.disconnect() }
|
||
capture.start()
|
||
gamepadCapture = capture
|
||
let feedback = GamepadFeedback(connection: conn, manager: .shared)
|
||
feedback.start()
|
||
gamepadFeedback = feedback
|
||
#if os(macOS)
|
||
// Shared clipboard: opt-in per host AND host-advertised (older hosts / operator-disabled
|
||
// hosts never see a ClipControl). Same trust gate as audio — nothing is announced
|
||
// during the trust prompt.
|
||
if activeHost?.clipboardSync == true, conn.hostSupportsClipboard {
|
||
startClipboardSync(conn)
|
||
}
|
||
#endif
|
||
#if os(tvOS)
|
||
let pointer = SiriRemotePointer(connection: conn)
|
||
pointer.onDisconnectRequest = { [weak self] in self?.disconnect() }
|
||
pointer.start()
|
||
remotePointer = pointer
|
||
#endif
|
||
}
|
||
|
||
#if os(macOS)
|
||
/// Create + start the session's clipboard bridge and route its host acks into the published
|
||
/// UI state. `ClipboardSync.start()` sends the enable; the host's `.state` answer flips
|
||
/// `clipboardEnabled` (or leaves it false with a `clipboardReason` the UI can explain).
|
||
private func startClipboardSync(_ conn: PunktfunkConnection) {
|
||
let sync = ClipboardSync(connection: conn)
|
||
sync.onState = { [weak self] enabled, _, reason in
|
||
Task { @MainActor in
|
||
self?.clipboardEnabled = enabled
|
||
self?.clipboardReason = reason
|
||
}
|
||
}
|
||
sync.start()
|
||
clipboardSync = sync
|
||
}
|
||
#endif
|
||
|
||
/// Flip clipboard sync mid-session (the Stream menu). Off → on requires the host cap; on →
|
||
/// off tears the bridge down (off-main — the drain join must not block the main actor) and
|
||
/// tells the host, which drops any selection we own there. No-op off-macOS or while idle.
|
||
func toggleClipboardSync() {
|
||
#if os(macOS)
|
||
guard let conn = connection, phase == .streaming else { return }
|
||
if let sync = clipboardSync {
|
||
clipboardSync = nil
|
||
clipboardEnabled = false
|
||
clipboardReason = 0
|
||
Task.detached { sync.stop() }
|
||
} else if conn.hostSupportsClipboard {
|
||
startClipboardSync(conn)
|
||
}
|
||
#endif
|
||
}
|
||
|
||
private func startStatsTimer() {
|
||
lastFramesDropped = 0 // a fresh connection's cumulative drop counter starts at 0
|
||
latencySplit.reset() // no stale receipts/samples from a previous session
|
||
let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
|
||
guard let self else { return }
|
||
Task { @MainActor in
|
||
// Resize-overlay safety net: clear a stuck overlay when a targeted size never
|
||
// decodes (a rejected/capped switch). The decoded-frame END clears it promptly on
|
||
// success; this only fires after the timeout.
|
||
self.resizeIndicator.tick(now: Date().timeIntervalSinceReferenceDate)
|
||
self.resizing = self.resizeIndicator.active
|
||
let (frames, bytes, total) = self.meter.drain()
|
||
self.fps = frames
|
||
self.mbps = Double(bytes) * 8 / 1_000_000
|
||
self.totalFrames = total
|
||
// Per-window `lost` = the delta of the connector's cumulative reassembler-drop
|
||
// counter (0 after close — treat a rewind as no loss rather than underflowing).
|
||
let dropped = self.connection?.framesDropped() ?? 0
|
||
let lost = dropped >= self.lastFramesDropped
|
||
? Int(dropped - self.lastFramesDropped) : 0
|
||
self.lastFramesDropped = dropped
|
||
self.lostFrames = lost
|
||
self.lostPct = lost > 0 ? Double(lost) / Double(frames + lost) * 100 : 0
|
||
if let lat = self.latency.drain() {
|
||
self.hostNetworkP50Ms = lat.p50Ms
|
||
self.hostNetworkP95Ms = lat.p95Ms
|
||
self.hostNetworkSkewCorrected = lat.skewCorrected
|
||
self.hostNetworkValid = true
|
||
} else {
|
||
self.hostNetworkValid = false
|
||
}
|
||
// Phase 2: drain the window's per-AU host timings (0xCF) into the splitter —
|
||
// non-blocking, bounded (a 240 fps window is ~240 reports; the cap only guards
|
||
// a pathological burst). `try?` flattens (SE-0230); a throw (.closed during
|
||
// teardown) just ends the drain. An old host never emits any → splitValid stays
|
||
// false and the HUD keeps the combined host+network term.
|
||
if let conn = self.connection {
|
||
var burst = 0
|
||
while burst < 1024, let t = try? conn.nextHostTiming(timeoutMs: 0) {
|
||
self.latencySplit.noteHostTiming(ptsNs: t.ptsNs, hostUs: t.hostUs)
|
||
burst += 1
|
||
}
|
||
}
|
||
if let s = self.latencySplit.drain() {
|
||
self.hostP50Ms = s.hostP50Ms
|
||
self.networkP50Ms = s.networkP50Ms
|
||
self.splitValid = true
|
||
} else {
|
||
self.splitValid = false
|
||
}
|
||
if let e = self.endToEnd.drain() {
|
||
self.endToEndP50Ms = e.p50Ms
|
||
self.endToEndP95Ms = e.p95Ms
|
||
self.endToEndSkewCorrected = e.skewCorrected
|
||
self.endToEndValid = true
|
||
} else {
|
||
self.endToEndValid = false
|
||
}
|
||
if let d = self.decodeStage.drain() {
|
||
self.decodeP50Ms = d.p50Ms
|
||
self.decodeValid = true
|
||
} else {
|
||
self.decodeValid = false
|
||
}
|
||
let displayWindow = self.displayStage.drain()
|
||
if let d = displayWindow {
|
||
self.displayP50Ms = d.p50Ms
|
||
self.displayValid = true
|
||
} else {
|
||
self.displayValid = false
|
||
}
|
||
if let f = self.presentFloor.drain() {
|
||
self.osFloorP50Ms = f.p50Ms
|
||
self.osFloorValid = true
|
||
} else {
|
||
self.osFloorValid = false
|
||
}
|
||
if let q = self.clientQueue.drain() {
|
||
self.clientQueueP50Ms = q.p50Ms
|
||
self.clientQueueValid = true
|
||
} else {
|
||
self.clientQueueValid = false
|
||
}
|
||
// Mirror the window to the unified log (see statsLog) — one line per second,
|
||
// stages in ms, only while frames actually flowed. `fps` counts RECEIVED AUs;
|
||
// `presents` counts frames that reached glass (the display meter's sample count)
|
||
// — a presents≪fps gap is the presenter dropping/serializing, an fps deficit is
|
||
// upstream (host capture/encode or the network).
|
||
if frames > 0 {
|
||
// The classic fields stay RAW (cross-session comparability with every log
|
||
// captured before the 2026-07 floor policy); the appended trio carries the
|
||
// measured OS present floor and the floor-shaved values the HUD displays.
|
||
let line = String(
|
||
// Swift Int is 64-bit → %lld, NOT %d (which is a 32-bit C int); macOS 26's
|
||
// strict String(format:) validator rejects the %d/Int mismatch and drops
|
||
// the whole line (a cascade error that also mis-blames the float args).
|
||
format: "fps=%lld presents=%lld e2e_p50=%.1f e2e_p95=%.1f hostnet_p50=%.1f "
|
||
+ "decode_p50=%.1f display_p50=%.1f lost=%lld "
|
||
+ "floor_p50=%.1f display_adj=%.1f e2e_adj=%.1f queue_p50=%.1f",
|
||
frames,
|
||
displayWindow?.count ?? 0,
|
||
self.endToEndValid ? self.endToEndP50Ms : -1,
|
||
self.endToEndValid ? self.endToEndP95Ms : -1,
|
||
self.hostNetworkValid ? self.hostNetworkP50Ms : -1,
|
||
self.decodeValid ? self.decodeP50Ms : -1,
|
||
self.displayValid ? self.displayP50Ms : -1,
|
||
lost,
|
||
self.osFloorValid ? self.osFloorP50Ms : -1,
|
||
self.displayValid ? self.displayAdjP50Ms : -1,
|
||
self.endToEndValid ? self.endToEndAdjP50Ms : -1,
|
||
self.clientQueueValid ? self.clientQueueP50Ms : -1)
|
||
statsLog.info("\(line, privacy: .public)")
|
||
}
|
||
}
|
||
}
|
||
// .common so the HUD keeps updating during window drags / menu tracking.
|
||
RunLoop.main.add(timer, forMode: .common)
|
||
statsTimer = timer
|
||
}
|
||
}
|