Files
punktfunk/clients/apple/Sources/PunktfunkShared/StreamProfile.swift
T
enricobuehler 1db7058a5d
apple / swift (pull_request) Successful in 1m30s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 4m16s
ci / rust-arm64 (pull_request) Successful in 3m20s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 59s
ci / web (pull_request) Successful in 1m36s
ci / docs-site (pull_request) Successful in 2m7s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m55s
ci / rust (pull_request) Successful in 10m36s
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:46:27 +02:00

487 lines
23 KiB
Swift

// Client settings profiles — named bundles of setting overrides applied on top of the global
// defaults (design/client-settings-profiles.md §4). The Swift half of the model whose Rust
// original is `crates/pf-client-core/src/profiles.rs`; the two are mirrored field for field so a
// future export/import has one shape to speak.
//
// A profile overrides only the fields the user touched; everything else keeps following the
// global defaults *live*, so fixing a global once fixes it everywhere. That is why an overlay is
// sparse `Optional`s rather than a snapshot copy, and why `.some(x)` is written on touch and nil
// on an explicit "reset to default" — never by diffing against the current global (a `.some`
// equal to today's global is a legitimate *pin*: the profile keeps `x` when the global later
// moves).
//
// The catalog lives in the APP GROUP suite, with the saved hosts rather than with the settings:
// per-host data is already shared there so the widget can read it, and bindings/pins live on the
// host record. It is a dependency-free part of PunktfunkShared for the same reason `StoredHost`
// is — an extension process can read it without linking PunktfunkKit.
import Foundation
/// The catalog file's schema version. Bumped only for a breaking shape change — additive fields
/// ride the unknown-key preservation below.
public let punktfunkProfilesVersion = 1
// MARK: - Unknown-key carry-through
/// A decoded-but-unmodelled JSON value. Exists only so an overlay (or a profile) written by a
/// NEWER build survives an open→save round-trip on an older one: the don't-clobber rule from the
/// design's §4.1, and the reason the Rust overlay carries a `#[serde(flatten)] extra` map.
public enum JSONValue: Codable, Equatable, Sendable {
case null
case bool(Bool)
case number(Double)
case string(String)
case array([JSONValue])
case object([String: JSONValue])
public init(from decoder: Decoder) throws {
let c = try decoder.singleValueContainer()
if c.decodeNil() {
self = .null
} else if let v = try? c.decode(Bool.self) {
self = .bool(v)
} else if let v = try? c.decode(Double.self) {
self = .number(v)
} else if let v = try? c.decode(String.self) {
self = .string(v)
} else if let v = try? c.decode([JSONValue].self) {
self = .array(v)
} else {
self = .object(try c.decode([String: JSONValue].self))
}
}
public func encode(to encoder: Encoder) throws {
var c = encoder.singleValueContainer()
switch self {
case .null: try c.encodeNil()
case .bool(let v): try c.encode(v)
case .number(let v):
// Whole numbers encode as integers so a carried-through `"width": 3840` doesn't come
// back as `3840.0` and read as a different value to the next parser.
if v == v.rounded(), abs(v) < 9_007_199_254_740_992 {
try c.encode(Int(v))
} else {
try c.encode(v)
}
case .string(let v): try c.encode(v)
case .array(let v): try c.encode(v)
case .object(let v): try c.encode(v)
}
}
}
/// A `CodingKey` for keys only known at runtime — what the unknown-key sweep needs.
private struct AnyKey: CodingKey {
let stringValue: String
var intValue: Int? { nil }
init(_ s: String) { stringValue = s }
init?(stringValue: String) { self.stringValue = stringValue }
init?(intValue: Int) { nil }
}
// MARK: - The overlay
/// Every profileable ("tier P") setting, as an `Optional`: nil = inherit the global value, live.
///
/// Tier-H fields (host properties like the per-host clipboard toggle) and tier-G ones (this
/// device's hardware and audio endpoints) are deliberately absent — see the design's §3 curation.
/// The last seven are the Apple-only additions §3 lists: presentation and modifier choices that
/// are host-OS- and display-specific by nature.
///
/// The coding keys are the Rust overlay's serialized names. Two of them carry a different
/// REPRESENTATION here — `compositor` and `gamepad` are the wire enum's ordinals on Apple and
/// strings in the Rust core — because those are the shapes the two clients already persist; a
/// cross-platform importer has to map them either way.
public struct SettingsOverlay: Codable, Equatable, Sendable {
public var width: Int?
public var height: Int?
public var refreshHz: Int?
public var matchWindow: Bool?
public var bitrateKbps: Int?
public var renderScale: Double?
public var codec: String?
public var hdrEnabled: Bool?
public var compositor: Int?
public var audioChannels: Int?
public var micEnabled: Bool?
public var echoCancel: Bool?
public var touchMode: String?
public var mouseMode: String?
public var invertScroll: Bool?
public var gamepadType: Int?
public var gamepadForwarding: Bool?
public var systemButtons: String?
public var guideGesture: String?
/// A `StatsVerbosity` raw value ("off"/"compact"/"normal"/"detailed") — the enum lives in
/// PunktfunkKit, which this module must not depend on.
public var statsVerbosity: String?
public var fullscreenWhileStreaming: Bool?
// Apple-only additions (design §3).
public var enable444: Bool?
public var presentPriority: String?
public var smoothBuffer: Int?
public var vsync: Bool?
public var allowVRR: Bool?
public var windowedSafePresent: Bool?
public var modifierLayout: String?
/// Overlay keys a newer build wrote and this one doesn't model — carried through a
/// load→save round-trip untouched.
public var extra: [String: JSONValue] = [:]
public init() {}
/// True when the profile overrides nothing — "inherits everything", the state a freshly
/// created profile starts in. Unknown-key carry-through counts: a profile that only holds a
/// newer build's field is not empty.
public var isEmpty: Bool { self == SettingsOverlay() }
// MARK: Codable
private enum Key: String, CaseIterable {
case width, height
case refreshHz = "refresh_hz"
case matchWindow = "match_window"
case bitrateKbps = "bitrate_kbps"
case renderScale = "render_scale"
case codec
case hdrEnabled = "hdr_enabled"
case compositor
case audioChannels = "audio_channels"
case micEnabled = "mic_enabled"
case echoCancel = "echo_cancel"
case touchMode = "touch_mode"
case mouseMode = "mouse_mode"
case invertScroll = "invert_scroll"
case gamepadType = "gamepad"
case gamepadForwarding = "gamepad_forwarding"
case systemButtons = "system_buttons"
case guideGesture = "guide_gesture"
case statsVerbosity = "stats_verbosity"
case fullscreenWhileStreaming = "fullscreen_on_stream"
case enable444 = "enable_444"
case presentPriority = "present_priority"
case smoothBuffer = "smooth_buffer"
case vsync
case allowVRR = "allow_vrr"
case windowedSafePresent = "windowed_safe_present"
case modifierLayout = "modifier_layout"
}
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: AnyKey.self)
func int(_ k: Key) -> Int? { try? c.decodeIfPresent(Int.self, forKey: AnyKey(k.rawValue)) }
func dbl(_ k: Key) -> Double? { try? c.decodeIfPresent(Double.self, forKey: AnyKey(k.rawValue)) }
func bool(_ k: Key) -> Bool? { try? c.decodeIfPresent(Bool.self, forKey: AnyKey(k.rawValue)) }
func str(_ k: Key) -> String? { try? c.decodeIfPresent(String.self, forKey: AnyKey(k.rawValue)) }
width = int(.width)
height = int(.height)
refreshHz = int(.refreshHz)
matchWindow = bool(.matchWindow)
bitrateKbps = int(.bitrateKbps)
renderScale = dbl(.renderScale)
codec = str(.codec)
hdrEnabled = bool(.hdrEnabled)
compositor = int(.compositor)
audioChannels = int(.audioChannels)
micEnabled = bool(.micEnabled)
echoCancel = bool(.echoCancel)
touchMode = str(.touchMode)
mouseMode = str(.mouseMode)
invertScroll = bool(.invertScroll)
gamepadType = int(.gamepadType)
gamepadForwarding = bool(.gamepadForwarding)
systemButtons = str(.systemButtons)
guideGesture = str(.guideGesture)
statsVerbosity = str(.statsVerbosity)
fullscreenWhileStreaming = bool(.fullscreenWhileStreaming)
enable444 = bool(.enable444)
presentPriority = str(.presentPriority)
smoothBuffer = int(.smoothBuffer)
vsync = bool(.vsync)
allowVRR = bool(.allowVRR)
windowedSafePresent = bool(.windowedSafePresent)
modifierLayout = str(.modifierLayout)
let known = Set(Key.allCases.map(\.rawValue))
for key in c.allKeys where !known.contains(key.stringValue) {
extra[key.stringValue] = try c.decode(JSONValue.self, forKey: key)
}
}
public func encode(to encoder: Encoder) throws {
var c = encoder.container(keyedBy: AnyKey.self)
// Absent overrides serialize away entirely (no `null`s) — the file stays readable, and
// "not overridden" has exactly one representation on disk.
try c.encodeIfPresent(width, forKey: AnyKey(Key.width.rawValue))
try c.encodeIfPresent(height, forKey: AnyKey(Key.height.rawValue))
try c.encodeIfPresent(refreshHz, forKey: AnyKey(Key.refreshHz.rawValue))
try c.encodeIfPresent(matchWindow, forKey: AnyKey(Key.matchWindow.rawValue))
try c.encodeIfPresent(bitrateKbps, forKey: AnyKey(Key.bitrateKbps.rawValue))
try c.encodeIfPresent(renderScale, forKey: AnyKey(Key.renderScale.rawValue))
try c.encodeIfPresent(codec, forKey: AnyKey(Key.codec.rawValue))
try c.encodeIfPresent(hdrEnabled, forKey: AnyKey(Key.hdrEnabled.rawValue))
try c.encodeIfPresent(compositor, forKey: AnyKey(Key.compositor.rawValue))
try c.encodeIfPresent(audioChannels, forKey: AnyKey(Key.audioChannels.rawValue))
try c.encodeIfPresent(micEnabled, forKey: AnyKey(Key.micEnabled.rawValue))
try c.encodeIfPresent(echoCancel, forKey: AnyKey(Key.echoCancel.rawValue))
try c.encodeIfPresent(touchMode, forKey: AnyKey(Key.touchMode.rawValue))
try c.encodeIfPresent(mouseMode, forKey: AnyKey(Key.mouseMode.rawValue))
try c.encodeIfPresent(invertScroll, forKey: AnyKey(Key.invertScroll.rawValue))
try c.encodeIfPresent(gamepadType, forKey: AnyKey(Key.gamepadType.rawValue))
try c.encodeIfPresent(
gamepadForwarding, forKey: AnyKey(Key.gamepadForwarding.rawValue))
try c.encodeIfPresent(systemButtons, forKey: AnyKey(Key.systemButtons.rawValue))
try c.encodeIfPresent(guideGesture, forKey: AnyKey(Key.guideGesture.rawValue))
try c.encodeIfPresent(statsVerbosity, forKey: AnyKey(Key.statsVerbosity.rawValue))
try c.encodeIfPresent(
fullscreenWhileStreaming, forKey: AnyKey(Key.fullscreenWhileStreaming.rawValue))
try c.encodeIfPresent(enable444, forKey: AnyKey(Key.enable444.rawValue))
try c.encodeIfPresent(presentPriority, forKey: AnyKey(Key.presentPriority.rawValue))
try c.encodeIfPresent(smoothBuffer, forKey: AnyKey(Key.smoothBuffer.rawValue))
try c.encodeIfPresent(vsync, forKey: AnyKey(Key.vsync.rawValue))
try c.encodeIfPresent(allowVRR, forKey: AnyKey(Key.allowVRR.rawValue))
try c.encodeIfPresent(
windowedSafePresent, forKey: AnyKey(Key.windowedSafePresent.rawValue))
try c.encodeIfPresent(modifierLayout, forKey: AnyKey(Key.modifierLayout.rawValue))
let known = Set(Key.allCases.map(\.rawValue))
for (key, value) in extra where !known.contains(key) {
try c.encode(value, forKey: AnyKey(key))
}
}
}
// MARK: - Field descriptors
/// The overlay's fields, named the way a UI can carry them as plain strings: the serialized key,
/// with `resolution` as the one alias covering the width/height/match-window tri-state a single
/// control drives on every client. Kept as one list so "what can be reset" lives in one place —
/// two copies of it is exactly how a reset button and a commit path drift.
public enum OverlayField {
public static let resolution = "resolution"
/// Drop one override by name, putting the row back to inheriting. Returns false for a name
/// this build doesn't know.
@discardableResult
public static func clear(_ field: String, in overlay: inout SettingsOverlay) -> Bool {
switch field {
case resolution:
overlay.width = nil
overlay.height = nil
overlay.matchWindow = nil
case "width": overlay.width = nil
case "height": overlay.height = nil
case "refresh_hz": overlay.refreshHz = nil
case "match_window": overlay.matchWindow = nil
case "bitrate_kbps": overlay.bitrateKbps = nil
case "render_scale": overlay.renderScale = nil
case "codec": overlay.codec = nil
case "hdr_enabled": overlay.hdrEnabled = nil
case "compositor": overlay.compositor = nil
case "audio_channels": overlay.audioChannels = nil
case "mic_enabled": overlay.micEnabled = nil
case "echo_cancel": overlay.echoCancel = nil
case "touch_mode": overlay.touchMode = nil
case "mouse_mode": overlay.mouseMode = nil
case "invert_scroll": overlay.invertScroll = nil
case "gamepad": overlay.gamepadType = nil
case "gamepad_forwarding": overlay.gamepadForwarding = nil
case "system_buttons": overlay.systemButtons = nil
case "guide_gesture": overlay.guideGesture = nil
case "stats_verbosity": overlay.statsVerbosity = nil
case "fullscreen_on_stream": overlay.fullscreenWhileStreaming = nil
case "enable_444": overlay.enable444 = nil
case "present_priority": overlay.presentPriority = nil
case "smooth_buffer": overlay.smoothBuffer = nil
case "vsync": overlay.vsync = nil
case "allow_vrr": overlay.allowVRR = nil
case "windowed_safe_present": overlay.windowedSafePresent = nil
case "modifier_layout": overlay.modifierLayout = nil
default: return false
}
return true
}
/// Does this overlay override `field`? Drives the per-row override marker; `resolution` is
/// overridden when any leg of its tri-state is.
public static func isOverridden(_ field: String, in o: SettingsOverlay) -> Bool {
switch field {
case resolution: return o.width != nil || o.height != nil || o.matchWindow != nil
case "width": return o.width != nil
case "height": return o.height != nil
case "refresh_hz": return o.refreshHz != nil
case "match_window": return o.matchWindow != nil
case "bitrate_kbps": return o.bitrateKbps != nil
case "render_scale": return o.renderScale != nil
case "codec": return o.codec != nil
case "hdr_enabled": return o.hdrEnabled != nil
case "compositor": return o.compositor != nil
case "audio_channels": return o.audioChannels != nil
case "mic_enabled": return o.micEnabled != nil
case "echo_cancel": return o.echoCancel != nil
case "touch_mode": return o.touchMode != nil
case "mouse_mode": return o.mouseMode != nil
case "invert_scroll": return o.invertScroll != nil
case "gamepad": return o.gamepadType != nil
case "gamepad_forwarding": return o.gamepadForwarding != nil
case "system_buttons": return o.systemButtons != nil
case "guide_gesture": return o.guideGesture != nil
case "stats_verbosity": return o.statsVerbosity != nil
case "fullscreen_on_stream": return o.fullscreenWhileStreaming != nil
case "enable_444": return o.enable444 != nil
case "present_priority": return o.presentPriority != nil
case "smooth_buffer": return o.smoothBuffer != nil
case "vsync": return o.vsync != nil
case "allow_vrr": return o.allowVRR != nil
case "windowed_safe_present": return o.windowedSafePresent != nil
case "modifier_layout": return o.modifierLayout != nil
default: return false
}
}
}
// MARK: - The profile
/// One named bundle of overrides. `id` is stable across renames — bindings, pins and deep links
/// all point at it, never at the name.
public struct StreamProfile: Codable, Equatable, Identifiable, Sendable {
public var id: String
/// User-facing and editable; unique case-insensitively (menus are ambiguous otherwise —
/// enforced by the editing UIs via `ProfileCatalog.nameTaken`).
public var name: String
/// `#RRGGBB` chip color. The UI may ignore it; the schema reserves it (pinned cards use it
/// to tint their subtitle).
public var accent: String?
public var overrides: SettingsOverlay
/// Profile keys a newer build wrote — preserved across a load→save round-trip.
public var extra: [String: JSONValue] = [:]
/// A new, empty profile: inherits everything (the right creation default under
/// inherit-by-exception — "Duplicate" covers starting from another profile).
public init(name: String, id: String = newProfileID(), accent: String? = nil,
overrides: SettingsOverlay = SettingsOverlay()) {
self.id = id
self.name = name
self.accent = accent
self.overrides = overrides
}
private enum Key: String, CaseIterable {
case id, name, accent, overrides
}
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: AnyKey.self)
id = (try? c.decode(String.self, forKey: AnyKey(Key.id.rawValue))) ?? newProfileID()
name = (try? c.decode(String.self, forKey: AnyKey(Key.name.rawValue))) ?? ""
accent = try? c.decodeIfPresent(String.self, forKey: AnyKey(Key.accent.rawValue))
overrides = (try? c.decodeIfPresent(
SettingsOverlay.self, forKey: AnyKey(Key.overrides.rawValue))) ?? SettingsOverlay()
let known = Set(Key.allCases.map(\.rawValue))
for key in c.allKeys where !known.contains(key.stringValue) {
extra[key.stringValue] = try c.decode(JSONValue.self, forKey: key)
}
}
public func encode(to encoder: Encoder) throws {
var c = encoder.container(keyedBy: AnyKey.self)
try c.encode(id, forKey: AnyKey(Key.id.rawValue))
try c.encode(name, forKey: AnyKey(Key.name.rawValue))
try c.encodeIfPresent(accent, forKey: AnyKey(Key.accent.rawValue))
try c.encode(overrides, forKey: AnyKey(Key.overrides.rawValue))
let known = Set(Key.allCases.map(\.rawValue))
for (key, value) in extra where !known.contains(key) {
try c.encode(value, forKey: AnyKey(key))
}
}
}
/// 12 lowercase hex characters — the shape the Rust `new_profile_id` mints, so the two catalogs
/// speak one id format.
public func newProfileID() -> String {
(0..<6).map { _ in String(format: "%02x", UInt8.random(in: 0...255)) }.joined()
}
// MARK: - The catalog
/// What a `profile=` / `Connect with ▸` reference resolved to. Ambiguity is reported rather than
/// guessed: a link naming two profiles must refuse, not pick one.
public enum ProfileResolution: Equatable, Sendable {
case found
case notFound
/// More than one profile carries this name (case-insensitively).
case ambiguous
}
/// The profile catalog — client-wide, not per host: "Work" applied to three hosts is one
/// profile, and the per-host part is only the binding on the host record.
public struct ProfileCatalog: Codable, Equatable, Sendable {
public var version: Int
public var profiles: [StreamProfile]
public init(version: Int = punktfunkProfilesVersion, profiles: [StreamProfile] = []) {
self.version = version
self.profiles = profiles
}
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
version = (try? c.decodeIfPresent(Int.self, forKey: .version)) ?? 0
profiles = (try? c.decodeIfPresent([StreamProfile].self, forKey: .profiles)) ?? []
}
/// The stored catalog, or an empty one — a missing or unreadable blob is "no profiles", never
/// an error: nothing about streaming may hinge on this key existing.
public static func load(from defaults: UserDefaults = AppGroup.defaults) -> ProfileCatalog {
guard let data = defaults.data(forKey: DefaultsKey.profiles),
let catalog = try? JSONDecoder().decode(ProfileCatalog.self, from: data)
else { return ProfileCatalog(profiles: []) }
return catalog
}
public func save(to defaults: UserDefaults = AppGroup.defaults) {
var out = self
out.version = punktfunkProfilesVersion
guard let data = try? JSONEncoder().encode(out) else { return }
defaults.set(data, forKey: DefaultsKey.profiles)
}
public func profile(id: String) -> StreamProfile? {
profiles.first { $0.id == id }
}
/// The binding a host resolves to, dropping a dangling id: a profile deleted out from under a
/// host is "no profile" (today's behaviour), never an error and never a blocked connect.
public func binding(for host: StoredHost) -> StreamProfile? {
host.profileID.flatMap { profile(id: $0) }
}
/// This host's pinned profiles, in card order, with duplicates and dangling ids dropped —
/// a pin is presentation only, so a deleted profile just loses its card.
public func pinned(for host: StoredHost) -> [StreamProfile] {
var seen = Set<String>()
return (host.pinnedProfileIDs ?? [])
.filter { seen.insert($0).inserted }
.compactMap { profile(id: $0) }
}
/// Resolve a reference the way every surface must: exact id first, then a unique
/// case-insensitive name. Ambiguous names resolve to `.ambiguous`, never to the first match.
public func resolve(_ reference: String) -> (StreamProfile?, ProfileResolution) {
if let p = profile(id: reference) { return (p, .found) }
let hits = profiles.filter { $0.name.lowercased() == reference.lowercased() }
switch hits.count {
case 1: return (hits[0], .found)
case 0: return (nil, .notFound)
default: return (nil, .ambiguous)
}
}
/// Is this name already used (case-insensitively) by a *different* profile? The create/rename
/// guard — `except` is the profile being renamed, so renaming "Work" to "work" is allowed.
public func nameTaken(_ name: String, except: String? = nil) -> Bool {
profiles.contains {
$0.name.lowercased() == name.lowercased() && $0.id != except
}
}
}