Files
punktfunk/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift
T
enricobuehler 3ba19f28a2
apple / swift (push) Waiting to run
apple / screenshots (push) Blocked by required conditions
release / apple (push) Waiting to run
ci / rust (push) Failing after 50s
ci / web (push) Successful in 57s
decky / build-publish (push) Successful in 14s
ci / docs-site (push) Successful in 1m2s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 29s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 6s
flatpak / build-publish (push) Failing after 2m14s
ci / bench (push) Successful in 5m34s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5m48s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5m46s
docker / deploy-docs (push) Successful in 23s
windows-host / package (push) Successful in 11m40s
arch / build-publish (push) Successful in 18m10s
deb / build-publish (push) Successful in 18m9s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m21s
android / android (push) Successful in 21m4s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m2s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m37s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 5m38s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m3s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 1m2s
feat(apple): the gamepad UI comes to tvOS - focus-driven, with real session controls
The console UI now runs on tvOS through the NATIVE focus engine: carousel
cards and settings rows are focusable Buttons (Siri Remote and pads both
navigate; imperative scrollTo replaces the drop-prone scrollPosition binding),
while iOS/macOS keep the 60 Hz poll untouched - on tvOS it carries only what
focus has no concept of: X/Y screen actions and left/right value adjust with
the poll's dominant-axis feel (onMoveCommand proved input-source-dependent:
keyboard intercepted, pad dpad not -> double steps). Text entry uses the
system fullscreen keyboard (TVTextEntry); pairing + library present as covers
under the launcher; the game library defaults ON; settings values slide a
quiet 14 pt in the step's direction.

Session controls: controller/remote input routes EXCLUSIVELY through
GameController during a stream (GCEventViewController, interaction disabled) -
a pad's B no longer doubles as a UIKit menu press that ended sessions
mid-game. Deliberate exits only: the cross-client escape chord (hold
L1+R1+Start+Select 1.5 s - pf-client-core's contract, now implemented on all
Apple platforms) and holding the remote's Back >= 1 s; the start-of-stream
banner (now also on tvOS) teaches both. The Siri Remote's touch surface
drives the host pointer - press = left click, Play/Pause = right click,
release-tail jumps gated so motion stays truly relative.

tvOS 26 regressions fixed at the root: the app-wide brand tint rendered every
unfocused control as a blank pill (tint dropped on tvOS) and the 17 pt root
font shrank the whole platform (29 pt there), plus 10-foot sizing across host
cards, the gamepad screens, and the stats HUD (whose misleading "Press Menu"
hint is gone). Acknowledgements scrolls by focus-sized chunks and Menu pops
instead of suspending; full-width focusSections make the home actions
reachable from any column. The presenter defaults to stage-3 glass pacing on
tvOS (a 60 Hz panel fed a 60 fps stream is the sticky-FIFO worst case behind
the 50 ms display stage) and is pickable from the gamepad settings; HDR
capability advertises from AVPlayer.eligibleForHDRPlayback instead of the
current mode's EDR headroom, so an SDR home screen no longer hides an HDR TV.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:59:01 +02:00

502 lines
26 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?
@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
/// 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
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()
/// 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(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 }
/// `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.
func connect(to host: StoredHost, width: UInt32, height: UInt32, hz: UInt32,
compositor: PunktfunkConnection.Compositor = .auto,
gamepad: PunktfunkConnection.GamepadType = .auto,
bitrateKbps: UInt32 = 0,
audioChannels: UInt8 = 2,
hdrEnabled: Bool = true,
preferredCodec: UInt8 = 0,
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
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)
return (NSScreen.main?.maximumExtendedDynamicRangeColorComponentValue ?? 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-out (default on); the hardware-decode probe below is the real gate.
let want444 = (UserDefaults.standard.object(forKey: DefaultsKey.enable444) as? Bool) ?? true
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 }
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, 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:
self.phase = .idle
self.activeHost = nil
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 3000 → 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."
}
}
}
}
}
/// 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
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 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()
// 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()
}
}
connection = nil
activeHost = nil
phase = .idle
fps = 0
mbps = 0
hostNetworkValid = false
splitValid = false
endToEndValid = false
decodeValid = false
displayValid = false
lostFrames = 0
lostPct = 0
mouseCaptured = false
}
/// 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)."
}
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
// Audio starts with streaming, not during the trust prompt — no host sound (or
// mic uplink!) before the user trusted the host. Devices come from Settings;
// "" = system default.
let defaults = UserDefaults.standard
let audio = SessionAudio(connection: conn)
audio.start(
speakerUID: defaults.string(forKey: DefaultsKey.speakerUID) ?? "",
micUID: defaults.string(forKey: DefaultsKey.micUID) ?? "",
micChannel: defaults.integer(forKey: DefaultsKey.micChannel),
micEnabled: defaults.object(forKey: DefaultsKey.micEnabled) as? Bool ?? true)
self.audio = audio
// Gamepads: forward GamepadManager's active controller as pad 0 and render the
// host's feedback (rumble always; lightbar/player-LEDs/adaptive-triggers when the
// session's virtual pad 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(tvOS)
let pointer = SiriRemotePointer(connection: conn)
pointer.onDisconnectRequest = { [weak self] in self?.disconnect() }
pointer.start()
remotePointer = pointer
#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
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
}
// 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 {
let line = String(
format: "fps=%d presents=%d e2e_p50=%.1f e2e_p95=%.1f hostnet_p50=%.1f "
+ "decode_p50=%.1f display_p50=%.1f lost=%d",
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)
statsLog.info("\(line, privacy: .public)")
}
}
}
// .common so the HUD keeps updating during window drags / menu tracking.
RunLoop.main.add(timer, forMode: .common)
statsTimer = timer
}
}