Files
punktfunk/clients/apple/Sources/PunktfunkClient/Stores/ProfileStore.swift
T
enricobuehler 8f35155c14 fix(apple/shots): the store screenshots show the app as it actually is
Uploading the 0.24.0 set surfaced a pair screen that reads as broken, and a
hero that was never the orientation it claimed.

The capture harness:

- Landscape scenes were captured in PORTRAIT. `IOSOrientationConfigurator`
  asked for the geometry update from `updateUIViewController`, where
  `view.window` is still nil — SwiftUI makes one update pass for a
  `.background` representable, before the hierarchy is in a window, so the
  guard fell through and nothing ever asked again. Both `.landscape` scenes
  (the stream hero, the trust card) shipped as portrait. Now a real
  UIViewController asks from `viewDidAppear` and pins
  `supportedInterfaceOrientations`.

- The shot host applied `.ignoresSafeArea()` to the whole scene, so the
  hero's HUD — resolution, bitrate, the latency breakdown, the entire point
  of that screenshot — sat under the Dynamic Island. Only the black backing
  ignores it now; scenes that want full bleed already ignore it themselves.

- `03-pair` was hand-composed into a ZStack rather than presented. PairSheet
  is a bottom sheet on iOS: its detents and the system's Liquid Glass only
  exist inside a real `.sheet`. Composed, the grouped Form stretched to full
  screen height and the capture was a strip of content over a black void,
  with a DISABLED "Pair & Connect" (empty PIN) and the capture simulator's
  own name — `pf-shot-iphone-6.9` — rendered in as the device name.

- Sheets do not inherit `.environment(\.colorScheme, .dark)` across the
  presentation boundary; they follow the DEVICE. The pairing sheet came out
  light grey over the dark app. The simulator is now set to dark appearance.

- Discovery browsed the live LAN mid-capture, so a bystanding machine's
  hostname went out on the listing and no two runs matched. `HostDiscovery`
  gains a `debugSet` seam (the counterpart to `HostWaker.debugSet`); the
  mock hosts advertise, so cards read ONLINE through the real `advertises`
  path and the reachability probe never touches the network.

- Created simulators were named `pf-shot-<prefix>`, which the reuse regex
  never matches: every run created another simulator and none was reused.
  They are named after the device now — reusable, and not user-visible junk.

Two bugs found on the way, neither screenshot-only:

- HostStore/ProfileStore PERSISTED the harness's mock data. On a dev Mac
  that is the same App-Group suite the real app reads, so running the
  script could replace the tester's saved hosts with "Battlestation" & co.
- GamepadHomeView drew the controller chip as a trailing `.overlay`, which
  reserves no width — on a portrait phone it sat on top of the centred
  "Select a Host". Laid out as a row with a hidden leading mirror.
- The pairing sheet's field prompt said "How the host lists this Mac" on
  iPhone and iPad.

Coverage: the listing set is six scenes in listing order, and is now the
stream, the machines it found, the couch/controller mode, waking a sleeping
host, the quality controls and pairing — the console and wake screens
already existed in `ShotScenes.all` and were simply never captured. Mock
hosts carry OS marks, Wake-on-LAN MACs and profile chips so the grid is
full rather than three offline rows over an empty half-screen. `SCENES=`
overrides the set for the dev scenes.
2026-08-04 21:30:18 +02:00

128 lines
5.7 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 {
/// One catalog for the whole app. Unlike `HostStore` (which ContentView owns and hands down),
/// the settings surface reaches this from a SEPARATE macOS `Settings` scene, where no parent
/// can pass it — and two instances would mean editing a profile in Preferences while the host
/// grid still shows the old one. Same shape as `GamepadManager.shared`.
static let shared = ProfileStore()
@Published private(set) var catalog: ProfileCatalog {
didSet {
#if DEBUG
// Shot mode seeds this SINGLETON with mock profiles to populate the host cards.
// Saving would write them into the tester's real catalog — see HostStore.persist().
if ScreenshotMode.isActive { return }
#endif
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) }
}
#if DEBUG
/// Shot-mode seed: replace the catalog outright so a capture shows a known set of profiles
/// rather than the tester's. Safe because `didSet` suppresses the write-back in shot mode.
func debugSet(_ profiles: [StreamProfile]) {
catalog = ProfileCatalog(profiles: profiles)
}
#endif
/// 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)
/// Add a profile the editor built. A blank one inherits everything — the right creation
/// default under inherit-by-exception; a duplicate arrives carrying the source's overrides,
/// which is what duplicating is for.
func add(_ profile: StreamProfile) {
catalog.profiles.append(profile)
}
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) -> (bound: Int, pinned: Int) {
let hosts = Self.savedHosts()
return (
hosts.filter { $0.profileID == id }.count,
hosts.filter { ($0.pinnedProfileIDs ?? []).contains(id) }.count
)
}
/// Saved hosts straight from the shared store. The settings surface owns no `HostStore` — it
/// only needs to COUNT what a delete is about to change, and reading the same App-Group blob
/// the widget reads beats threading a store through a separate macOS Settings scene.
static func savedHosts() -> [StoredHost] {
guard let data = AppGroup.defaults.data(forKey: DefaultsKey.hosts),
let hosts = try? JSONDecoder().decode([StoredHost].self, from: data)
else { return [] }
return hosts
}
// 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)
}
}