import Foundation import Combine /// User-facing settings, persisted to `UserDefaults`. /// /// Phase 0 scope: backend host + TLS-skip, output folder, and adapter toggles. /// The adapter toggles persist but do nothing yet (adapters arrive in Phase 3–4). @MainActor final class AppSettings: ObservableObject { /// Adapters the app will eventually run, in display order. static let adapterKeys: [(key: String, label: String)] = [ ("zoom", "Zoom"), ("teams", "Microsoft Teams"), ("signal", "Signal"), ("meet", "Google Meet"), ] @Published var backendBaseURL: String { didSet { defaults.set(backendBaseURL, forKey: Keys.backendBaseURL) } } @Published var skipTLSVerification: Bool { didSet { defaults.set(skipTLSVerification, forKey: Keys.skipTLS) } } @Published var outputFolderPath: String { didSet { defaults.set(outputFolderPath, forKey: Keys.outputFolder) } } @Published var adapterEnabled: [String: Bool] { didSet { defaults.set(adapterEnabled, forKey: Keys.adapterEnabled) } } @Published var autoRecordOnDetection: Bool { didSet { defaults.set(autoRecordOnDetection, forKey: Keys.autoRecord) } } /// The user's name, pre-seeded into the timeline for mic-VAD "self" spans. @Published var selfName: String { didSet { defaults.set(selfName, forKey: Keys.selfName) } } /// Auto-send a finished recording to the backend for transcription. Default /// off while developing; flip on for hands-free transcripts. @Published var autoSendOnStop: Bool { didSet { defaults.set(autoSendOnStop, forKey: Keys.autoSend) } } /// After transcription, build the readable recap (topic sections + meeting /// extras) via the gateway LLM and write transcript.md / recap.html. Best-effort. @Published var recapEnabled: Bool { didSet { defaults.set(recapEnabled, forKey: Keys.recapEnabled) } } /// Reconcile speaker labels after transcription: merge a person split across /// chunks (same voiceprint) and name placeholder/initial labels from what the /// conversation reveals (gateway LLM). Best-effort. @Published var reconcileSpeakers: Bool { didSet { defaults.set(reconcileSpeakers, forKey: Keys.reconcileSpeakers) } } /// User-editable recap templates (takeaways categories per meeting type). @Published var recapTemplates: [RecapTemplate] { didSet { persist(recapTemplates, forKey: Keys.recapTemplates) } } /// Id of the template used automatically for new recaps. @Published var defaultTemplateId: String { didSet { defaults.set(defaultTemplateId, forKey: Keys.defaultTemplate) } } /// The active default template (falls back to the first available / built-in). var defaultTemplate: RecapTemplate { recapTemplates.first { $0.id == defaultTemplateId } ?? recapTemplates.first ?? .internalMeeting } /// Output folder as a resolved file URL (expands a leading `~`). var outputFolderURL: URL { URL(fileURLWithPath: (outputFolderPath as NSString).expandingTildeInPath, isDirectory: true) } private let defaults: UserDefaults init(defaults: UserDefaults = .standard) { self.defaults = defaults self.backendBaseURL = defaults.string(forKey: Keys.backendBaseURL) ?? "https://your-spark-backend.local:62419" self.skipTLSVerification = defaults.object(forKey: Keys.skipTLS) as? Bool ?? true self.outputFolderPath = defaults.string(forKey: Keys.outputFolder) ?? "~/Ten31Transcripts" let stored = defaults.dictionary(forKey: Keys.adapterEnabled) as? [String: Bool] self.adapterEnabled = stored ?? Dictionary( uniqueKeysWithValues: Self.adapterKeys.map { ($0.key, true) } ) self.autoRecordOnDetection = defaults.object(forKey: Keys.autoRecord) as? Bool ?? true self.selfName = defaults.string(forKey: Keys.selfName) ?? "Me" self.autoSendOnStop = defaults.object(forKey: Keys.autoSend) as? Bool ?? false self.recapEnabled = defaults.object(forKey: Keys.recapEnabled) as? Bool ?? true self.reconcileSpeakers = defaults.object(forKey: Keys.reconcileSpeakers) as? Bool ?? true let loaded = (defaults.data(forKey: Keys.recapTemplates)) .flatMap { try? JSONDecoder().decode([RecapTemplate].self, from: $0) } self.recapTemplates = (loaded?.isEmpty == false) ? loaded! : RecapTemplate.builtIns self.defaultTemplateId = defaults.string(forKey: Keys.defaultTemplate) ?? RecapTemplate.builtIns.first!.id } private func persist(_ value: T, forKey key: String) { if let data = try? JSONEncoder().encode(value) { defaults.set(data, forKey: key) } } private enum Keys { static let backendBaseURL = "backendBaseURL" static let skipTLS = "skipTLSVerification" static let outputFolder = "outputFolderPath" static let adapterEnabled = "adapterEnabled" static let autoRecord = "autoRecordOnDetection" static let selfName = "selfName" static let autoSend = "autoSendOnStop" static let recapEnabled = "recapEnabled" static let reconcileSpeakers = "reconcileSpeakers" static let recapTemplates = "recapTemplates" static let defaultTemplate = "defaultTemplateId" } }