Files
punktfunk/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift
T
enricobuehler 90d13de81e
ci / web (pull_request) Successful in 1m13s
ci / bun-nix (pull_request) Successful in 1m47s
apple / swift (pull_request) Successful in 2m3s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 2m57s
ci / rust-arm64 (pull_request) Successful in 3m15s
ci / rust (pull_request) Successful in 4m20s
fix(apple): the stats overlay lied three ways — a frozen clock offset, silently trimmed impossible samples, and Int -1 fallbacks printing as NaN
Field 2026-08-13, Apple TV vs Bazzite VM host, two sessions minutes apart on
the same wire: hostnet_p50 read 17-21 ms, then a physically impossible
4.4 ms (host-side encode alone is ~4.7). Root causes, each its own defect:

- The client consumed the CONNECT-TIME skew offset and froze it: cached in
  a Stage2Pipeline field, in a StreamPump let, and in a ContentView closure
  CAPTURE LIST feeding the hostnet meter and the host/network splitter.
  The core keeps a live estimate (punktfunk_connection_clock_offset_now_ns,
  ABI v10, re-synced every 60 s + on suspected wall-clock steps) and its
  own doc says the connect-time value 'silently corrupts every
  capture-clock comparison' after an NTP step — a VM host steps. Now
  PunktfunkConnection.clockOffsetNs IS the live read (an atomic load
  behind the FFI) and every consumer reads it at use: per record, per AU,
  per enqueue. The Swift audio plane's AvSync observation gets the live
  value through the same property.

- LatencyMeter's impossible-sample guard (≤ 0 after offset correction)
  dropped samples SILENTLY, so a wrong offset didn't invalidate a window —
  it trimmed the impossible half of the shifted distribution and presented
  the surviving tail as a plausible small number ('e2e 0-3 ms p50 /
  23 ms p95' on a session whose true hostnet was ~18 ms; also the
  historical '0 ms network / 0 ms e2e' readings). The refusals are now
  counted and drained separately from Stats — deliberately, because a
  fully-poisoned window drains to nil and a count inside Stats would
  vanish with it. The HUD shows an orange 'clock offset suspect' line and
  the stats line grew skew_trim=N; nonzero means disregard e2e/hostnet.

- Every invalid-field fallback in the 1 Hz stats line was a bare -1: in
  the variadic CVarArg context the ternary does NOT unify to Double, the
  literal goes in as Int, and %f reads Int64(-1)'s all-ones bit pattern —
  which is a quiet NaN. Latent since the line existed; stage-1 (the first
  rung with invalid fields while frames flow) printed nan for every one.
  All fallbacks are now typed -1.0 / Double()-wrapped.
2026-08-13 23:15:30 +02:00

1057 lines
61 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.
// Session state for the app shell: owns the connection, the input capture, the trust
// handshake phase, and the pump-thread → main-actor stats relay.
// AVFoundation: AVCaptureDevice.authorizationStatus (the mic TCC grant behind `micAvailable`)
// and, on tvOS, AVPlayer.eligibleForHDRPlayback (the TV-capability HDR gate).
import AVFoundation
import Foundation
import os
import PunktfunkKit
import SwiftUI
#if canImport(AppKit)
import AppKit
#elseif canImport(UIKit)
import UIKit
#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")
/// Mirror the 1 Hz vitals line to STDOUT as well as the unified log.
///
/// Exists for **tvOS, where the unified log is unreachable**: `log stream --device` is gone from
/// modern macOS, `log collect --device-name` needs root and then fails "Device not configured"
/// (an Apple TV has no USB to fall back to), and libimobiledevice pairs against a different
/// database than Xcode. Stdout, however, IS bridged — `xcrun devicectl device process launch
/// --console -e '{"PUNKTFUNK_STATS_STDOUT":"1"}' io.unom.punktfunk` streams these lines straight
/// to the Mac. That is the only way to read a session's numbers with the **stats overlay OFF**,
/// which matters because the overlay is itself a composited layer over the Metal one — i.e. a
/// plausible cause of the very present-floor inflation the overlay is used to measure.
/// Env-gated: no cost, and no stdout noise, unless someone is deliberately measuring.
private let statsToStdout = ProcessInfo.processInfo.environment["PUNKTFUNK_STATS_STDOUT"] == "1"
/// 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 library entry this session was launched with (`connect(launchID:)`), or nil if the user
/// just connected to the host's desktop. Kept because where the client should go when the
/// session ends depends on where it came FROM: a title launched out of the library belongs back
/// in that library when its game exits, not on the host-selection screen.
private var launchedTitleID: String?
/// WHICH library shelf that title was launched from — a host's own, or one of its pinned
/// host+profile cards (§5.2a). The host alone would not answer it: a pinned card's shelf
/// launches with that card's profile, so returning to the host's default shelf would quietly
/// change what the next title streams with.
private var launchedShelf: LibraryTarget?
/// Set when a session ended because its game exited and it began as a library launch: the
/// shelf to reopen. The view layer consumes it and sets it back to nil.
@Published var returnToLibrary: LibraryTarget?
/// 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 deadline link's `preferredFrameLatency` ASK beside its property READBACK (see
/// `PresentLinkInfo` — it exists because tvOS has no reachable log). ⚠ The readback is NOT
/// a grant: it is a plain float property, so it echoes whatever was stored unless the
/// system clamps the setter. readback ≠ ask ⇒ a visible clamp (the one signal the API can
/// give); readback == ask proves nothing — `osFloorP50Ms` (the measured vend lead) is the
/// truth-teller (field 2026-08-13: readback 1.00 beside a 32.5 ms floor).
@Published var linkLatencyAskFrames: Float = 0
@Published var linkLatencyFrames: Float = 0
@Published var linkRangeMinHz: Float = 0
@Published var linkRangeMaxHz: Float = 0
@Published var linkDrawables = 0
@Published var linkInfoValid = false
/// Impossible samples the HOST-ANCHORED meters (host+network, end-to-end) refused this
/// second (`LatencyMeter.drainTrimmed`). Nonzero means the clock offset is lying and every
/// host-anchored p50/p95 this window is a TRUNCATED distribution — the HUD marks the window
/// suspect instead of letting a trimmed tail pose as a healthy small number (the field
/// "e2e 03 ms" reading, 2026-08-13). Client-local stages can't go negative, so they carry
/// no such term.
@Published var skewTrimPerS = 0
/// The AUDIO plane's latency, from the playback ring (`SessionAudio.Stats`): how much decoded
/// audio is queued ahead of the speaker, and where that PUTS it relative to the picture
/// (positive = audio behind). `audioValid` is false until playback runs.
///
/// Both numbers, never just the depth — a deep ring on a jittery link is the adaptive floor
/// doing its job, and only the offset separates that from audio simply being held late. They
/// existed nowhere a surface could render them until now, which is why a field report of "the
/// audio delay seems way too high" was triaged all the way to a conclusion without them.
@Published var audioBufferMs = 0
@Published var audioAvOffsetMs = 0
@Published var audioValid = 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
/// The USER's in-stream mic mute (the HUD button, the Stream menu's ⌃⌥⇧A, the captured-state
/// chord, the iOS mic disc) — session state, deliberately NOT persisted: a mute is for the
/// people in the room right now, so every new session starts live if the mic is on at all.
/// One of the two inputs to the effective mute; `isBackgrounded` is the other, and
/// `applyMicMute` composes them — a user mute survives a trip through the background, and the
/// background's privacy mute never clears the user's choice. Local and instant: it gates
/// capture on this device, nothing is sent to the host.
@Published private(set) var micMuted = false
/// The kind a controller declared when it turned out this session cannot carry its motion —
/// set once per such pad, cleared after `motionHintSeconds`. Nil the rest of the time.
///
/// It exists because the failure is otherwise entirely silent: the gyro simply does nothing,
/// with no way for the player to tell a dead sensor from a session that resolved a backend
/// without a motion plane. The fix is a settings change, so the hint has to name it.
@Published private(set) var motionUnreachableKind: PunktfunkConnection.GamepadType?
/// Drops `motionUnreachableKind` again — held so a second pad's hint replaces the first
/// cleanly, and so ending the session cancels a pending clear rather than letting it fire
/// into a torn-down model.
private var motionHintTimer: Task<Void, Never>?
/// How long the motion hint stays up — the start-of-stream shortcut banner's 6 s, since the
/// two share the bottom-centre stack and a player reads them the same way.
private static let motionHintSeconds: UInt64 = 6
/// 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,
/// The library shelf `launchID` was picked on, so a game exit can return to it.
/// Only meaningful alongside a `launchID`; nil for a plain desktop connect.
shelf: LibraryTarget? = nil,
allowTofu: Bool = false,
autoTrust: Bool = false,
requestAccess: Bool = false,
onUnreachable: (@MainActor () -> Void)? = nil) {
guard phase == .idle else { return }
phase = .connecting
activeHost = host
launchedTitleID = launchID
launchedShelf = shelf
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
// iOS/tvOS run the stage-4 deadline presenter, whose link thread feeds
// reportPhase — advertise the vsync-aware presenter (0x02, CLIENT_CAP_PHASE_LOCK).
// macOS stays without it: the stage-2 arrival presenter has no latch grid.
let clientCaps: UInt8 = 0x02
#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
}
// A LATER connect has already latched its own settings; only release the
// latch when nothing took over, or this would blank the live session's.
if self.phase == .idle { SessionSettings.end() }
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
SessionSettings.end() // no session, so nothing may keep its settings latched
self.errorMessage = "\(host.displayName) is not paired yet. "
+ "Pair with its PIN before streaming."
}
case .failure(let error):
self.phase = .idle
self.activeHost = nil
SessionSettings.end() // the dial failed — back to the plain globals
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)
applyMicMute() // now muted for privacy — on top of the user's own mute, not instead of it
// 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
applyMicMute() // back to the user's own choice — which may well still be "muted"
if let conn = connection {
conn.setVideoDropped(false)
conn.requestKeyframe()
}
}
// MARK: - Microphone mute (in-stream, per session)
/// Whether this session has a mic uplink there is any point in muting: the mic must be on in
/// the session's RESOLVED settings (a profile can turn it on or off), the platform must have
/// an app-accessible input at all, and the OS must not have refused us one. Drives whether the
/// mute control is offered — a live-looking mute button over a session that sends no
/// microphone would be a lie. Same three conditions `SessionAudio` starts an uplink on
/// (`.notDetermined` counts: the prompt is pending and a grant starts the uplink mid-session).
var micAvailable: Bool {
#if os(tvOS)
return false // no app-accessible microphone — SessionAudio never opens an uplink either
#else
guard settings.micEnabled else { return false }
switch AVCaptureDevice.authorizationStatus(for: .audio) {
case .authorized, .notDetermined: return true
default: return false // denied / restricted — there is no uplink to mute
}
#endif
}
/// Flip the user's mute. The in-stream surfaces (HUD button, Stream menu, ⌃⌥⇧A while
/// captured, the iOS mic disc) all land here.
func toggleMicMute() {
setMicMuted(!micMuted)
}
/// Set the user's mute directly (the badge's tap-to-unmute). Ignored when the session has no
/// microphone, so a stale surface can't leave a phantom "muted" badge over a session that was
/// never sending anything.
func setMicMuted(_ muted: Bool) {
guard micAvailable, micMuted != muted else { return }
micMuted = muted
applyMicMute()
}
/// A forwarded controller has a gyro this session cannot carry (see
/// `GamepadCapture.onMotionUnreachable`). Show it briefly, then let it go.
///
/// Last pad wins, and its timer restarts: two such pads are the same one fact to a player, and
/// a second hint appearing under a still-visible first would only read as a stutter.
private func noteMotionUnreachable(_ kind: PunktfunkConnection.GamepadType) {
motionUnreachableKind = kind
motionHintTimer?.cancel()
motionHintTimer = Task { [weak self] in
try? await Task.sleep(for: .seconds(Self.motionHintSeconds))
guard !Task.isCancelled else { return }
self?.motionUnreachableKind = nil
}
}
/// Push the EFFECTIVE mute — the user's choice OR the background keep-alive's privacy mute —
/// onto the audio engine. The two reasons are composed here and nowhere else: whichever one
/// changed, the other still holds, so returning from the background can't un-mute a user who
/// muted mid-stream, and a user unmuting while backgrounded (Live Activity, another window)
/// doesn't open the mic behind their back.
private func applyMicMute() {
audio?.setMicMuted(micMuted || isBackgrounded)
}
/// 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
// The mic mute is per-session and never persisted: the next stream starts live (if the
// mic is enabled), rather than silently carrying a mute nobody remembers making.
micMuted = false
// Cancel before clearing: a pending clear firing into a torn-down session would be
// harmless but pointless, and leaving the hint set would carry it into the next stream.
motionHintTimer?.cancel()
motionHintTimer = nil
motionUnreachableKind = 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
// Read by `sessionEnded` BEFORE it calls us, so clearing here can't rob it of the answer.
launchedTitleID = nil
launchedShelf = nil
phase = .idle
fps = 0
mbps = 0
hostNetworkValid = false
splitValid = false
endToEndValid = false
decodeValid = false
displayValid = false
clientQueueValid = false
osFloorValid = false
linkInfoValid = false
// Drop the previous session's grant too — the shared box outlives the session, and a new
// link may never come up (a non-deadline rung has none at all).
PresentLinkInfo.shared.clear()
audioValid = 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 let conn = connection else { return }
let name = activeHost?.displayName ?? "host"
// WHY it ended, asked while the connection is still up — `disconnect` tears it down.
let reason = conn.sessionEndReason
// Where a game exit sends us: back into the library this title was launched from, so the
// next one is a tap away. Only for a launch that CAME from the library — a game exiting in
// a plain desktop session has no library to return to.
let host = activeHost
let cameFromLibrary = launchedTitleID != nil
// The shelf it came off — falling back to the host's own if a caller launched a title
// without naming one, which is what that launch effectively browsed.
let shelf = launchedShelf ?? activeHost.map { LibraryTarget(host: $0) }
disconnect(deliberate: false) // host/network ended it — keep the linger for a reconnect
switch reason {
case .gameExited:
// The player quit their own game. Not a failure, and they are probably after the next
// title — so no banner, and back to the library it came from.
if cameFromLibrary, host != nil, let shelf {
returnToLibrary = shelf
}
case .hostEnded, .local:
// Someone asked for this: an operator "End" on the host, or our own close racing in.
// Say it plainly, without the error framing.
errorMessage = "\(name) ended the session."
case .hostError:
errorMessage = "\(name) ended the session with an error."
case .lost:
errorMessage = "Lost the connection to \(name)."
case .none:
// No verdict (an older core, or the close raced the read): keep the wording this path
// has always used rather than inventing one.
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,
echoCancel: settings.echoCancel,
// The A/V sync reference: `endToEnd` is capture→on-glass, the one figure that says
// where the picture actually IS, and the audio ring steers its depth to land with it.
// The same meter object the presenter writes per presented frame, so audio reads the
// video plane's own measurement rather than a second estimate of it — and under the
// stage-1 fallback presenter, which stamps nothing, it stays empty and the loop
// correctly declines to correct.
videoLatency: endToEnd)
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.
// `gamepadForwarding` off means the host gets this device's pads from somewhere else
// (USB passthrough, or a pad plugged into the host) — capture still runs, and still
// watches for the escape chord, but puts nothing on the wire.
// System-button routing: whether raw guide/share presses ride the wire, and whether
// hold-Select arms as the alternate guide route (auto = on everywhere but macOS —
// iOS reserves the physical Home press, tvOS never delivers it).
let capture = GamepadCapture(
connection: conn, manager: .shared, forwarding: settings.gamepadForwarding,
systemForward: settings.systemButtonsForward,
guideGesture: settings.guideGestureEnabled)
// 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() }
// A pad with a gyro that this session cannot carry — say so once, briefly, and name the
// setting that fixes it. Already main-actor (GamepadCapture fires it there).
capture.onMotionUnreachable = { [weak self] kind in self?.noteMotionUnreachable(kind) }
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
}
// Drained even when the stats drains came back empty — with a badly wrong offset
// an entire window is refused and only this counter still tells the story.
self.skewTrimPerS =
self.latency.drainTrimmed() + self.endToEnd.drainTrimmed()
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
}
// The display link's latency ask + property readback (deadline rung only) — a
// LEVEL, not a window, so it is read rather than drained.
if let l = PresentLinkInfo.shared.snapshot() {
self.linkLatencyAskFrames = l.ask
self.linkLatencyFrames = l.latency
self.linkRangeMinHz = l.rangeMin
self.linkRangeMaxHz = l.rangeMax
self.linkDrawables = l.drawables
self.linkInfoValid = true
} else {
self.linkInfoValid = false
}
if let q = self.clientQueue.drain() {
self.clientQueueP50Ms = q.p50Ms
self.clientQueueValid = true
} else {
self.clientQueueValid = false
}
// The audio plane is a LEVEL, not a window: the ring's depth and the sync loop's
// smoothed offset are both current values, so they are read rather than drained.
if let a = self.audio?.stats {
self.audioBufferMs = a.bufferMS
self.audioAvOffsetMs = a.avOffsetMS
self.audioValid = true
} else {
self.audioValid = 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).
//
// ⚠ Every invalid-field fallback below MUST be a typed `-1.0` (or a
// `Double(...)`-wrapped value), never a bare `-1`: in this variadic
// `CVarArg` context the ternary does NOT unify to Double — the untyped
// literal goes in as Int, and `%f` then reads Int64(-1)'s all-ones bit
// pattern, which IS a quiet NaN. Field 2026-08-13 (tvOS, stage-1, the
// first session ever to have invalid fields while frames flowed): every
// fallback printed `nan`. Latent since the line was added.
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 "
// Appended LAST, so every existing parser of this line is unaffected.
// In the log as well as on the HUD because the overlay is only up when
// someone thought to turn it on, and the reports that need these
// numbers arrive after the fact.
+ "audio_buffer=%lld audio_av_offset=%lld "
// The deadline link's latency ask + property readback (both -1 on
// non-deadline rungs) — appended so the PUNKTFUNK_FRAME_LATENCY
// ladder is readable over the stdout channel with the HUD off,
// which is the only honest way to run it on a tvOS device.
+ "link_ask=%.2f link_readback=%.2f "
// Impossible samples the host-anchored meters refused this window:
// nonzero ⇒ the clock offset is lying and e2e/hostnet above are
// truncated distributions — disregard their p50/p95.
+ "skew_trim=%lld",
frames,
displayWindow?.count ?? 0,
self.endToEndValid ? self.endToEndP50Ms : -1.0,
self.endToEndValid ? self.endToEndP95Ms : -1.0,
self.hostNetworkValid ? self.hostNetworkP50Ms : -1.0,
self.decodeValid ? self.decodeP50Ms : -1.0,
self.displayValid ? self.displayP50Ms : -1.0,
lost,
self.osFloorValid ? self.osFloorP50Ms : -1.0,
self.displayValid ? self.displayAdjP50Ms : -1.0,
self.endToEndValid ? self.endToEndAdjP50Ms : -1.0,
self.clientQueueValid ? self.clientQueueP50Ms : -1.0,
self.audioValid ? self.audioBufferMs : -1,
self.audioValid ? self.audioAvOffsetMs : 0,
self.linkInfoValid ? Double(self.linkLatencyAskFrames) : -1.0,
self.linkInfoValid ? Double(self.linkLatencyFrames) : -1.0,
self.skewTrimPerS)
statsLog.info("\(line, privacy: .public)")
if statsToStdout { print("pf.stats \(line)") }
}
}
}
// .common so the HUD keeps updating during window drags / menu tracking.
RunLoop.main.add(timer, forMode: .common)
statsTimer = timer
}
}