Zum Inhalt

apple/PhysarumApp/Sources/Model/KVSSlotBackend.swift

import Foundation

/// iCloud key-value backend. Each filled slot → `slot.NN`, the pin list → `pinned`, the version →
/// `seedVersion`. Observes `didChangeExternallyNotification` (another device wrote) and calls
/// `onExternalChange` on the MAIN queue (the model is main-only). Stays under the per-key (1 MB) / account
/// (~1 MB) KVS budget by design (24 small slots ≈ a few KB each).
final class KVSSlotBackend: SlotBackend {
    /// Per-key safety ceiling. A single slot's JSON is a few KB; reject anything pathological (e.g. a
    /// thumbnail accidentally embedded) rather than silently failing the KVS write.
    private static let maxEntryBytes = 512 * 1024

    /// Fallback: if iCloud never posts an initial-sync notification (a truly-empty account, or no
    /// network), treat the local cache as the authoritative state after this delay so seeding/UI is
    /// never blocked forever. Non-authoritative (seed in memory, don't push factory at the cloud).
    private static let initialLoadTimeout: TimeInterval = 6

    private let kv = NSUbiquitousKeyValueStore.default
    var onExternalChange: (() -> Void)?
    var onLoaded: ((Bool) -> Void)?
    private(set) var isLoaded = false
    /// The block-based observer token, removed on deinit so a discarded backend (SwiftUI can
    /// re-evaluate a View's `@State` initializer) never leaves a dead NotificationCenter entry.
    private var observer: NSObjectProtocol?

    init() {
        observer = NotificationCenter.default.addObserver(
            forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification, object: kv, queue: .main
        ) { [weak self] _ in self?.handleChange() }
        kv.synchronize()
        // The initial iCloud download is async and may post no notification on an empty account →
        // unblock seeding after a short grace period (non-authoritative: don't push factory upward).
        DispatchQueue.main.asyncAfter(deadline: .now() + Self.initialLoadTimeout) { [weak self] in
            self?.markLoaded(authoritative: false)
        }
    }

    deinit {
        if let observer { NotificationCenter.default.removeObserver(observer) }
    }

    /// The first external notification means the cloud state is now available (initial load,
    /// authoritative — safe to persist a first-run seed). Subsequent notifications are ongoing edits
    /// from another device → reconcile via `onExternalChange`.
    private func handleChange() {
        if isLoaded { onExternalChange?() } else { markLoaded(authoritative: true) }
    }

    private func markLoaded(authoritative: Bool) {
        guard !isLoaded else { return }
        isLoaded = true
        onLoaded?(authoritative)
    }

    func flush() { kv.synchronize() }

    func read() -> SlotPayload {
        var entries: [SlotPayload.Entry] = []
        for i in 0..<PresetSlots.count {
            guard let data = kv.data(forKey: "slot.\(String(format: "%02d", i))"),
                  let e = try? JSONDecoder().decode(SlotPayload.Entry.self, from: data) else { continue }
            entries.append(e)
        }
        let pinned = (kv.array(forKey: "pinned") as? [String]) ?? []
        return SlotPayload(entries: entries, pinned: pinned, seedVersion: Int(kv.longLong(forKey: "seedVersion")))
    }

    func write(_ payload: SlotPayload) {
        let byIndex = Dictionary(uniqueKeysWithValues: payload.entries.map { ($0.index, $0) })
        for i in 0..<PresetSlots.count {
            let key = "slot.\(String(format: "%02d", i))"
            if let e = byIndex[i], let data = try? JSONEncoder().encode(e) {
                guard data.count <= Self.maxEntryBytes else { continue }   // skip a pathological oversize entry; keep the prior value
                kv.set(data, forKey: key)
            } else {
                kv.removeObject(forKey: key)
            }
        }
        kv.set(payload.pinned, forKey: "pinned")
        kv.set(Int64(payload.seedVersion), forKey: "seedVersion")
        kv.synchronize()
    }
}