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>
462 lines
21 KiB
Swift
462 lines
21 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 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<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
|
|
}
|
|
}
|
|
}
|