Preset Slots + iCloud Sync — Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax. Pure-logic tasks (PresetSlots model, factory seeding, pin/rotation, KVS value mapping, migration) are full TDD on the macOS host; the iCloud-KVS backend + entitlements + UI are build-verified and confirmed by device sign-off (cross-device iCloud propagation can't be unit-tested).
Goal: Reorganize presets into a fixed array of editable slots (save-game model) that sync across the user's devices via iCloud key-value store, make save = pick a slot, and make pinned slots drive the ATV's lean-back rotation — with a Reset all → factory escape hatch.
Architecture: A pure PresetSlots value type holds the fixed slot array + pin set + seed version + per-slot userOwned flag, with all logic (fill/overwrite/clear/pin/rotation/seed/reset/migrate) host-testable. A SlotBackend protocol persists it — KVSSlotBackend (iCloud, device-empirical) and a FileSlotBackend offline fallback, with an InMemorySlotBackend for unit tests. SlotStore (@Observable) wires model + backend + external-change observation. AppState swaps its builtIns + customs for the slot model; the library becomes a slot grid, save a slot picker; AmbientController cycles the pinned subset.
Tech Stack: Swift 5, NSUbiquitousKeyValueStore (iCloud KVS), SwiftUI, XCTest (macOS host), XcodeGen.
Spec: docs/superpowers/specs/2026-06-18-preset-slots-icloud-sync-design.md
File map¶
- Create
Sources/Model/PresetSlots.swift— pure slot model + all logic (shared, host-tested). - Create
Sources/Model/FactorySlots.swift— loads the bundled factory manifest (shared, host-tested). - Create
Resources/Factory/factory.json— bundled ordered factory presets (placeholder content; Strand ② curates). - Create
Sources/Model/SlotBackend.swift—SlotBackendprotocol +SlotPayloadCodable +InMemorySlotBackend(shared/test). - Create
Sources/Model/KVSSlotBackend.swift—NSUbiquitousKeyValueStorebackend (device-empirical). - Create
Sources/Model/FileSlotBackend.swift— local JSON fallback backend (host-testable). - Create
Sources/Model/SlotStore.swift—@Observablestore: model + backend + observation (apply logic host-tested). - Modify
Sources/Model/AppState.swift— slots replacebuiltIns + customs; select/save/pin/reset by slot; migration. - Modify
Sources/Ambient/AmbientController.swift— playlist = pinned rotation (derived), not an init snapshot. - Modify
Sources/Library/LibraryView.swift— slot grid + per-slot pin toggle + Reset-all action (read current file first). - Modify
Sources/Library/SaveDialog.swift— save → slot picker (read current file first). - Modify
apple/PhysarumApp/project.yml— per-SDKCODE_SIGN_ENTITLEMENTS; add iCloud-KVS entitlement files. - Create
Physarum.iOS.entitlements,Physarum.tvOS.entitlements— iCloud ubiquity-kvstore entitlement. - Tests:
Tests/PresetSlotsTests.swift,Tests/FactorySlotsTests.swift,Tests/SlotBackendTests.swift,Tests/SlotStoreApplyTests.swift,Tests/SlotMigrationTests.swift.
Slot identity convention (used throughout): slot index 0…23; slot id = String(format: "slot-%02d", index) → slot-00 … slot-23. A filled slot's Preset.id is its slot id. AppState.selectedID stays a String (a slot id), so the T2a remote protocol (RemoteState.selectedID) is unaffected.
Task 1: PresetSlots model — fill / overwrite / clear / userOwned¶
Files: Create Sources/Model/PresetSlots.swift; Test Tests/PresetSlotsTests.swift.
- [ ] Step 1: Failing test
import XCTest
import PhysarumEngine
@testable import Physarum
final class PresetSlotsTests: XCTestCase {
private func preset(_ name: String) -> Preset {
var p = PresetCatalog.fallbackPreset; p.name = name; p.builtIn = false; return p
}
func testFillAssignsSlotIdAndIsNotUserOwned() {
var s = PresetSlots()
XCTAssertEqual(s.count, 24)
s.fill(index: 3, preset: preset("Aurora"), userOwned: false)
XCTAssertEqual(s.preset(at: 3)?.id, "slot-03")
XCTAssertEqual(s.preset(at: 3)?.name, "Aurora")
XCTAssertFalse(s.isUserOwned(3))
XCTAssertEqual(s.filled.map { $0.id }, ["slot-03"])
}
func testOverwriteMarksUserOwnedAndKeepsSlotId() {
var s = PresetSlots()
s.fill(index: 3, preset: preset("Aurora"), userOwned: false)
s.overwrite(slotID: "slot-03", preset: preset("Mine"))
XCTAssertEqual(s.preset(at: 3)?.id, "slot-03")
XCTAssertEqual(s.preset(at: 3)?.name, "Mine")
XCTAssertTrue(s.isUserOwned(3))
}
func testClearEmptiesSlot() {
var s = PresetSlots()
s.fill(index: 3, preset: preset("Aurora"), userOwned: true)
s.clear(slotID: "slot-03")
XCTAssertNil(s.preset(at: 3))
XCTAssertFalse(s.isUserOwned(3))
XCTAssertTrue(s.filled.isEmpty)
}
func testFirstEmptyIndexFindsLowestGap() {
var s = PresetSlots()
s.fill(index: 0, preset: preset("A"), userOwned: false)
s.fill(index: 1, preset: preset("B"), userOwned: false)
XCTAssertEqual(s.firstEmptyIndex, 2)
}
}
-
[ ] Step 2: Run → fails (
PresetSlotsundefined).xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/PresetSlotsTests. -
[ ] Step 3: Implement
PresetSlots.swift
import PhysarumEngine
/// The fixed, save-game-style preset store: 24 slots, each empty or holding one Preset, plus the pinned
/// set, the seed version, and a per-slot `userOwned` flag (false while a slot tracks the shipped default,
/// true once the user has saved into it — that flag is what makes "an update never overwrites your edits"
/// work). Pure value type: no persistence, no iCloud — `SlotStore` adds those. Host-testable.
struct PresetSlots: Equatable {
static let count = 24
static func slotID(_ index: Int) -> String { String(format: "slot-%02d", index) }
static func index(of slotID: String) -> Int? { Int(slotID.dropFirst("slot-".count)) }
private struct Slot: Equatable { var preset: Preset?; var userOwned: Bool }
private var slots: [Slot]
private(set) var pinned: Set<String> = []
private(set) var seedVersion: Int = 0
init() { slots = Array(repeating: Slot(preset: nil, userOwned: false), count: Self.count) }
var count: Int { slots.count }
func preset(at index: Int) -> Preset? { slots.indices.contains(index) ? slots[index].preset : nil }
func isUserOwned(_ index: Int) -> Bool { slots.indices.contains(index) ? slots[index].userOwned : false }
/// The filled presets, in slot order (this is what the library shows and selection resolves against).
var filled: [Preset] { slots.compactMap { $0.preset } }
var firstEmptyIndex: Int? { slots.firstIndex { $0.preset == nil } }
mutating func fill(index: Int, preset: Preset, userOwned: Bool) {
guard slots.indices.contains(index) else { return }
var p = preset; p.id = Self.slotID(index) // a slot's preset id IS its slot id (stable identity)
slots[index] = Slot(preset: p, userOwned: userOwned)
}
mutating func overwrite(slotID: String, preset: Preset) {
guard let i = Self.index(of: slotID) else { return }
fill(index: i, preset: preset, userOwned: true) // a user save always marks the slot owned
}
mutating func clear(slotID: String) {
guard let i = Self.index(of: slotID), slots.indices.contains(i) else { return }
slots[i] = Slot(preset: nil, userOwned: false)
pinned.remove(slotID)
}
}
- [ ] Step 4: Run → passes.
- [ ] Step 5: Commit
feat(slots): PresetSlots model — fill/overwrite/clear with userOwned.
Task 2: Pinning + rotation¶
Files: Modify Sources/Model/PresetSlots.swift; Test add to Tests/PresetSlotsTests.swift.
- [ ] Step 1: Failing test (append)
extension PresetSlotsTests {
func testPinUnpinAndRotation() {
var s = PresetSlots()
s.fill(index: 0, preset: preset("A"), userOwned: false)
s.fill(index: 1, preset: preset("B"), userOwned: false)
s.fill(index: 2, preset: preset("C"), userOwned: false)
XCTAssertEqual(s.rotation.map { $0.id }, ["slot-00", "slot-01", "slot-02"]) // nothing pinned → all filled
s.pin("slot-02"); s.pin("slot-00")
XCTAssertTrue(s.isPinned("slot-00"))
XCTAssertEqual(s.rotation.map { $0.id }, ["slot-00", "slot-02"]) // pinned subset, in slot order
s.unpin("slot-00")
XCTAssertEqual(s.rotation.map { $0.id }, ["slot-02"])
}
func testPinIsIdempotentAndClearingRemovesPin() {
var s = PresetSlots()
s.fill(index: 0, preset: preset("A"), userOwned: true)
s.pin("slot-00"); s.pin("slot-00")
XCTAssertEqual(s.pinned.count, 1)
s.clear(slotID: "slot-00")
XCTAssertFalse(s.isPinned("slot-00")) // clearing a slot drops its pin
XCTAssertTrue(s.rotation.isEmpty)
}
}
-
[ ] Step 2: Run → fails (
pin/rotationundefined). -
[ ] Step 3: Implement (add to
PresetSlots)
func isPinned(_ slotID: String) -> Bool { pinned.contains(slotID) }
mutating func pin(_ slotID: String) { if preset(atSlotID: slotID) != nil { pinned.insert(slotID) } }
mutating func unpin(_ slotID: String) { pinned.remove(slotID) }
private func preset(atSlotID id: String) -> Preset? { Self.index(of: id).flatMap { preset(at: $0) } }
/// The ATV lean-back rotation: the pinned filled slots in slot order; nothing pinned → all filled.
var rotation: [Preset] {
let pins = slots.compactMap { $0.preset }.filter { pinned.contains($0.id) }
return pins.isEmpty ? filled : pins
}
- [ ] Step 4: Run → passes.
- [ ] Step 5: Commit
feat(slots): pinning + ATV rotation (pinned subset, empty → all).
Task 3: Seeding, version-gated updates, and factory reset¶
Files: Modify Sources/Model/PresetSlots.swift; Test add to Tests/PresetSlotsTests.swift.
Factory is the seed input: an ordered list of presets (slot 0..k-1) + a version + the factory pin ids.
- [ ] Step 1: Failing test (append)
extension PresetSlotsTests {
private func factory(_ names: [String], version: Int, pins: Set<String> = []) -> PresetSlots.Factory {
.init(presets: names.map { preset($0) }, version: version, pinnedSlotIDs: pins)
}
func testSeedFillsEmptyOnFirstRun() {
var s = PresetSlots()
s.seed(factory(["A", "B"], version: 1))
XCTAssertEqual(s.filled.map { $0.name }, ["A", "B"])
XCTAssertEqual(s.seedVersion, 1)
XCTAssertFalse(s.isUserOwned(0)) // seeded slots track the default
}
func testSeedUpdateSkipsUserOwnedButFillsUntouched() {
var s = PresetSlots()
s.seed(factory(["A", "B"], version: 1))
s.overwrite(slotID: "slot-00", preset: preset("Mine")) // user edits slot 0 → owned
s.seed(factory(["A2", "B2", "C2"], version: 2)) // newer factory
XCTAssertEqual(s.preset(at: 0)?.name, "Mine") // user-owned slot untouched
XCTAssertEqual(s.preset(at: 1)?.name, "B2") // untouched slot takes new default
XCTAssertEqual(s.preset(at: 2)?.name, "C2") // new default fills empty slot
XCTAssertEqual(s.seedVersion, 2)
}
func testSeedNoOpWhenVersionNotNewer() {
var s = PresetSlots()
s.seed(factory(["A"], version: 2))
s.seed(factory(["X"], version: 2)) // same version → no change
XCTAssertEqual(s.preset(at: 0)?.name, "A")
}
func testResetToFactoryClearsUserOwnedAndPins() {
var s = PresetSlots()
s.seed(factory(["A", "B"], version: 1, pins: ["slot-00"]))
s.overwrite(slotID: "slot-00", preset: preset("Mine"))
s.pin("slot-00")
s.resetToFactory(factory(["A", "B"], version: 1, pins: ["slot-01"]))
XCTAssertEqual(s.preset(at: 0)?.name, "A") // back to factory
XCTAssertFalse(s.isUserOwned(0)) // ownership cleared
XCTAssertEqual(s.pinned, ["slot-01"]) // pins reset to factory pins
}
}
-
[ ] Step 2: Run → fails (
Factory/seed/resetToFactoryundefined). -
[ ] Step 3: Implement (add to
PresetSlots)
struct Factory: Equatable {
var presets: [Preset] // slot 0..presets.count-1
var version: Int
var pinnedSlotIDs: Set<String> = []
}
/// First run (empty) or an update with a newer factory version: write each factory preset into its
/// slot ONLY if that slot is empty or not user-owned. User-owned slots are sacrosanct. Pins are only
/// applied on the very first seed (when seedVersion was 0), so an update never re-pins behind the user.
mutating func seed(_ factory: Factory) {
guard factory.version > seedVersion else { return }
let firstSeed = seedVersion == 0
for (i, preset) in factory.presets.enumerated() where slots.indices.contains(i) {
if slots[i].preset == nil || !slots[i].userOwned {
fill(index: i, preset: preset, userOwned: false)
}
}
if firstSeed { pinned = factory.pinnedSlotIDs.filter { preset(atSlotID: $0) != nil } }
seedVersion = factory.version
}
/// Deliberate factory reset (the "Reset all Presets" button): every slot back to its factory default
/// (or empty), ownership cleared, pins back to factory pins.
mutating func resetToFactory(_ factory: Factory) {
slots = Array(repeating: Slot(preset: nil, userOwned: false), count: Self.count)
for (i, preset) in factory.presets.enumerated() where slots.indices.contains(i) {
fill(index: i, preset: preset, userOwned: false)
}
pinned = factory.pinnedSlotIDs.filter { preset(atSlotID: $0) != nil }
seedVersion = factory.version
}
- [ ] Step 4: Run → passes.
- [ ] Step 5: Commit
feat(slots): version-gated seeding + factory reset (userOwned-respecting).
Task 4: Migrate existing custom presets into empty slots¶
Files: Modify Sources/Model/PresetSlots.swift; Test Tests/SlotMigrationTests.swift.
- [ ] Step 1: Failing test
import XCTest
import PhysarumEngine
@testable import Physarum
final class SlotMigrationTests: XCTestCase {
private func preset(_ name: String) -> Preset {
var p = PresetCatalog.fallbackPreset; p.name = name; p.builtIn = false; return p
}
func testMigrateFillsEmptySlotsAndMarksUserOwned() {
var s = PresetSlots()
s.seed(.init(presets: [preset("Factory0"), preset("Factory1")], version: 1))
let overflow = s.migrate(customs: [preset("Old A"), preset("Old B")])
XCTAssertEqual(s.preset(at: 2)?.name, "Old A") // first empty after the 2 factory slots
XCTAssertEqual(s.preset(at: 3)?.name, "Old B")
XCTAssertTrue(s.isUserOwned(2)) // migrated customs are the user's
XCTAssertTrue(overflow.isEmpty)
}
func testMigrateReturnsOverflowWhenNoRoom() {
var s = PresetSlots()
// Fill all 24 slots so there is no room.
for i in 0..<PresetSlots.count { s.fill(index: i, preset: preset("F\(i)"), userOwned: false) }
let overflow = s.migrate(customs: [preset("Homeless")])
XCTAssertEqual(overflow.map { $0.name }, ["Homeless"]) // returned, not lost
}
}
-
[ ] Step 2: Run → fails (
migrateundefined). -
[ ] Step 3: Implement (add to
PresetSlots)
/// One-shot migration of the pre-slot custom presets into the lowest empty slots (marked userOwned so
/// an update never clobbers them). Returns any that didn't fit — the caller exports them rather than
/// dropping the user's work silently.
mutating func migrate(customs: [Preset]) -> [Preset] {
var overflow: [Preset] = []
for custom in customs {
if let i = firstEmptyIndex { fill(index: i, preset: custom, userOwned: true) }
else { overflow.append(custom) }
}
return overflow
}
- [ ] Step 4: Run → passes.
- [ ] Step 5: Commit
feat(slots): migrate existing customs into empty slots (overflow returned).
Task 5: Factory manifest + FactorySlots loader¶
Files: Create Resources/Factory/factory.json, Sources/Model/FactorySlots.swift; Test Tests/FactorySlotsTests.swift. Modify project.yml (bundle the new resource folder).
The factory manifest is a bundled, ordered list of presets + a version. Placeholder content for Strand ①: reuse the current bundled presets (a chosen 24-cut, in a chosen order); Strand ② replaces the contents. The loader is content-agnostic.
-
[ ] Step 1: Author
Resources/Factory/factory.jsonas{ "version": 1, "pinnedSlotIDs": [], "presets": [ <up to 24 Preset JSON objects, in slot order> ] }. For the placeholder, copy the JSON bodies of the 24 currentResources/Presets/*.jsonyou judge best/most varied intopresets(Strand ② curates later). Each entry is a fullPresetJSON (the same schemaPresetCatalogdecodes). -
[ ] Step 2: Failing test
import XCTest
import PhysarumEngine
@testable import Physarum
final class FactorySlotsTests: XCTestCase {
func testLoadsBundledFactoryManifest() {
let f = FactorySlots.load()
XCTAssertGreaterThan(f.version, 0)
XCTAssertFalse(f.presets.isEmpty)
XCTAssertLessThanOrEqual(f.presets.count, PresetSlots.count) // never more presets than slots
}
}
- [ ] Step 3: Implement
FactorySlots.swift
import Foundation
import PhysarumEngine
/// Loads the bundled factory manifest (`Resources/Factory/factory.json`) into a `PresetSlots.Factory`.
/// Content-agnostic: Strand ② edits the manifest, not this loader. Falls back to an empty (version-0)
/// factory if the resource is missing, so the app still launches.
enum FactorySlots {
private struct Manifest: Decodable { var version: Int; var pinnedSlotIDs: [String]; var presets: [Preset] }
static func load(bundle: Bundle = .main) -> PresetSlots.Factory {
guard let url = bundle.url(forResource: "factory", withExtension: "json", subdirectory: "Factory")
?? bundle.url(forResource: "factory", withExtension: "json"),
let data = try? Data(contentsOf: url),
let m = try? JSONDecoder().decode(Manifest.self, from: data)
else { return .init(presets: [], version: 0) }
let presets = Array(m.presets.prefix(PresetSlots.count)) // never exceed the slot count
return .init(presets: presets, version: m.version, pinnedSlotIDs: Set(m.pinnedSlotIDs))
}
}
-
[ ] Step 4: Add the resource to
project.ymltargets.Physarum.sources(folder reference, likeResources/Presets):- path: Resources/Factorywithtype: folder. Runxcodegen generate. -
[ ] Step 5: Run → passes. Commit
feat(slots): bundled factory manifest + FactorySlots loader.
Task 6: SlotBackend protocol + payload mapping + InMemorySlotBackend¶
Files: Create Sources/Model/SlotBackend.swift; Test Tests/SlotBackendTests.swift.
The backend persists a flat SlotPayload (per-slot JSON + pins + seedVersion). PresetSlots ⇄ SlotPayload is pure and host-tested; InMemorySlotBackend lets SlotStore be unit-tested without iCloud.
- [ ] Step 1: Failing test
import XCTest
import PhysarumEngine
@testable import Physarum
final class SlotBackendTests: XCTestCase {
private func preset(_ name: String) -> Preset {
var p = PresetCatalog.fallbackPreset; p.name = name; p.builtIn = false; return p
}
func testPayloadRoundTripPreservesSlotsPinsVersion() {
var s = PresetSlots()
s.seed(.init(presets: [preset("A"), preset("B")], version: 3, pinnedSlotIDs: ["slot-01"]))
s.overwrite(slotID: "slot-00", preset: preset("Mine"))
let restored = PresetSlots(payload: s.payload)
XCTAssertEqual(restored, s)
}
func testInMemoryBackendPersists() {
let backend = InMemorySlotBackend()
var s = PresetSlots()
s.fill(index: 5, preset: preset("E"), userOwned: true)
backend.write(s.payload)
XCTAssertEqual(PresetSlots(payload: backend.read()), s)
}
}
-
[ ] Step 2: Run → fails (
payload/SlotPayload/InMemorySlotBackendundefined). -
[ ] Step 3: Implement
SlotBackend.swift
import PhysarumEngine
/// The flat, codable mirror of a `PresetSlots` — one entry per filled slot, plus pins and seed version.
/// This is exactly what a backend writes/reads (each backend maps it to its medium: KVS keys, a JSON file).
struct SlotPayload: Codable, Equatable {
struct Entry: Codable, Equatable { var index: Int; var preset: Preset; var userOwned: Bool }
var entries: [Entry] = []
var pinned: [String] = []
var seedVersion: Int = 0
}
extension PresetSlots {
var payload: SlotPayload {
var entries: [SlotPayload.Entry] = []
for i in 0..<count where preset(at: i) != nil {
entries.append(.init(index: i, preset: preset(at: i)!, userOwned: isUserOwned(i)))
}
return SlotPayload(entries: entries, pinned: pinned.sorted(), seedVersion: seedVersion)
}
init(payload: SlotPayload) {
self.init()
for e in payload.entries { fill(index: e.index, preset: e.preset, userOwned: e.userOwned) }
seedVersion = payload.seedVersion
for id in payload.pinned { pin(id) }
}
}
/// Persistence boundary. `SlotStore` talks only to this; tests inject `InMemorySlotBackend`, the app uses
/// `KVSSlotBackend` (iCloud) with a `FileSlotBackend` fallback. `onExternalChange` fires when another
/// device updates the store (KVS notification); it is a no-op for the in-memory/file backends.
protocol SlotBackend: AnyObject {
func read() -> SlotPayload
func write(_ payload: SlotPayload)
var onExternalChange: (() -> Void)? { get set }
}
final class InMemorySlotBackend: SlotBackend {
private var stored = SlotPayload()
var onExternalChange: (() -> Void)?
func read() -> SlotPayload { stored }
func write(_ payload: SlotPayload) { stored = payload }
}
- [ ] Step 4: Run → passes.
- [ ] Step 5: Commit
feat(slots): SlotPayload mapping + SlotBackend protocol + in-memory backend.
Task 7: SlotStore — model + backend + external-change reconcile¶
Files: Create Sources/Model/SlotStore.swift; Test Tests/SlotStoreApplyTests.swift.
SlotStore owns a PresetSlots, persists via a SlotBackend, seeds from the factory on init, and reloads on an external change. The mutating ops + the seed-on-init are host-tested with InMemorySlotBackend.
- [ ] Step 1: Failing test
import XCTest
import PhysarumEngine
@testable import Physarum
final class SlotStoreApplyTests: XCTestCase {
private func preset(_ name: String) -> Preset {
var p = PresetCatalog.fallbackPreset; p.name = name; p.builtIn = false; return p
}
private func factory(_ names: [String], _ v: Int) -> PresetSlots.Factory {
.init(presets: names.map { preset($0) }, version: v)
}
func testInitSeedsFactoryIntoBackend() {
let backend = InMemorySlotBackend()
let store = SlotStore(backend: backend, factory: factory(["A", "B"], 1))
XCTAssertEqual(store.slots.filled.map { $0.name }, ["A", "B"])
XCTAssertEqual(PresetSlots(payload: backend.read()).filled.count, 2) // persisted
}
func testSaveOverwritePersistsAndMarksOwned() {
let backend = InMemorySlotBackend()
let store = SlotStore(backend: backend, factory: factory(["A", "B"], 1))
store.save(preset("Mine"), toIndex: 0)
XCTAssertEqual(store.slots.preset(at: 0)?.name, "Mine")
XCTAssertTrue(store.slots.isUserOwned(0))
XCTAssertEqual(PresetSlots(payload: backend.read()).preset(at: 0)?.name, "Mine")
}
func testExternalChangeReloadsFromBackend() {
let backend = InMemorySlotBackend()
let store = SlotStore(backend: backend, factory: factory(["A"], 1))
// Simulate another device writing a new slot, then the KVS notification firing.
var other = PresetSlots(payload: backend.read())
other.fill(index: 5, preset: preset("FromPhone"), userOwned: true)
backend.write(other.payload)
store.reloadFromBackend()
XCTAssertEqual(store.slots.preset(at: 5)?.name, "FromPhone")
}
func testResetToFactory() {
let backend = InMemorySlotBackend()
let store = SlotStore(backend: backend, factory: factory(["A", "B"], 1))
store.save(preset("Mine"), toIndex: 0)
store.resetToFactory()
XCTAssertEqual(store.slots.preset(at: 0)?.name, "A")
XCTAssertFalse(store.slots.isUserOwned(0))
}
}
-
[ ] Step 2: Run → fails (
SlotStoreundefined). -
[ ] Step 3: Implement
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
init(backend: SlotBackend, factory: PresetSlots.Factory) {
self.backend = backend
self.factory = factory
var model = PresetSlots(payload: backend.read())
model.seed(factory) // first run fills empty; updates fill untouched; no-op if not newer
self.slots = model
backend.write(model.payload)
backend.onExternalChange = { [weak self] in self?.reloadFromBackend() }
}
func reloadFromBackend() { slots = PresetSlots(payload: backend.read()); persist() }
func save(_ preset: Preset, toIndex index: Int) {
slots.fill(index: index, preset: preset, 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() }
/// 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) }
}
- [ ] Step 4: Run → passes.
- [ ] Step 5: Commit
feat(slots): SlotStore — model + backend + external-change reconcile.
Task 8: iCloud KVS backend + local file fallback (device-empirical; build-verified)¶
Files: Create Sources/Model/KVSSlotBackend.swift, Sources/Model/FileSlotBackend.swift. Test (file backend only) add to Tests/SlotBackendTests.swift.
The KVS path needs a real iCloud account → no unit test; gate = it compiles on all SDKs + Task 12 sign-off. The file backend IS host-testable (temp dir).
- [ ] Step 1: Failing test (file backend round-trip, temp dir)
extension SlotBackendTests {
func testFileBackendRoundTrips() {
let url = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("slots-\(UUID().uuidString).json")
let backend = FileSlotBackend(url: url)
var s = PresetSlots(); s.fill(index: 2, preset: preset("F"), userOwned: true)
backend.write(s.payload)
XCTAssertEqual(PresetSlots(payload: FileSlotBackend(url: url).read()), s)
}
}
-
[ ] Step 2: Run → fails.
-
[ ] Step 3: Implement
FileSlotBackend.swift(local JSON, the offline fallback)
import Foundation
/// Local JSON persistence — the fallback when iCloud is unavailable / the user isn't signed in. No
/// external-change source (single-device), so `onExternalChange` is never called.
final class FileSlotBackend: SlotBackend {
private let url: URL
var onExternalChange: (() -> Void)?
init(url: URL) { self.url = url }
func read() -> SlotPayload {
(try? JSONDecoder().decode(SlotPayload.self, from: Data(contentsOf: url))) ?? SlotPayload()
}
func write(_ payload: SlotPayload) { try? JSONEncoder().encode(payload).write(to: url) }
}
- [ ] Step 4: Implement
KVSSlotBackend.swift(iCloud; build-verified)
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 main. Falls under the per-key (1 MB) KVS budget by design (24 small slots).
final class KVSSlotBackend: SlotBackend {
private let kv = NSUbiquitousKeyValueStore.default
var onExternalChange: (() -> Void)?
init() {
NotificationCenter.default.addObserver(
forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification, object: kv, queue: .main
) { [weak self] _ in self?.onExternalChange?() }
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) { 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()
}
}
- [ ] Step 5: macOS host suite green (file backend test passes); the KVS backend compiles. Commit
feat(slots): iCloud KVS backend + local file fallback.
Task 9: Wire SlotStore into AppState (slots replace builtIns + customs)¶
Files: Modify Sources/Model/AppState.swift; update Tests/PresetStoreTests.swift / Tests/AppStateResetTests.swift as needed. Read AppState.swift in full first.
This is the integration. Replace builtIns + customs with the slot model and route save/select/pin/reset through SlotStore. Keep edited/baseline/undo/redo/thumbnails behavior.
- [ ] Step 1: Add an injectable
SlotStoretoAppState.init(default: real backend; tests injectInMemorySlotBackend):
init(slotStore: SlotStore? = nil, store: PresetStore = PresetStore(), audioStore: AudioPresetStore = AudioPresetStore()) {
self.store = store
self.audioStore = audioStore
let factory = FactorySlots.load()
let backend: SlotBackend = slotStore == nil ? KVSSlotBackend() : InMemorySlotBackend()
self.slotStore = slotStore ?? SlotStore(backend: backend, factory: factory)
// … existing audio loads …
let first = self.slotStore.slots.filled.first ?? PresetCatalog.fallbackPreset
selectedID = first.id; edited = first.params; baseline = first.params
migrateLegacyCustomsIfNeeded() // Task 10
}
- [ ] Step 2: Replace
presets,select, save/pin/reset to useslotStore:
var presets: [Preset] { slotStore.slots.filled } // filled slots, in order
var pinned: Set<String> { slotStore.slots.pinned }
func isPinned(_ id: String) -> Bool { slotStore.slots.isPinned(id) }
func togglePin(_ id: String) { slotStore.togglePin(id) }
func resetAllToFactory() { slotStore.resetToFactory(); if let f = presets.first { select(id: f.id) } }
func saveToSlot(index: Int, name: String, tags: [String], tagSignal: SignalName) {
var p = selectedPreset // start from the live preset shell
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) // SlotStore stamps p.id = slot id
let id = PresetSlots.slotID(index)
selectedID = id; baseline = edited
generateThumbnail(for: id, params: edited)
}
select(id:) is unchanged (it resolves against presets, which is now the filled slots). advance(by:) is unchanged (wraps over presets). Delete builtIns, customs, saveAsNew, saveOverwrite, deleteCustom, rename, moveCustom, importPreset, selectedIsCustom — replaced by slot operations (saveToSlot, slotStore.clear, togglePin, resetAllToFactory). Keep paramsEqual, isModified, generateThumbnail/backfillThumbnails (now keyed by slot id; thumbnails stay device-local).
-
[ ] Step 3: Fix the test suite: change
AppState(store: …)factories toAppState(slotStore: SlotStore(backend: InMemorySlotBackend(), factory: …)); replacesaveAsNew/deleteCustomassertions withsaveToSlot/clear. Run the full macOS suite. -
[ ] Step 4: macOS build + full suite green. Commit
feat(slots): AppState uses the slot model (save/select/pin/reset by slot).
Task 10: Legacy custom-preset migration on first launch¶
Files: Modify Sources/Model/AppState.swift; covered by Tests/SlotMigrationTests.swift (model-level) — add an AppState-level guard test.
- [ ] Step 1: Implement
migrateLegacyCustomsIfNeeded()— once (guard aUserDefaultsflagslotMigrationDone), load the oldstore.loadCustom(),slotStore.migrate(customs:), write overflow to aLegacyPresets-export.jsonin App-Support, set the flag.
private func migrateLegacyCustomsIfNeeded() {
let key = "slotMigrationDone"
guard !UserDefaults.standard.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) // add this URL to PresetStore
}
}
UserDefaults.standard.set(true, forKey: key)
}
- [ ] Step 2: Add
PresetStore.legacyExportURL(App-Support/Physarum/LegacyPresets-export.json). Run the suite. - [ ] Step 3: Commit
feat(slots): one-shot migration of legacy customs into slots (overflow exported).
Task 11: ATV rotation = pinned subset; UI (slot grid, slot picker, reset); entitlements¶
Files: Modify Sources/Ambient/AmbientController.swift, Sources/TV/TVRootView.swift, Sources/App/RootView.swift, Sources/Library/LibraryView.swift, Sources/Library/SaveDialog.swift, project.yml; create Physarum.iOS.entitlements, Physarum.tvOS.entitlements. Read each UI file in full before editing.
UI + entitlements are build-verified across iOS/tvOS/macOS sims; behavior is Task 12 sign-off.
-
[ ] Step 1: Rotation = pinned subset.
AmbientController.playlistcurrently a captured[Preset]. Change it to derive from the model so pinning takes effect live: store theAppState(already held) and makeplaylista computedappState.slotStore.slots.rotation(pinned filled, empty → all).currentIndexand thePlaylistDirector.nextIndex(count:)calls then operate on the rotation. UpdateTVRootView.initto no longer pass a snapshot (AmbientController(appState:)), and the iOSRootView.startCycle/AppState.advance(by:)to cycleslotStore.slots.rotationrather than the fullpresets. AddAmbientControllerTests/PlaylistDirectorTestscoverage for "rotation follows pins" (host-testable: build a model, pin a subset, assert the controller advances only over pinned). -
[ ] Step 2: Library = slot grid + pin toggle + Reset. In
LibraryView, render the 24 slots as a grid: filled =PresetCard(thumbnail viaPresetCatalog.thumbnailURL(for: id, store:), name) with a pin star (isPinned(id)→togglePin(id)); empty = a "+ Empty" cell. Tapping a filled slot →state.select(id:). Add a destructive "Reset all Presets" button → confirmation alert ("Are you sure? This will reset all your presets to factory settings.") →state.resetAllToFactory(). Remove the built-in/custom sections + the rename/reorder/delete context menu (replaced by: clear = long-press/Delete on a slot →slotStore.clear(slotID:); reorder is gone — slots are positional). -
[ ] Step 3: Save → slot picker. In
SaveDialog, replace save-as-new/overwrite with a slot grid: pick a slot (filled shows its name + an overwrite confirmation, empty fills) →state.saveToSlot(index:name:tags:tagSignal:). -
[ ] Step 4: Entitlements. Create
Physarum.iOS.entitlements+Physarum.tvOS.entitlements:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)de.jkaindl.physarum</string>
</dict></plist>
In project.yml targets.Physarum.settings.base add per-SDK:
"CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]": Physarum.iOS.entitlements
"CODE_SIGN_ENTITLEMENTS[sdk=appletvos*]": Physarum.tvOS.entitlements
(macOS keeps Physarum.macOS.entitlements; do not add the kvstore key there yet — the Developer-ID/notarized Mac build needs an embedded iCloud provisioning profile, deferred per the spec. iPhone/iPad/ATV sync ships first.) Regenerate.
- [ ] Step 5: iOS-sim + tvOS-sim + macOS builds compile; macOS host suite green. Commit
feat(slots): pinned rotation + slot-grid library + slot-picker save + Reset + iCloud entitlements.
Task 12: Gates + device sign-off¶
- [ ] Full macOS host suite green (
-destination 'platform=macOS'); all new SlotStore/PresetSlots/migration tests pass. - [ ] iOS-sim + tvOS-sim + macOS builds green.
- [ ] Release deploy to an iPhone + iPad + the ATV (Wohnzimmer
00008110-000170C80A30A01E), all signed into the same iCloud account. - [ ] Device sign-off (Johannes, same iCloud account): save a preset into a slot on the phone → it appears on the iPad + ATV; pin a slot → the ATV's lean-back rotation changes to the pinned set (nothing pinned → cycles all); edit a slot → the edit syncs; Reset all Presets → factory restores defaults on every device; works offline then syncs on reconnect. (KVS propagation is the device-only validation; the model/seeding/pin/migration logic is covered by host tests.)
Self-Review¶
Spec coverage: slot model + ids → T1; pinning → T2; seed/update/reset → T3; migration → T4/T10; factory manifest → T5; payload mapping + backend protocol → T6; SlotStore reconcile → T7; KVS backend + file fallback → T8; AppState integration → T9; rotation + UI + entitlements → T11; gates + sign-off → T12. All spec sections covered. Placeholders: the factory.json content is an explicit Strand-② placeholder (mechanism is complete); the macOS-iCloud deferral is a spec-sanctioned decision, not a vague TODO. Type consistency: PresetSlots.slotID/index, Factory, payload/init(payload:), SlotBackend.read/write/onExternalChange, SlotStore.save(_:toIndex:)/togglePin/resetToFactory/migrate, AppState.saveToSlot/togglePin/resetAllToFactory are defined where first used and reused consistently. Device-empirical tasks (T8 KVS, T11 entitlements/UI) are intentionally build-verified; the pure core (T1–T7, T10 model) carries the host coverage — the same split that worked for T2a.