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()
}
}