Files
punktfunk/clients/apple/Sources/PunktfunkShared/EffectiveSettings.swift
T
enricobuehler ac395bbfa9
ci / rust-arm64 (pull_request) Failing after 48s
apple / swift (pull_request) Successful in 1m18s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m22s
android / android (pull_request) Successful in 3m22s
ci / web (pull_request) Successful in 1m39s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m0s
ci / rust (pull_request) Canceled after 10m44s
windows / build (x86_64-pc-windows-msvc) (pull_request) Canceled after 1m43s
feat(clients/input): system buttons route around local overlays
Pressing guide/Steam/QAM collided with the client device's own shell: iOS 26
opens its Game Overlay for the Home press (no app opt-out until iOS 27 makes
it a user setting), and a Gaming-Mode client opened BOTH Steam overlays for
one press — the local one covering the stream.

Two cross-client tier-P settings, zero wire changes:

- system_buttons (auto|forward|local): raw guide+misc1 passthrough. Auto
  forwards everywhere EXCEPT under gamescope, where SteamOS reacts to the
  same physical press no matter what.
- guide_gesture (auto|on|off): hold Select ALONE ~350ms sends the HOST's
  guide, down until release — held on, that's the host's long-press, which
  opens a Gaming-Mode host's QAM for regular pads. A Select tap is delivered
  on release with its up TAP_PRESS (50ms) behind, because per-transition
  sends fold into seq'd GamepadState snapshots and a back-to-back pair can
  coalesce into no press at all. A Select inside a combo (the escape chord)
  passes through untouched. Auto arms it only where the raw press can't
  reach the host cleanly: gamescope, iOS/iPadOS, tvOS.

The same SelectGesture rules live in pf-client-core (pure state machine +
unit tests), the Apple client (mask-diff adaptation in GamepadCapture), and
Android's GamepadRouter. Settings rows on every surface (GTK, WinUI,
console UI, Decky, Apple x2, Android x2) with profile plumbing throughout.

punktfunk-session grows a control socket
($XDG_RUNTIME_DIR[/app/$FLATPAK_ID]/punktfunk-session-ctl.sock — the one
runtime path a flatpak and the host see identically): 'guide'/'qam' verbs
inject synthetic taps. The Decky panel gains a Host menus section (visible
while the client runs) whose buttons press the host's Steam/QAM and close
the local menu so the host's shows through.

iOS 27's GCControllerHomeButtonSettingsManager deep-link is a TODO (the
class needs the Xcode 27 SDK to compile). Docs: input, client-settings,
steam-deck. Design: punktfunk-planning design/system-buttons-routing.md.

Gates: docker clippy --all-targets --locked -D warnings + tests
(pf-client-core 88 incl. 6 new gesture tests, pf-console-ui 47),
cargo fmt --all --check, swift build (macOS), gradle kit+app compile,
decky tsc --noEmit + py_compile. clients/windows not compiled (no box).
2026-08-04 21:11:05 +02:00

302 lines
14 KiB
Swift

// The settings ONE session runs on — the global defaults with the session's profile overlaid,
// resolved once at connect and read from there on (design/client-settings-profiles.md §4.2/§4.4):
//
// effective = overlay(profile).apply(globals)
// profile = one-off pick (Connect with ▸) ?? host.profileID ?? none
//
// Before this existed, ~10 sites scattered across the app AND the kit read `UserDefaults` directly
// mid-session — a per-host profile would have applied to some of them and not others, which is
// worse than not shipping the feature. They now read `SessionSettings.current`: the live session's
// resolution while one is up, the plain globals otherwise (byte-for-byte today's behaviour when no
// profile is involved).
//
// Only SESSION-CONSUMED values live here. Pure app-level preferences — the library toggle, the
// gamepad-UI switch, HUD placement, auto-wake, background keep-alive — stay plain `@AppStorage`
// where they are read: they are about the app, not about a stream.
import Foundation
public struct EffectiveSettings: Equatable, Sendable {
// Tier P — profileable (design §3).
public var width = 1920
public var height = 1080
public var refreshHz = 60
public var matchWindow = false
public var bitrateKbps = 0
public var renderScale = 1.0
public var codec = "auto"
public var hdrEnabled = true
public var compositor = 0
public var audioChannels = 2
public var micEnabled = true
public var echoCancel = true
public var touchMode = "trackpad"
public var mouseMode = "capture"
public var invertScroll = false
public var gamepadType = 0
public var gamepadForwarding = true
/// Cross-client `system_buttons`: "auto" | "forward" | "local".
public var systemButtons = "auto"
/// Cross-client `guide_gesture`: "auto" | "on" | "off".
public var guideGesture = "auto"
/// A `StatsVerbosity` raw value; the enum lives in PunktfunkKit, which this module can't see.
public var statsVerbosity = "normal"
public var fullscreenWhileStreaming = true
public var enable444 = false
public var presentPriority = "latency"
public var smoothBuffer = 0
public var vsync = false
public var allowVRR = true
public var windowedSafePresent = true
public var modifierLayout = "mac"
// Tier G — this device's endpoints and hardware. Session-consumed, so they ride along, but
// never profileable: a profile is about how a host is streamed, not about which speaker this
// Mac uses.
public var speakerUID = ""
public var micUID = ""
public var micChannel = 0
public var pointerCapture = true
/// The profile this resolution came from, when one applied — the HUD names it so "which
/// profile am I on?" is answerable mid-session, and the one-off/binding distinction never has
/// to be guessed from the settings themselves.
public var profileID: String?
public var profileName: String?
/// The profile's `#RRGGBB` chip colour, so the HUD names it in the same colour the card that
/// launched it wore — the colour is an identifier, and it only works if it's the same one.
public var profileAccent: String?
public init() {}
/// The global defaults, as every `@AppStorage` in the settings surface sees them. `.standard`
/// by default — settings are per-device, unlike the App-Group-shared host store.
public init(defaults: UserDefaults) {
func int(_ key: String, _ fallback: Int) -> Int {
defaults.object(forKey: key) as? Int ?? fallback
}
func dbl(_ key: String, _ fallback: Double) -> Double {
defaults.object(forKey: key) as? Double ?? fallback
}
func bool(_ key: String, _ fallback: Bool) -> Bool {
defaults.object(forKey: key) as? Bool ?? fallback
}
func str(_ key: String, _ fallback: String) -> String {
defaults.string(forKey: key) ?? fallback
}
width = int(DefaultsKey.streamWidth, width)
height = int(DefaultsKey.streamHeight, height)
refreshHz = int(DefaultsKey.streamHz, refreshHz)
matchWindow = bool(DefaultsKey.matchWindow, matchWindow)
bitrateKbps = int(DefaultsKey.bitrateKbps, bitrateKbps)
renderScale = dbl(DefaultsKey.renderScale, renderScale)
codec = str(DefaultsKey.codec, codec)
hdrEnabled = bool(DefaultsKey.hdrEnabled, hdrEnabled)
compositor = int(DefaultsKey.compositor, compositor)
audioChannels = int(DefaultsKey.audioChannels, audioChannels)
micEnabled = bool(DefaultsKey.micEnabled, micEnabled)
echoCancel = bool(DefaultsKey.echoCancel, echoCancel)
touchMode = str(DefaultsKey.touchMode, touchMode)
mouseMode = str(DefaultsKey.mouseMode, mouseMode)
invertScroll = bool(DefaultsKey.invertScroll, invertScroll)
gamepadType = int(DefaultsKey.gamepadType, gamepadType)
gamepadForwarding = bool(DefaultsKey.gamepadForwarding, gamepadForwarding)
systemButtons = str(DefaultsKey.systemButtons, systemButtons)
guideGesture = str(DefaultsKey.guideGesture, guideGesture)
statsVerbosity = Self.storedStatsVerbosity(defaults)
fullscreenWhileStreaming = bool(
DefaultsKey.fullscreenWhileStreaming, fullscreenWhileStreaming)
enable444 = bool(DefaultsKey.enable444, enable444)
presentPriority = str(DefaultsKey.presentPriority, presentPriority)
smoothBuffer = int(DefaultsKey.smoothBuffer, smoothBuffer)
vsync = bool(DefaultsKey.vsync, vsync)
allowVRR = bool(DefaultsKey.allowVRR, allowVRR)
windowedSafePresent = bool(DefaultsKey.windowedSafePresent, windowedSafePresent)
modifierLayout = str(DefaultsKey.modifierLayout, modifierLayout)
speakerUID = str(DefaultsKey.speakerUID, speakerUID)
micUID = str(DefaultsKey.micUID, micUID)
micChannel = int(DefaultsKey.micChannel, micChannel)
pointerCapture = bool(DefaultsKey.pointerCapture, pointerCapture)
}
/// The stats tier as stored, with the pre-tier `hudEnabled` migration `StatsVerbosity.current`
/// performs — duplicated in one line here so this module needn't reach into PunktfunkKit.
private static func storedStatsVerbosity(_ defaults: UserDefaults) -> String {
if let raw = defaults.string(forKey: DefaultsKey.statsVerbosity) { return raw }
if let legacy = defaults.object(forKey: DefaultsKey.hudEnabled) as? Bool, !legacy {
return "off"
}
return "normal"
}
/// The `system_buttons` policy resolved for this platform: forward the raw guide (and
/// share/QAM misc) presses? Auto = forward on every Apple platform — where the OS shows
/// its own overlay for the press that is the OS's business, and suppressing our send
/// would only break users who handed the button to the app (iOS 27's Home-button
/// setting; macOS with the gestures claimed).
public var systemButtonsForward: Bool {
switch systemButtons {
case "local": return false
default: return true
}
}
/// The hold-Select guide gesture resolved for this platform ([`guideGesture`]). Auto =
/// on everywhere but macOS: iOS reserves the physical Home press (the Game Overlay,
/// uncapturable pre-27) and tvOS never delivers it, so holding Select is the controller
/// route to the host's guide — and, held on, to a Gaming-Mode host's QAM. On macOS the
/// raw press reaches the host, so auto stays off and Select keeps its exact timing.
public var guideGestureEnabled: Bool {
switch guideGesture {
case "on": return true
case "off": return false
default:
#if os(macOS)
return false
#else
return true
#endif
}
}
/// The one resolution seam: this overlay on top of these settings. Pure — no store reads, no
/// clock — so it is testable field by field. A `.some` that happens to equal the base is a
/// legitimate PIN: it keeps its value when the global later moves.
public func applying(_ overlay: SettingsOverlay) -> EffectiveSettings {
var s = self
if let v = overlay.width { s.width = v }
if let v = overlay.height { s.height = v }
if let v = overlay.refreshHz { s.refreshHz = v }
if let v = overlay.matchWindow { s.matchWindow = v }
if let v = overlay.bitrateKbps { s.bitrateKbps = v }
if let v = overlay.renderScale { s.renderScale = v }
if let v = overlay.codec { s.codec = v }
if let v = overlay.hdrEnabled { s.hdrEnabled = v }
if let v = overlay.compositor { s.compositor = v }
if let v = overlay.audioChannels { s.audioChannels = v }
if let v = overlay.micEnabled { s.micEnabled = v }
if let v = overlay.echoCancel { s.echoCancel = v }
if let v = overlay.touchMode { s.touchMode = v }
if let v = overlay.mouseMode { s.mouseMode = v }
if let v = overlay.invertScroll { s.invertScroll = v }
if let v = overlay.gamepadType { s.gamepadType = v }
if let v = overlay.gamepadForwarding { s.gamepadForwarding = v }
if let v = overlay.systemButtons { s.systemButtons = v }
if let v = overlay.guideGesture { s.guideGesture = v }
if let v = overlay.statsVerbosity { s.statsVerbosity = v }
if let v = overlay.fullscreenWhileStreaming { s.fullscreenWhileStreaming = v }
if let v = overlay.enable444 { s.enable444 = v }
if let v = overlay.presentPriority { s.presentPriority = v }
if let v = overlay.smoothBuffer { s.smoothBuffer = v }
if let v = overlay.vsync { s.vsync = v }
if let v = overlay.allowVRR { s.allowVRR = v }
if let v = overlay.windowedSafePresent { s.windowedSafePresent = v }
if let v = overlay.modifierLayout { s.modifierLayout = v }
return s
}
/// The whole resolution, in one call, in the precedence every client shares:
/// **one-off pick ?? host binding ?? none**. A dangling id — a profile deleted out from under
/// a binding, a link naming one that no longer exists — resolves as none: never an error,
/// never a blocked connect (§4.4).
public static func resolve(
host: StoredHost?, selection: ProfileSelection = .inherit,
catalog: ProfileCatalog? = nil, defaults: UserDefaults = .standard
) -> EffectiveSettings {
let base = EffectiveSettings(defaults: defaults)
let profile: StreamProfile? = {
switch selection {
case .defaults:
return nil
case .profile(let id):
return (catalog ?? ProfileCatalog.load()).profile(id: id)
case .inherit:
guard let host else { return nil }
return (catalog ?? ProfileCatalog.load()).binding(for: host)
}
}()
guard let profile else { return base }
var out = base.applying(profile.overrides)
out.profileID = profile.id
out.profileName = profile.name
out.profileAccent = profile.accent
return out
}
}
/// What a single connect was told to use, before any store is consulted.
///
/// The third case is why this is an enum rather than an `Optional<StreamProfile>`: "Connect with ▸
/// Default settings" on a BOUND host has to force the globals, and "no pick at all" has to fall
/// through to the binding. Collapsing the two would make the menu item that says "Default
/// settings" silently connect with the host's profile. It is the same distinction the session
/// binary's `--profile ""` reserves on the desktop clients.
public enum ProfileSelection: Equatable, Sendable {
/// No pick — the host's default binding applies (a plain click/tap).
case inherit
/// Force the global defaults for this one connect, whatever the host is bound to.
case defaults
/// This profile, for this one connect. NEVER rebinds the host (§5.2).
case profile(String)
public init(profileID: String?) {
self = profileID.map(ProfileSelection.profile) ?? .inherit
}
}
// MARK: - The live session's resolution
/// What a session-scoped reader should read instead of `UserDefaults`.
///
/// A plain `static var` would be the obvious shape, but these are read from the decode pump and
/// the presenter's display-link callback as well as the main actor, so the value sits behind a
/// lock — the `FrameMeter` pattern used elsewhere in the client.
public enum SessionSettings {
private final class Box: @unchecked Sendable {
private let lock = NSLock()
private var value: EffectiveSettings?
var active: EffectiveSettings? {
get { lock.withLock { value } }
set { lock.withLock { value = newValue } }
}
}
private static let box = Box()
/// The live session's resolution, or nil while idle.
public static var active: EffectiveSettings? { box.active }
/// What every session-scoped reader uses: the live session's resolution while one is up, the
/// plain globals otherwise. Reading it off-session is exactly what those sites did before.
public static var current: EffectiveSettings {
box.active ?? EffectiveSettings(defaults: .standard)
}
/// Latch the settings a starting session resolved. Called once per connect, before the
/// connection exists, so nothing reads a half-applied mix.
public static func begin(_ settings: EffectiveSettings) {
box.active = settings
}
/// Release the latch when the session ends — later reads fall back to the globals.
public static func end() {
box.active = nil
}
/// The one value that legitimately moves mid-session: the stats tier, which every client
/// cycles live (⌃⌥⇧S, the three-finger tap). The cycle writes the global as before AND moves
/// the session's own value, so cycling works in a profile-driven session too.
public static func setStatsVerbosity(_ raw: String) {
guard var s = box.active else { return }
s.statsVerbosity = raw
box.active = s
}
}
private extension NSLock {
func withLock<T>(_ body: () -> T) -> T {
lock()
defer { unlock() }
return body()
}
}