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
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).
487 lines
23 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|