// // SettingsService.swift // oAI // // Settings persistence: SQLite for preferences, Keychain for API keys // import Foundation import os import Security @Observable class SettingsService { static let shared = SettingsService() // In-memory cache of DB settings for fast reads private var cache: [String: String] = [:] // Keychain keys (secrets only) private enum KeychainKeys { static let openrouterAPIKey = "com.oai.apikey.openrouter" static let anthropicAPIKey = "com.oai.apikey.anthropic" static let openaiAPIKey = "com.oai.apikey.openai" static let googleAPIKey = "com.oai.apikey.google" static let googleSearchEngineID = "com.oai.google.searchEngineID" } private init() { // Load all settings from DB into cache cache = (try? DatabaseService.shared.loadAllSettings()) ?? [:] Log.settings.info("Settings initialized with \(self.cache.count) cached entries") // Migrate from UserDefaults on first launch migrateFromUserDefaultsIfNeeded() } // MARK: - Provider Settings var defaultProvider: Settings.Provider { get { if let raw = cache["defaultProvider"], let provider = Settings.Provider(rawValue: raw) { return provider } return .openrouter } set { cache["defaultProvider"] = newValue.rawValue DatabaseService.shared.setSetting(key: "defaultProvider", value: newValue.rawValue) } } var defaultModel: String? { get { cache["defaultModel"] } set { if let value = newValue { cache["defaultModel"] = value DatabaseService.shared.setSetting(key: "defaultModel", value: value) } else { cache.removeValue(forKey: "defaultModel") DatabaseService.shared.deleteSetting(key: "defaultModel") } } } // MARK: - Model Settings var streamEnabled: Bool { get { cache["streamEnabled"].map { $0 == "true" } ?? true } set { cache["streamEnabled"] = String(newValue) DatabaseService.shared.setSetting(key: "streamEnabled", value: String(newValue)) } } var maxTokens: Int { get { cache["maxTokens"].flatMap(Int.init) ?? 0 } set { cache["maxTokens"] = String(newValue) DatabaseService.shared.setSetting(key: "maxTokens", value: String(newValue)) } } var temperature: Double { get { cache["temperature"].flatMap(Double.init) ?? 0.0 } set { cache["temperature"] = String(newValue) DatabaseService.shared.setSetting(key: "temperature", value: String(newValue)) } } // MARK: - Feature Settings var onlineMode: Bool { get { cache["onlineMode"] == "true" } set { cache["onlineMode"] = String(newValue) DatabaseService.shared.setSetting(key: "onlineMode", value: String(newValue)) } } var memoryEnabled: Bool { get { cache["memoryEnabled"].map { $0 == "true" } ?? true } set { cache["memoryEnabled"] = String(newValue) DatabaseService.shared.setSetting(key: "memoryEnabled", value: String(newValue)) } } var mcpEnabled: Bool { get { cache["mcpEnabled"] == "true" } set { cache["mcpEnabled"] = String(newValue) DatabaseService.shared.setSetting(key: "mcpEnabled", value: String(newValue)) } } // MARK: - Text Size Settings /// GUI text size (headers, labels, buttons) — default 13 var guiTextSize: Double { get { cache["guiTextSize"].flatMap(Double.init) ?? 13.0 } set { cache["guiTextSize"] = String(newValue) DatabaseService.shared.setSetting(key: "guiTextSize", value: String(newValue)) } } /// Dialog/chat message text size — default 14 var dialogTextSize: Double { get { cache["dialogTextSize"].flatMap(Double.init) ?? 14.0 } set { cache["dialogTextSize"] = String(newValue) DatabaseService.shared.setSetting(key: "dialogTextSize", value: String(newValue)) } } /// Input box text size — default 14 var inputTextSize: Double { get { cache["inputTextSize"].flatMap(Double.init) ?? 14.0 } set { cache["inputTextSize"] = String(newValue) DatabaseService.shared.setSetting(key: "inputTextSize", value: String(newValue)) } } // MARK: - MCP Permissions var mcpCanWriteFiles: Bool { get { cache["mcpCanWriteFiles"] == "true" } set { cache["mcpCanWriteFiles"] = String(newValue) DatabaseService.shared.setSetting(key: "mcpCanWriteFiles", value: String(newValue)) } } var mcpCanDeleteFiles: Bool { get { cache["mcpCanDeleteFiles"] == "true" } set { cache["mcpCanDeleteFiles"] = String(newValue) DatabaseService.shared.setSetting(key: "mcpCanDeleteFiles", value: String(newValue)) } } var mcpCanCreateDirectories: Bool { get { cache["mcpCanCreateDirectories"] == "true" } set { cache["mcpCanCreateDirectories"] = String(newValue) DatabaseService.shared.setSetting(key: "mcpCanCreateDirectories", value: String(newValue)) } } var mcpCanMoveFiles: Bool { get { cache["mcpCanMoveFiles"] == "true" } set { cache["mcpCanMoveFiles"] = String(newValue) DatabaseService.shared.setSetting(key: "mcpCanMoveFiles", value: String(newValue)) } } var mcpRespectGitignore: Bool { get { cache["mcpRespectGitignore"].map { $0 == "true" } ?? true } set { cache["mcpRespectGitignore"] = String(newValue) DatabaseService.shared.setSetting(key: "mcpRespectGitignore", value: String(newValue)) } } // MARK: - MCP Allowed Folders var mcpAllowedFolders: [String] { get { guard let json = cache["mcpAllowedFolders"], let data = json.data(using: .utf8), let folders = try? JSONDecoder().decode([String].self, from: data) else { return [] } return folders } set { if let data = try? JSONEncoder().encode(newValue), let json = String(data: data, encoding: .utf8) { cache["mcpAllowedFolders"] = json DatabaseService.shared.setSetting(key: "mcpAllowedFolders", value: json) } } } // MARK: - Search Settings var searchProvider: Settings.SearchProvider { get { if let raw = cache["searchProvider"], let provider = Settings.SearchProvider(rawValue: raw) { return provider } return .duckduckgo } set { cache["searchProvider"] = newValue.rawValue DatabaseService.shared.setSetting(key: "searchProvider", value: newValue.rawValue) } } // MARK: - Ollama Settings var ollamaBaseURL: String { get { cache["ollamaBaseURL"] ?? "" } set { let trimmed = newValue.trimmingCharacters(in: .whitespaces) if trimmed.isEmpty { cache.removeValue(forKey: "ollamaBaseURL") DatabaseService.shared.deleteSetting(key: "ollamaBaseURL") } else { cache["ollamaBaseURL"] = trimmed DatabaseService.shared.setSetting(key: "ollamaBaseURL", value: trimmed) } } } /// Resolved Ollama URL — returns the user value or the default var ollamaEffectiveURL: String { let url = ollamaBaseURL return url.isEmpty ? "http://localhost:11434" : url } /// Whether the user has explicitly configured an Ollama URL var ollamaConfigured: Bool { cache["ollamaBaseURL"] != nil && !(cache["ollamaBaseURL"]!.isEmpty) } // MARK: - API Keys (Keychain) var openrouterAPIKey: String? { get { getKeychainValue(for: KeychainKeys.openrouterAPIKey) } set { if let value = newValue { setKeychainValue(value, for: KeychainKeys.openrouterAPIKey) } else { deleteKeychainValue(for: KeychainKeys.openrouterAPIKey) } } } var anthropicAPIKey: String? { get { getKeychainValue(for: KeychainKeys.anthropicAPIKey) } set { if let value = newValue { setKeychainValue(value, for: KeychainKeys.anthropicAPIKey) } else { deleteKeychainValue(for: KeychainKeys.anthropicAPIKey) } } } var openaiAPIKey: String? { get { getKeychainValue(for: KeychainKeys.openaiAPIKey) } set { if let value = newValue { setKeychainValue(value, for: KeychainKeys.openaiAPIKey) } else { deleteKeychainValue(for: KeychainKeys.openaiAPIKey) } } } var googleAPIKey: String? { get { getKeychainValue(for: KeychainKeys.googleAPIKey) } set { if let value = newValue { setKeychainValue(value, for: KeychainKeys.googleAPIKey) } else { deleteKeychainValue(for: KeychainKeys.googleAPIKey) } } } var googleSearchEngineID: String? { get { getKeychainValue(for: KeychainKeys.googleSearchEngineID) } set { if let value = newValue { setKeychainValue(value, for: KeychainKeys.googleSearchEngineID) } else { deleteKeychainValue(for: KeychainKeys.googleSearchEngineID) } } } // MARK: - UserDefaults Migration private func migrateFromUserDefaultsIfNeeded() { // Skip if already migrated guard cache["_migrated"] == nil else { return } let defaults = UserDefaults.standard let migrations: [(udKey: String, dbKey: String)] = [ ("defaultProvider", "defaultProvider"), ("defaultModel", "defaultModel"), ("streamEnabled", "streamEnabled"), ("maxTokens", "maxTokens"), ("temperature", "temperature"), ("onlineMode", "onlineMode"), ("memoryEnabled", "memoryEnabled"), ("mcpEnabled", "mcpEnabled"), ("searchProvider", "searchProvider"), ("ollamaBaseURL", "ollamaBaseURL"), ] for (udKey, dbKey) in migrations { guard cache[dbKey] == nil else { continue } if let stringVal = defaults.string(forKey: udKey) { cache[dbKey] = stringVal DatabaseService.shared.setSetting(key: dbKey, value: stringVal) } else if defaults.object(forKey: udKey) != nil { // Handle bool/int/double stored as non-string let value: String if let boolVal = defaults.object(forKey: udKey) as? Bool { value = String(boolVal) } else if defaults.integer(forKey: udKey) != 0 { value = String(defaults.integer(forKey: udKey)) } else if defaults.double(forKey: udKey) != 0.0 { value = String(defaults.double(forKey: udKey)) } else { continue } cache[dbKey] = value DatabaseService.shared.setSetting(key: dbKey, value: value) } } // Mark migration complete cache["_migrated"] = "true" DatabaseService.shared.setSetting(key: "_migrated", value: "true") } // MARK: - Keychain Helpers private func getKeychainValue(for key: String) -> String? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: key, kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne ] var dataTypeRef: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &dataTypeRef) guard status == errSecSuccess, let data = dataTypeRef as? Data, let value = String(data: data, encoding: .utf8) else { return nil } return value } private func setKeychainValue(_ value: String, for key: String) { guard let data = value.data(using: .utf8) else { return } let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: key ] let attributes: [String: Any] = [ kSecValueData as String: data ] let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) if updateStatus == errSecItemNotFound { var newItem = query newItem[kSecValueData as String] = data SecItemAdd(newItem as CFDictionary, nil) } } private func deleteKeychainValue(for key: String) { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: key ] SecItemDelete(query as CFDictionary) } }