// 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 touchMode: String? public var mouseMode: String? public var invertScroll: Bool? public var gamepadType: Int? /// 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 touchMode = "touch_mode" case mouseMode = "mouse_mode" case invertScroll = "invert_scroll" case gamepadType = "gamepad" 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) touchMode = str(.touchMode) mouseMode = str(.mouseMode) invertScroll = bool(.invertScroll) gamepadType = int(.gamepadType) 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(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(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 "touch_mode": overlay.touchMode = nil case "mouse_mode": overlay.mouseMode = nil case "invert_scroll": overlay.invertScroll = nil case "gamepad": overlay.gamepadType = 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 "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 "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() 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 } } }