Zum Inhalt

apple/PhysarumApp/Sources/Model/SlotStore.swift

import SwiftUI
import PhysarumEngine

/// Owns the live `PresetSlots`, persists every mutation through a `SlotBackend`, seeds from the factory on
/// first run, and reloads when another device changes the store. `@Observable` so the library + ATV react.
/// Main-only (like the rest of the model); the KVS backend hops its notification to main before calling back.
@Observable
final class SlotStore {
    private(set) var slots: PresetSlots
    private let backend: SlotBackend
    private let factory: PresetSlots.Factory
    /// Fired (on main) after an external change reloaded the slots — AppState uses it to reconcile
    /// its selection/working copy against the new slot set (e.g. the selected slot was cleared or
    /// edited on another device).
    var onReload: (() -> Void)?

    /// Whether the backend's authoritative state has been adopted yet (immediately for local backends;
    /// after the initial iCloud download / fallback for KVS).
    private(set) var isLoaded = false
    /// Work deferred until the store is loaded (e.g. the legacy migration) — so it never runs against,
    /// or persists over, a not-yet-downloaded cloud state.
    private var pendingWhenLoaded: [() -> Void] = []

    init(backend: SlotBackend, factory: PresetSlots.Factory) {
        self.backend = backend
        self.factory = factory
        // Seed in memory for immediate UI, but DON'T persist yet: on a fresh device the first read can
        // be empty before iCloud finishes its async download — persisting factory here would clobber the
        // account's real slots/pins (the bug). The authoritative reconcile happens once loaded.
        var model = PresetSlots(payload: backend.read())
        model.seed(factory)
        self.slots = model
        backend.onExternalChange = { [weak self] in self?.reloadFromBackend() }
        backend.onLoaded = { [weak self] authoritative in self?.reconcileAfterLoad(authoritative: authoritative) }
        if backend.isLoaded { reconcileAfterLoad(authoritative: true) }
    }

    /// The backend's authoritative state is now available. Adopt it, seed any empty/untouched slots
    /// (true-first-run, or a newer factory version), and persist ONLY when `authoritative` AND seeding
    /// actually changed something — so a synced device never clobbers the cloud with factory, a fresh
    /// account gets established, and a mere fallback-timeout never pushes factory upward.
    private func reconcileAfterLoad(authoritative: Bool) {
        guard !isLoaded else { return }
        isLoaded = true
        var model = PresetSlots(payload: backend.read())
        let beforeSeed = model
        model.seed(factory)
        slots = model
        if authoritative, model != beforeSeed { backend.write(model.payload) }
        onReload?()
        let blocks = pendingWhenLoaded
        pendingWhenLoaded = []
        blocks.forEach { $0() }
    }

    /// Run `block` once the store is loaded (immediately if already loaded). Used to defer the legacy
    /// migration past the initial iCloud sync so it doesn't clobber not-yet-downloaded cloud data.
    func whenLoaded(_ block: @escaping () -> Void) {
        if isLoaded { block() } else { pendingWhenLoaded.append(block) }
    }

    /// Adopt another device's change. Does NOT write back (avoids echoing). Guards against a degenerate
    /// EMPTY read mid-download: never replace a populated model with an empty payload (a partial sync
    /// artifact) — real edits that empty everything don't happen (a reset writes the factory set).
    func reloadFromBackend() {
        let incoming = PresetSlots(payload: backend.read())
        if incoming.filled.isEmpty && !slots.filled.isEmpty { return }
        slots = incoming
        onReload?()
    }

    /// Force the backend to push/pull pending changes (the app calls this on background).
    func flush() { backend.flush() }

    func save(_ preset: Preset, toIndex index: Int) {
        var p = preset
        p.thumbnail = nil   // thumbnails render locally per device — never let one bloat the KVS payload
        slots.fill(index: index, preset: p, userOwned: true); persist()
    }
    func clear(slotID: String) { slots.clear(slotID: slotID); persist() }
    func pin(_ slotID: String) { slots.pin(slotID); persist() }
    func unpin(_ slotID: String) { slots.unpin(slotID); persist() }
    func togglePin(_ slotID: String) { slots.isPinned(slotID) ? slots.unpin(slotID) : slots.pin(slotID); persist() }
    func resetToFactory() { slots.resetToFactory(factory); persist() }
    func keepOnlyPinned() { slots.keepOnlyPinned(); persist() }

    /// Returns overflow customs that didn't fit (caller exports them).
    @discardableResult func migrate(customs: [Preset]) -> [Preset] {
        let overflow = slots.migrate(customs: customs); persist(); return overflow
    }

    private func persist() { backend.write(slots.payload) }
}