Files
punktfunk/clients/apple/Sources/PunktfunkKit/Support/StatsVerbosity.swift
T
enricobuehlerandClaude Opus 5 858cf0e535 feat(client/apple): profiles, resolved once per connect instead of ten times mid-session
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>
2026-07-29 12:06:41 +02:00

77 lines
3.5 KiB
Swift

// The stats overlay's verbosity tier — a port of the Android client's 3-tier overlay semantics
// so the two clients feel identical: Off → Compact (one-line pill) → Normal (headline stats) →
// Detailed (plus the latency stage equation). Persisted under `DefaultsKey.statsVerbosity`;
// the in-stream cycle surfaces (⌃⌥⇧S, the three-finger tap) advance it with
// `store(current.next())`, and every UI reader observes the same default via @AppStorage.
//
// Lives in PunktfunkKit (not the app) because the kit's input paths (TouchMouse's three-finger
// tap, InputCapture's captured-state ⌃⌥⇧S) cycle it directly.
import Foundation
import PunktfunkShared
/// How much of the streaming statistics overlay to show. The raw values are stable on disk —
/// rename the cases freely, never the strings.
public enum StatsVerbosity: String, CaseIterable, Sendable {
case off, compact, normal, detailed
/// User-facing tier label (Settings pickers, the gamepad settings row).
public var label: String {
switch self {
case .off: return "Off"
case .compact: return "Compact"
case .normal: return "Normal"
case .detailed: return "Detailed"
}
}
/// The next tier in the cycle: off → compact → normal → detailed → off (wrapping) —
/// the ⌃⌥⇧S / three-finger-tap order, same as Android.
public func next() -> StatsVerbosity {
switch self {
case .off: return .compact
case .compact: return .normal
case .normal: return .detailed
case .detailed: return .off
}
}
/// The persisted tier. When `statsVerbosity` has never been written, migrates from the
/// legacy `hudEnabled` bool the pre-tiered clients stored: absent-or-true → `.normal`
/// (the old "on" look minus the equation lines), explicit false → `.off`.
public static var current: StatsVerbosity {
let defaults = UserDefaults.standard
if let raw = defaults.string(forKey: DefaultsKey.statsVerbosity),
let tier = StatsVerbosity(rawValue: raw) {
return tier
}
if let legacy = defaults.object(forKey: DefaultsKey.hudEnabled) as? Bool, !legacy {
return .off
}
return .normal
}
/// Persist a tier (the cycle surfaces write through here; the Settings pickers write the
/// same key via @AppStorage).
public static func store(_ tier: StatsVerbosity) {
UserDefaults.standard.set(tier.rawValue, forKey: DefaultsKey.statsVerbosity)
}
/// The tier the LIVE session is showing — its profile's, if one overrode it — falling back to
/// the persisted global while idle. What the in-stream cycle advances FROM: cycling in a
/// session a profile put on Detailed must go to Off, not to whatever the global happens to be.
public static var session: StatsVerbosity {
StatsVerbosity(rawValue: SessionSettings.current.statsVerbosity) ?? .normal
}
/// Advance the in-stream overlay one tier (⌃⌥⇧S, the three-finger tap, the Stream menu).
///
/// It writes the GLOBAL, as every client's cycle always has, and the app pushes that back into
/// the live session — so from the moment the user cycles, the session follows the global
/// rather than the profile's start-of-session tier. That is the honest reading of an explicit
/// live override, and it keeps one observable source (`@AppStorage`) driving the overlay.
public static func cycle() {
store(session.next())
}
}