import SwiftUI
import PhysarumEngine
@Observable
final class AppState {
private let store: PresetStore
private let audioStore: AudioPresetStore
/// The fixed-slot preset model (save-game style): the filled slots ARE the preset list, a
/// slot's `Preset.id` is its slot id (`slot-NN`), and pins drive the ATV rotation. Persists +
/// syncs via its backend (iCloud-KVS on iOS/tvOS, a local file on macOS / in tests).
let slotStore: SlotStore
/// Read-only access to the underlying preset store (e.g. for thumbnail resolution).
var presetStore: PresetStore { store }
/// Saved, switchable audio-reactivity configs (routes + gain + auto-gain). Persisted via
/// `audioStore`, ordered by `_order.json`.
var audioPresets: [AudioConfig]
var selectedID: String
var edited: PresetParams // live-edited working copy (drives the engine)
var baseline: PresetParams // selected preset's original — for dirty + per-param reset
var autoCycle: Bool = false
var locked: Set<ParamID> = []
/// Bumped when a thumbnail PNG is (re)written, so PresetCards reload.
var thumbnailRevision = 0
/// Set by RootView so the save flow can snapshot the live image.
weak var snapshotProvider: LiveSnapshotProvider?
var undoStack: [PresetParams] = []
var redoStack: [PresetParams] = []
let audio = AudioInput()
/// Global, preset-independent audio-reactivity config — persisted, survives preset
/// switch / undo / randomize. (Mic setup is a property of "how I drive it", not the look.)
var audioReactive: AudioReactive {
didSet { store.saveAudio(audioReactive) }
}
/// Loudness-response calibration constants (device-tuning). Global like `audioReactive`,
/// persisted to its own file, never part of the preset/undo history. Defaults reproduce
/// the shipped curve; only the DEBUG tuning rig mutates these.
var audioTuning: AudioTuning {
didSet { store.saveTuning(audioTuning) }
}
/// The preset list = the filled slots, in slot order. (No built-in/custom split — every slot
/// is an equal, editable preset; some ship factory-filled, all overwritable.)
var presets: [Preset] { slotStore.slots.filled }
/// Never empty-crashes: prefer the selected slot, else the first filled slot, else the fallback.
var selectedPreset: Preset {
presets.first { $0.id == selectedID } ?? presets.first ?? PresetCatalog.fallbackPreset
}
/// The slot index of the current selection, or nil when the selection isn't a *filled* slot
/// (parses `slot-NN` AND confirms the slot is non-empty — so SaveDialog never labels an empty
/// slot "overwrite" after a remote clear).
var selectedSlotIndex: Int? {
PresetSlots.index(of: selectedID).flatMap { slotStore.slots.preset(at: $0) != nil ? $0 : nil }
}
/// The lowest empty slot, or nil when all 24 slots are filled.
var firstEmptySlotIndex: Int? { slotStore.slots.firstEmptyIndex }
var isDirty: Bool { !paramsEqual(edited, baseline) }
// MARK: - pinning (drives the ATV lean-back rotation)
var pinned: Set<String> { slotStore.slots.pinned }
func isPinned(_ id: String) -> Bool { slotStore.slots.isPinned(id) }
func togglePin(_ id: String) { slotStore.togglePin(id) }
init(slotStore: SlotStore? = nil,
store: PresetStore = PresetStore(),
audioStore: AudioPresetStore = AudioPresetStore()) {
self.store = store
self.audioStore = audioStore
// Default to an isolated in-memory store (tests + previews never touch iCloud / disk); the
// app's scenes inject the real persisted store via `makeProductionSlotStore()`.
self.slotStore = slotStore ?? SlotStore(backend: InMemorySlotBackend(), factory: FactorySlots.load())
audioPresets = audioStore.loadAll()
audioReactive = store.loadAudio() ?? AudioReactive(enabled: false, gain: 1, autoGain: true, routes: [])
audioTuning = store.loadTuning() ?? AudioTuning()
let filled = self.slotStore.slots.filled
var first = filled.first ?? PresetCatalog.fallbackPreset
#if DEBUG
// Capture hook: boot a specific filled-slot preset via PHYSARUM_BOOT_PRESET=<index>
// for deterministic App Store media capture. DEBUG-only — excluded from release builds.
if let idx = AppState.bootPresetIndex(from: ProcessInfo.processInfo.environment, filledCount: filled.count) {
first = filled[idx]
}
#endif
selectedID = first.id
edited = first.params
baseline = first.params
// Reconcile the selection/working copy + refresh thumbnails when another device changes the
// store (iCloud sync) or the initial load lands.
self.slotStore.onReload = { [weak self] in self?.handleSlotReload() }
}
#if DEBUG
/// Parse `PHYSARUM_BOOT_PRESET` into a valid filled-slot index, or nil. Capture hook only.
static func bootPresetIndex(from env: [String: String], filledCount: Int) -> Int? {
guard let raw = env["PHYSARUM_BOOT_PRESET"], let i = Int(raw), i >= 0, i < filledCount else { return nil }
return i
}
#endif
/// Run after the slot store reloads (external iCloud change or initial load): reconcile the
/// selection, then refresh thumbnails — bump the revision so content-addressed cards re-resolve,
/// and backfill renders any newly-arrived / changed-content slots that lack a local PNG.
private func handleSlotReload() {
reconcileSelectionAfterReload()
thumbnailRevision += 1
backfillThumbnails()
}
/// After an external (iCloud) change reloaded the slots: if the selected slot vanished (cleared
/// on another device), fall back to the first filled slot; if it still exists but its content
/// changed and there are no pending local edits, refresh the working copy to the new content.
/// Dirty local edits are kept (last-writer-wins per the spec — don't clobber an in-progress edit).
private func reconcileSelectionAfterReload() {
guard let current = presets.first(where: { $0.id == selectedID }) else {
if let firstFilled = presets.first { select(id: firstFilled.id) }
return
}
if !isDirty, !paramsEqual(current.params, baseline) {
baseline = current.params
edited = current.params
undoStack.removeAll() // the undo history referenced the pre-refresh params — drop it
redoStack.removeAll()
}
}
/// The production slot store: iCloud-KVS on iOS/tvOS (paid team + ubiquity-kvstore entitlement),
/// a local JSON file on macOS — the Developer-ID/notarized Mac build has no embedded iCloud
/// provisioning profile yet, so KVS is deferred there (spec-sanctioned fast-follow).
static func makeProductionSlotStore() -> SlotStore {
let factory = FactorySlots.load()
#if os(macOS)
let backend: SlotBackend = FileSlotBackend(url: slotsFileURL)
#else
let backend: SlotBackend = KVSSlotBackend()
#endif
return SlotStore(backend: backend, factory: factory)
}
/// Local fallback slot file (App Support), used by the macOS backend.
static var slotsFileURL: URL {
let base = (try? FileManager.default.url(for: .applicationSupportDirectory,
in: .userDomainMask, appropriateFor: nil, create: true))
?? URL(fileURLWithPath: NSTemporaryDirectory())
let dir = base.appendingPathComponent("Physarum", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir.appendingPathComponent("slots.json")
}
func select(id: String) {
guard let p = presets.first(where: { $0.id == id }) else { return }
selectedID = id
edited = p.params
baseline = p.params
undoStack.removeAll()
redoStack.removeAll()
}
/// Cycle the selection by ±step over the ATV rotation (pinned slots; nothing pinned → all
/// filled). Pins curate the auto-cycle on every platform. When the current selection isn't in
/// the rotation (an unpinned slot while a pinned subset exists), enter the rotation at its start
/// (forward) or end (backward) rather than landing off-by-one.
func advance(by step: Int) {
let list = slotStore.slots.rotation
guard !list.isEmpty else { return }
let i: Int
if let current = list.firstIndex(where: { $0.id == selectedID }) {
i = (current + step + list.count) % list.count
} else {
i = step > 0 ? 0 : list.count - 1
}
select(id: list[i].id)
}
func reset(_ descriptor: ParamDescriptor) { descriptor.set(&edited, descriptor.get(baseline)) }
func resetAll() { pushUndo(); edited = baseline }
/// True if `descriptor` differs from the selected preset's baseline (per-param dirty).
func isModified(_ d: ParamDescriptor) -> Bool { abs(d.get(edited) - d.get(baseline)) > 1e-6 }
func resetSpawnPattern() { pushUndo(); edited.spawnPattern = baseline.spawnPattern }
func resetBlendMode() { pushUndo(); edited.blendMode = baseline.blendMode }
func resetBackgroundColor() { pushUndo(); edited.backgroundColor = baseline.backgroundColor }
func resetSpeciesColor(_ i: Int) { pushUndo(); edited.species[i].color = baseline.species[i].color }
/// Non-scalar dirty checks (mirror `isModified` for enum/colour controls).
var spawnModified: Bool { edited.spawnPattern != baseline.spawnPattern }
var blendModified: Bool { edited.blendMode != baseline.blendMode }
var backgroundModified: Bool { edited.backgroundColor != baseline.backgroundColor }
func speciesColorModified(_ i: Int) -> Bool { edited.species[i].color != baseline.species[i].color }
// MARK: - slot operations (save = pick a slot)
/// Save the current live edits into `index` (overwrite a filled slot or fill an empty one). The
/// slot's preset id becomes its slot id; the slot is marked user-owned (so an update never
/// clobbers it); a fresh thumbnail is rendered locally (thumbnails are device-local, not synced).
func saveToSlot(index: Int, name: String, tags: [String], tagSignal: SignalName) {
var p = selectedPreset
p.name = name.isEmpty ? "Untitled" : name
p.author = nil
p.builtIn = false
p.tags = tags
p.tagSignal = tagSignal
p.thumbnail = nil
p.params = edited
slotStore.save(p, toIndex: index) // stamps p.id = slot id, strips thumbnail, marks owned
selectedID = PresetSlots.slotID(index)
baseline = edited
generateThumbnail(params: edited)
}
/// Empty a slot (drops its pin). If it held the selection, fall back to the first filled slot.
/// (Thumbnails are content-addressed and orphan-pruned by `backfillThumbnails`, so a reused slot
/// never shows a previous occupant's render — its new content has a different key.)
func clearSlot(slotID: String) {
slotStore.clear(slotID: slotID)
if selectedID == slotID, let first = presets.first { select(id: first.id) }
}
/// Import a preset into the lowest empty slot (slot-aware replacement for the old custom import).
/// Re-id'd to its slot id, forced non-builtin, author/thumbnail cleared, marked user-owned.
/// Returns false when every slot is full (caller surfaces it rather than dropping the file).
@discardableResult
func importIntoSlot(_ incoming: Preset) -> Bool {
// Prefer an empty slot; if the grid is full of factory defaults, displace the lowest one
// (recoverable via Reset). Only fail when every slot is the user's own work.
guard let i = slotStore.slots.firstEmptyIndex ?? slotStore.slots.firstNonUserOwnedIndex else { return false }
var p = incoming
p.author = nil
p.builtIn = false
p.thumbnail = nil
slotStore.save(p, toIndex: i)
generateThumbnail(params: p.params)
return true
}
/// Reset every slot to its factory default (destructive; clears user edits + pins, and — on the
/// app's synced backend — propagates to every device). Re-selects the first filled slot.
func resetAllToFactory() {
slotStore.resetToFactory() // factory slots resolve curated bundle art; user renders get orphan-pruned
if let first = presets.first { select(id: first.id) }
}
/// Prune the store to just the pinned presets, compacted to the front (the kept set stays
/// pinned, so the ATV rotation becomes exactly that set). No-op when nothing is pinned. Slot
/// thumbnails are cleared first (ids are reused); factory-derived kept slots re-resolve curated
/// art immediately, user slots re-render via backfill. Re-selects the first surviving slot.
func keepOnlyPinned() {
guard !pinned.isEmpty else { return }
for i in 0..<PresetSlots.count { store.deleteThumbnail(for: PresetSlots.slotID(i)) }
slotStore.keepOnlyPinned()
if let first = presets.first { select(id: first.id) }
}
/// One-shot migration of the pre-slot custom presets (the old `PresetStore` JSONs) into the
/// lowest empty slots (marked user-owned, so a later factory update never clobbers them). Runs
/// once per device, guarded by a `UserDefaults` flag. Overflow (more legacy customs than free
/// slots) is exported rather than dropped. Called by the production scenes after construction;
/// `defaults` is injectable so tests neither read the real customs nor flip the global flag.
func migrateLegacyCustomsIfNeeded(defaults: UserDefaults = .standard) {
// Defer past the initial iCloud sync: migrating + persisting before the cloud has downloaded
// would clobber the account's synced slots with this device's local customs.
slotStore.whenLoaded { [weak self] in self?.performLegacyMigration(defaults: defaults) }
}
private func performLegacyMigration(defaults: UserDefaults) {
let key = "slotMigrationDone"
guard !defaults.bool(forKey: key) else { return }
let legacy = store.loadCustom()
if !legacy.isEmpty {
let overflow = slotStore.migrate(customs: legacy)
if !overflow.isEmpty, let data = try? JSONEncoder().encode(overflow) {
try? data.write(to: store.legacyExportURL)
}
}
defaults.set(true, forKey: key)
}
/// Push pending slot changes to the backend. iCloud-KVS pushes most reliably when the app
/// backgrounds, so the scenes call this on `scenePhase` leaving `.active`.
func flushSync() { slotStore.flush() }
// MARK: - thumbnails
/// Render warm-start thumbnails for filled slots that have NO resolvable thumbnail (neither a
/// local render of the current content nor curated bundled art), one at a time (off-main,
/// serialized) so opening the library never blocks and never floods the GPU. Thumbnails are
/// content-addressed, so a slot whose params changed (edited OR synced from another device) has a
/// new key with no PNG yet → it gets re-rendered here. Also prunes orphaned PNGs from prior content.
func backfillThumbnails() {
let store = self.store
let filled = slotStore.slots.filled
let keep = Set(filled.map { PresetCatalog.thumbnailKey(for: $0) })
let missing = filled.filter { PresetCatalog.thumbnailURL(for: $0, store: store) == nil }
Task.detached(priority: .background) {
store.pruneThumbnails(keeping: keep)
for p in missing {
guard let png = try? ThumbnailRenderer.render(.warmStart(p.params)) else { continue }
try? store.writeThumbnail(png, for: PresetCatalog.thumbnailKey(for: p))
await MainActor.run { self.thumbnailRevision += 1 }
}
}
}
/// Render + persist a thumbnail for the given params, off-main, keyed by their content hash. Live
/// snapshot if available, else a fresh warm-start. Bumps `thumbnailRevision` on completion.
func generateThumbnail(params: PresetParams) {
let key = PresetCatalog.thumbnailKey(forParams: params)
let source: ThumbnailSource = (snapshotProvider?.takeSnapshot()).map { .liveSnapshot($0) }
?? .warmStart(params)
let store = self.store
Task.detached(priority: .utility) { [self] in
guard let png = try? ThumbnailRenderer.render(source) else { return }
try? store.writeThumbnail(png, for: key)
await MainActor.run { self.thumbnailRevision += 1 }
}
}
// MARK: - audio wiring
static let defaultAudioRoutes: [AudioRoute] = [
AudioRoute(source: .bass, target: .bloomIntensity, depth: 1.2, mode: .transient),
AudioRoute(source: .bass, target: .moveSpeed, depth: 0.8, mode: .transient),
AudioRoute(source: .high, target: .exposure, depth: 0.6, mode: .transient),
AudioRoute(source: .mid, target: .chaosStrength, depth: 0.7, mode: .transient),
AudioRoute(source: .mid, target: .saturation, depth: 0.4, mode: .level),
]
func applyDefaultAudioWiring() {
audioReactive = AudioReactive(enabled: true, gain: 1, autoGain: true, routes: Self.defaultAudioRoutes)
}
/// Restore the loudness-response constants to the shipped defaults (DEBUG tuning rig).
func resetTuning() { audioTuning = AudioTuning() }
/// Toggle audio. On the FIRST enable (routes empty), auto-apply the default routes
/// + autoGain so it reacts instantly without the user hunting for the Default button.
/// Audio config is global (not part of the preset/undo history).
func setAudioEnabled(_ on: Bool) {
var a = audioReactive
if on && a.routes.isEmpty {
a.routes = Self.defaultAudioRoutes
a.autoGain = true
}
a.enabled = on
audioReactive = a // single assignment → one persistence write
}
// MARK: - audio presets (saveable, switchable configs)
/// Snapshot the current `audioReactive` as a named, persisted config.
func saveAudioPreset(name: String) {
let id = "audio-\(slug(name))-\(Int(Date().timeIntervalSince1970))"
try? audioStore.save(AudioConfig(id: id, name: name.isEmpty ? "Audio" : name, reactive: audioReactive))
audioPresets = audioStore.loadAll()
}
/// Apply a saved config to the live `audioReactive` (single assignment → one
/// persistence write via `didSet`). No-op for an unknown id.
func applyAudioPreset(id: String) {
guard let cfg = audioPresets.first(where: { $0.id == id }) else { return }
audioReactive = cfg.reactive
}
/// Add an imported audio config as a fresh, persisted preset: always re-id'd (so
/// re-importing the same file never collides). Persists via `audioStore` and reloads.
func importAudioConfig(_ incoming: AudioConfig) {
var c = incoming
c.id = "audio-\(slug(incoming.name))-\(Int(Date().timeIntervalSince1970))"
try? audioStore.save(c)
audioPresets = audioStore.loadAll()
}
func deleteAudioPreset(id: String) {
audioStore.delete(id)
audioPresets = audioStore.loadAll()
}
func reorderAudioPreset(id: String, up: Bool) {
audioStore.reorder(reorderedIDs(audioPresets.map { $0.id }, moving: id, up: up))
audioPresets = audioStore.loadAll()
}
private func slug(_ s: String) -> String {
let lower = s.lowercased()
let mapped = lower.map { ch -> Character in ch.isLetter || ch.isNumber ? ch : "-" }
let collapsed = String(mapped).split(separator: "-").joined(separator: "-")
return collapsed.isEmpty ? "preset" : collapsed
}
}
/// Move `id` one slot up/down within `ids`, returning the new order. No-op at the
/// boundary or for an unknown id.
func reorderedIDs(_ ids: [String], moving id: String, up: Bool) -> [String] {
guard let i = ids.firstIndex(of: id) else { return ids }
let j = up ? i - 1 : i + 1
guard ids.indices.contains(j) else { return ids }
var r = ids
r.swapAt(i, j)
return r
}
/// Structural equality for dirty tracking (PresetParams isn't Equatable): compare
/// every registry scalar plus the non-scalar params.
func paramsEqual(_ a: PresetParams, _ b: PresetParams) -> Bool {
for d in ParamRegistry.all where abs(d.get(a) - d.get(b)) > 1e-6 { return false }
return a.spawnPattern == b.spawnPattern
&& a.blendMode == b.blendMode
&& a.backgroundColor == b.backgroundColor
&& zip(a.species, b.species).allSatisfy { $0.color == $1.color }
}