import Foundation import MusubiCore import Observation /// What is running right now, saved continuously so a restart (or power loss) brings it back. struct LiveState: Codable, Equatable { var local = MachineConfig.empty /// Control only: what each node should show. var nodes: [String: MachineConfig] = [:] var sceneID: UUID? var sceneName: String? /// The live state differs from the saved scene. var modified = false } /// Saved scenes and the live state in ~/Library/Application Support/Musubi. @MainActor @Observable final class SceneStore { private(set) var scenes: [Scene] = [] private let directory: URL private var saveTask: Task? init() { directory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] .appending(path: "Musubi", directoryHint: .isDirectory) try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) scenes = (try? load([Scene].self, from: "scenes.json")) ?? [] } // MARK: Live state func loadLiveState() -> LiveState { (try? load(LiveState.self, from: "live-state.json")) ?? LiveState() } /// Debounced: many small changes (gain slider) end up as one write. func saveLiveState(_ state: LiveState) { saveTask?.cancel() saveTask = Task { [weak self] in try? await Task.sleep(for: .milliseconds(500)) guard !Task.isCancelled else { return } self?.write(state, to: "live-state.json") } } // MARK: Scenes func save(_ scene: Scene) { if let index = scenes.firstIndex(where: { $0.id == scene.id }) { scenes[index] = scene } else { scenes.append(scene) scenes.sort { $0.name.localizedStandardCompare($1.name) == .orderedAscending } } write(scenes, to: "scenes.json") } func delete(_ id: UUID) { scenes.removeAll { $0.id == id } write(scenes, to: "scenes.json") } func scene(_ id: UUID?) -> Scene? { scenes.first { $0.id == id } } /// Imports a scene file; a scene with the same name is replaced. func importScene(from url: URL) throws -> Scene { let accessing = url.startAccessingSecurityScopedResource() defer { if accessing { url.stopAccessingSecurityScopedResource() } } var scene = try JSONDecoder().decode(Scene.self, from: Data(contentsOf: url)) if let existing = scenes.first(where: { $0.name == scene.name }) { scene.id = existing.id } save(scene) return scene } // MARK: Files private func load(_ type: T.Type, from name: String) throws -> T { try JSONDecoder().decode(type, from: Data(contentsOf: directory.appending(path: name))) } private func write(_ value: T, to name: String) { do { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] try encoder.encode(value).write(to: directory.appending(path: name), options: .atomic) } catch { Log.ndi.error("Saving \(name, privacy: .public) failed: \(error.localizedDescription, privacy: .public)") } } }