Files
punktfunk/clients/apple/Sources/PunktfunkClient/Stores/ProfileStore.swift
T
enricobuehlerandClaude Opus 5 858cf0e535 feat(client/apple): profiles, resolved once per connect instead of ten times mid-session
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>
2026-07-29 12:06:41 +02:00

108 lines
4.4 KiB
Swift

// The settings-profile catalog as an observable store — the app-side wrapper around
// `ProfileCatalog` (design/client-settings-profiles.md §4.2), matching what `HostStore` is to
// `[StoredHost]`.
//
// The catalog lives in the App Group suite beside the saved hosts, because that is where the
// things that POINT at it live: a binding is `StoredHost.profileID` and a pin is an entry in
// `StoredHost.pinnedProfileIDs`. Nothing here is keyed by host — "Work" applied to three hosts is
// one profile, and the per-host part is only the binding (§4.1).
import Foundation
import PunktfunkKit
import SwiftUI
@MainActor
final class ProfileStore: ObservableObject {
@Published private(set) var catalog: ProfileCatalog {
didSet { catalog.save() }
}
var profiles: [StreamProfile] { catalog.profiles }
init(catalog: ProfileCatalog? = nil) {
self.catalog = catalog ?? ProfileCatalog.load()
}
func profile(id: String?) -> StreamProfile? {
id.flatMap { catalog.profile(id: $0) }
}
/// This host's default profile, dangling ids dropped — a deleted profile resolves as "Default
/// settings", never an error (§4.4).
func binding(for host: StoredHost) -> StreamProfile? { catalog.binding(for: host) }
/// This host's pinned profiles in card order, duplicates and dangling ids dropped.
func pinned(for host: StoredHost) -> [StreamProfile] { catalog.pinned(for: host) }
func nameTaken(_ name: String, except: String? = nil) -> Bool {
catalog.nameTaken(name, except: except)
}
// MARK: - Catalog management (the scope menu's Rename / Duplicate / Delete)
/// Create an EMPTY profile — it inherits everything, which is the right creation default under
/// inherit-by-exception. "Duplicate" covers starting from another profile.
@discardableResult
func create(name: String) -> StreamProfile {
let profile = StreamProfile(name: name)
catalog.profiles.append(profile)
return profile
}
@discardableResult
func duplicate(_ id: String, name: String) -> StreamProfile? {
guard let source = catalog.profile(id: id) else { return nil }
var copy = source
copy.id = newProfileID()
copy.name = name
catalog.profiles.append(copy)
return copy
}
func rename(_ id: String, to name: String) {
guard let i = catalog.profiles.firstIndex(where: { $0.id == id }) else { return }
catalog.profiles[i].name = name
}
func setAccent(_ id: String, to accent: String?) {
guard let i = catalog.profiles.firstIndex(where: { $0.id == id }) else { return }
catalog.profiles[i].accent = accent
}
/// Delete a profile. Bindings and pins pointing at it are left alone deliberately: they
/// degrade to "Default settings" / a dropped card at read time (§6), so a delete never has to
/// walk the host store — and a host record saved by an older build can't resurrect a stale id.
func delete(_ id: String) {
catalog.profiles.removeAll { $0.id == id }
}
/// How the delete warning counts what it is about to change: hosts bound to this profile and
/// pinned cards that will disappear.
func usage(of id: String, hosts: [StoredHost]) -> (bound: Int, pinned: Int) {
(
hosts.filter { $0.profileID == id }.count,
hosts.filter { ($0.pinnedProfileIDs ?? []).contains(id) }.count
)
}
// MARK: - Overrides
/// Record an override, always by explicit write — never by comparing the new value against
/// today's global. A value that happens to equal the global is a legitimate PIN: the profile
/// keeps it when the global later moves, and that is the whole difference between this feature
/// and "copy the settings" (§4.1).
func setOverride<Value>(
_ id: String, _ keyPath: WritableKeyPath<SettingsOverlay, Value?>, _ value: Value
) {
guard let i = catalog.profiles.firstIndex(where: { $0.id == id }) else { return }
catalog.profiles[i].overrides[keyPath: keyPath] = value
}
/// The only way back to inheriting: an explicit per-row reset. `field` is the overlay's own
/// serialized name, with `resolution` covering the width/height/match-window tri-state.
func clearOverride(_ id: String, field: String) {
guard let i = catalog.profiles.firstIndex(where: { $0.id == id }) else { return }
OverlayField.clear(field, in: &catalog.profiles[i].overrides)
}
}