// // SettingsService.swift // oAI // // Settings persistence: SQLite for preferences, Keychain for API keys // // SPDX-License-Identifier: AGPL-3.0-or-later // Copyright (C) 2026 Rune Olsen // // This file is part of oAI. // // oAI is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // oAI is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General // Public License for more details. // // You should have received a copy of the GNU Affero General Public // License along with oAI. If not, see . 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] = [:] // Encrypted database keys (for API keys) private enum EncryptedKeys { static let openrouterAPIKey = "openrouterAPIKey" static let anthropicAPIKey = "anthropicAPIKey" static let openaiAPIKey = "openaiAPIKey" static let googleAPIKey = "googleAPIKey" static let googleSearchEngineID = "googleSearchEngineID" static let anytypeMcpAPIKey = "anytypeMcpAPIKey" } // Old keychain keys (for migration 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() // Migrate API keys from Keychain to encrypted database migrateFromKeychainIfNeeded() } // 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)) } } var systemPrompt: String? { get { cache["systemPrompt"] } set { if let value = newValue, !value.isEmpty { cache["systemPrompt"] = value DatabaseService.shared.setSetting(key: "systemPrompt", value: value) } else { cache.removeValue(forKey: "systemPrompt") DatabaseService.shared.deleteSetting(key: "systemPrompt") } } } var customPromptMode: Settings.CustomPromptMode { get { if let rawValue = cache["customPromptMode"], let mode = Settings.CustomPromptMode(rawValue: rawValue) { return mode } return .append // Default to append mode } set { cache["customPromptMode"] = newValue.rawValue DatabaseService.shared.setSetting(key: "customPromptMode", value: newValue.rawValue) } } // 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 contextSelectionEnabled: Bool { get { cache["contextSelectionEnabled"] == "true" } set { cache["contextSelectionEnabled"] = String(newValue) DatabaseService.shared.setSetting(key: "contextSelectionEnabled", value: String(newValue)) } } var contextMaxTokens: Int { get { cache["contextMaxTokens"].flatMap(Int.init) ?? 100_000 } set { cache["contextMaxTokens"] = String(newValue) DatabaseService.shared.setSetting(key: "contextMaxTokens", value: String(newValue)) } } var embeddingsEnabled: Bool { get { cache["embeddingsEnabled"] == "true" } set { cache["embeddingsEnabled"] = String(newValue) DatabaseService.shared.setSetting(key: "embeddingsEnabled", value: String(newValue)) } } var embeddingProvider: String { get { cache["embeddingProvider"] ?? "openai-small" } set { cache["embeddingProvider"] = newValue DatabaseService.shared.setSetting(key: "embeddingProvider", value: newValue) } } var progressiveSummarizationEnabled: Bool { get { cache["progressiveSummarizationEnabled"] == "true" } set { cache["progressiveSummarizationEnabled"] = String(newValue) DatabaseService.shared.setSetting(key: "progressiveSummarizationEnabled", value: String(newValue)) } } var summarizationThreshold: Int { get { cache["summarizationThreshold"].flatMap(Int.init) ?? 50 } set { cache["summarizationThreshold"] = String(newValue) DatabaseService.shared.setSetting(key: "summarizationThreshold", 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: - Toolbar Settings /// Toolbar icon size — default 16 (minimum) var toolbarIconSize: Double { get { cache["toolbarIconSize"].flatMap(Double.init) ?? 16.0 } set { cache["toolbarIconSize"] = String(newValue) DatabaseService.shared.setSetting(key: "toolbarIconSize", value: String(newValue)) } } /// Show labels on toolbar icons — default false var showToolbarLabels: Bool { get { cache["showToolbarLabels"] == "true" } set { cache["showToolbarLabels"] = String(newValue) DatabaseService.shared.setSetting(key: "showToolbarLabels", 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: - User Shortcuts (prompt template macros) var userShortcuts: [Shortcut] { get { guard let json = cache["userShortcuts"], let data = json.data(using: .utf8) else { return [] } let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 return (try? decoder.decode([Shortcut].self, from: data)) ?? [] } set { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 if let data = try? encoder.encode(newValue), let json = String(data: data, encoding: .utf8) { cache["userShortcuts"] = json DatabaseService.shared.setSetting(key: "userShortcuts", value: json) } } } func addShortcut(_ shortcut: Shortcut) { userShortcuts = userShortcuts + [shortcut] } func updateShortcut(_ shortcut: Shortcut) { userShortcuts = userShortcuts.map { $0.id == shortcut.id ? shortcut : $0 } } func deleteShortcut(id: UUID) { userShortcuts = userShortcuts.filter { $0.id != id } } // MARK: - Agent Skills (SKILL.md-style behavioral instructions) var agentSkills: [AgentSkill] { get { guard let json = cache["agentSkills"], let data = json.data(using: .utf8) else { return [] } let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 return (try? decoder.decode([AgentSkill].self, from: data)) ?? [] } set { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 if let data = try? encoder.encode(newValue), let json = String(data: data, encoding: .utf8) { cache["agentSkills"] = json DatabaseService.shared.setSetting(key: "agentSkills", value: json) } } } func addAgentSkill(_ skill: AgentSkill) { agentSkills = agentSkills + [skill] } func updateAgentSkill(_ skill: AgentSkill) { agentSkills = agentSkills.map { $0.id == skill.id ? skill : $0 } } func deleteAgentSkill(id: UUID) { agentSkills = agentSkills.filter { $0.id != id } AgentSkillFilesService.shared.deleteAll(for: id) } func toggleAgentSkill(id: UUID) { agentSkills = agentSkills.map { s in s.id == id ? AgentSkill(id: s.id, name: s.name, skillDescription: s.skillDescription, content: s.content, isActive: !s.isActive, createdAt: s.createdAt, updatedAt: Date()) : s } } // MARK: - Anytype MCP Settings var anytypeMcpEnabled: Bool { get { cache["anytypeMcpEnabled"] == "true" } set { cache["anytypeMcpEnabled"] = String(newValue) DatabaseService.shared.setSetting(key: "anytypeMcpEnabled", value: String(newValue)) } } var anytypeMcpURL: String { get { cache["anytypeMcpURL"] ?? "" } set { let trimmed = newValue.trimmingCharacters(in: .whitespaces) if trimmed.isEmpty { cache.removeValue(forKey: "anytypeMcpURL") DatabaseService.shared.deleteSetting(key: "anytypeMcpURL") } else { cache["anytypeMcpURL"] = trimmed DatabaseService.shared.setSetting(key: "anytypeMcpURL", value: trimmed) } } } var anytypeMcpEffectiveURL: String { let url = anytypeMcpURL return url.isEmpty ? "http://127.0.0.1:31009" : url } var anytypeMcpAPIKey: String? { get { try? DatabaseService.shared.getEncryptedSetting(key: EncryptedKeys.anytypeMcpAPIKey) } set { if let value = newValue, !value.isEmpty { try? DatabaseService.shared.setEncryptedSetting(key: EncryptedKeys.anytypeMcpAPIKey, value: value) } else { DatabaseService.shared.deleteEncryptedSetting(key: EncryptedKeys.anytypeMcpAPIKey) } } } var anytypeMcpConfigured: Bool { guard let key = anytypeMcpAPIKey else { return false } return !key.isEmpty } // 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 (Encrypted Database) var openrouterAPIKey: String? { get { try? DatabaseService.shared.getEncryptedSetting(key: EncryptedKeys.openrouterAPIKey) } set { if let value = newValue, !value.isEmpty { try? DatabaseService.shared.setEncryptedSetting(key: EncryptedKeys.openrouterAPIKey, value: value) } else { DatabaseService.shared.deleteEncryptedSetting(key: EncryptedKeys.openrouterAPIKey) } } } var anthropicAPIKey: String? { get { try? DatabaseService.shared.getEncryptedSetting(key: EncryptedKeys.anthropicAPIKey) } set { if let value = newValue, !value.isEmpty { try? DatabaseService.shared.setEncryptedSetting(key: EncryptedKeys.anthropicAPIKey, value: value) } else { DatabaseService.shared.deleteEncryptedSetting(key: EncryptedKeys.anthropicAPIKey) } } } var openaiAPIKey: String? { get { try? DatabaseService.shared.getEncryptedSetting(key: EncryptedKeys.openaiAPIKey) } set { if let value = newValue, !value.isEmpty { try? DatabaseService.shared.setEncryptedSetting(key: EncryptedKeys.openaiAPIKey, value: value) } else { DatabaseService.shared.deleteEncryptedSetting(key: EncryptedKeys.openaiAPIKey) } } } var googleAPIKey: String? { get { try? DatabaseService.shared.getEncryptedSetting(key: EncryptedKeys.googleAPIKey) } set { if let value = newValue, !value.isEmpty { try? DatabaseService.shared.setEncryptedSetting(key: EncryptedKeys.googleAPIKey, value: value) } else { DatabaseService.shared.deleteEncryptedSetting(key: EncryptedKeys.googleAPIKey) } } } var googleSearchEngineID: String? { get { try? DatabaseService.shared.getEncryptedSetting(key: EncryptedKeys.googleSearchEngineID) } set { if let value = newValue, !value.isEmpty { try? DatabaseService.shared.setEncryptedSetting(key: EncryptedKeys.googleSearchEngineID, value: value) } else { DatabaseService.shared.deleteEncryptedSetting(key: EncryptedKeys.googleSearchEngineID) } } } // MARK: - Git Sync Settings var syncEnabled: Bool { get { cache["syncEnabled"] == "true" } set { cache["syncEnabled"] = String(newValue) DatabaseService.shared.setSetting(key: "syncEnabled", value: String(newValue)) } } var syncRepoURL: String { get { cache["syncRepoURL"] ?? "" } set { let trimmed = newValue.trimmingCharacters(in: .whitespaces) if trimmed.isEmpty { cache.removeValue(forKey: "syncRepoURL") DatabaseService.shared.deleteSetting(key: "syncRepoURL") } else { cache["syncRepoURL"] = trimmed DatabaseService.shared.setSetting(key: "syncRepoURL", value: trimmed) } } } var syncLocalPath: String { get { cache["syncLocalPath"] ?? "~/Library/Application Support/oAI/sync" } set { cache["syncLocalPath"] = newValue DatabaseService.shared.setSetting(key: "syncLocalPath", value: newValue) } } var syncAuthMethod: String { get { cache["syncAuthMethod"] ?? "token" } // Default to access token set { cache["syncAuthMethod"] = newValue DatabaseService.shared.setSetting(key: "syncAuthMethod", value: newValue) } } // Encrypted sync credentials var syncUsername: String? { get { try? DatabaseService.shared.getEncryptedSetting(key: "syncUsername") } set { if let value = newValue, !value.isEmpty { try? DatabaseService.shared.setEncryptedSetting(key: "syncUsername", value: value) } else { DatabaseService.shared.deleteEncryptedSetting(key: "syncUsername") } } } var syncPassword: String? { get { try? DatabaseService.shared.getEncryptedSetting(key: "syncPassword") } set { if let value = newValue, !value.isEmpty { try? DatabaseService.shared.setEncryptedSetting(key: "syncPassword", value: value) } else { DatabaseService.shared.deleteEncryptedSetting(key: "syncPassword") } } } var syncAccessToken: String? { get { try? DatabaseService.shared.getEncryptedSetting(key: "syncAccessToken") } set { if let value = newValue, !value.isEmpty { try? DatabaseService.shared.setEncryptedSetting(key: "syncAccessToken", value: value) } else { DatabaseService.shared.deleteEncryptedSetting(key: "syncAccessToken") } } } var syncAutoExport: Bool { get { cache["syncAutoExport"] == "true" } set { cache["syncAutoExport"] = String(newValue) DatabaseService.shared.setSetting(key: "syncAutoExport", value: String(newValue)) } } var syncAutoPull: Bool { get { cache["syncAutoPull"] == "true" } set { cache["syncAutoPull"] = String(newValue) DatabaseService.shared.setSetting(key: "syncAutoPull", value: String(newValue)) } } var syncConfigured: Bool { guard !syncRepoURL.isEmpty else { return false } switch syncAuthMethod { case "ssh": return true // SSH uses system keys case "password": return syncUsername != nil && syncPassword != nil case "token": return syncAccessToken != nil default: return false } } // MARK: - Auto-Sync Settings var syncAutoSave: Bool { get { cache["syncAutoSave"] == "true" } set { cache["syncAutoSave"] = String(newValue) DatabaseService.shared.setSetting(key: "syncAutoSave", value: String(newValue)) } } var syncAutoSaveMinMessages: Int { get { cache["syncAutoSaveMinMessages"].flatMap(Int.init) ?? 5 } set { cache["syncAutoSaveMinMessages"] = String(newValue) DatabaseService.shared.setSetting(key: "syncAutoSaveMinMessages", value: String(newValue)) } } var syncAutoSaveOnModelSwitch: Bool { get { cache["syncAutoSaveOnModelSwitch"] == "true" } set { cache["syncAutoSaveOnModelSwitch"] = String(newValue) DatabaseService.shared.setSetting(key: "syncAutoSaveOnModelSwitch", value: String(newValue)) } } var syncAutoSaveOnAppQuit: Bool { get { cache["syncAutoSaveOnAppQuit"] == "true" } set { cache["syncAutoSaveOnAppQuit"] = String(newValue) DatabaseService.shared.setSetting(key: "syncAutoSaveOnAppQuit", value: String(newValue)) } } var syncAutoSaveOnIdle: Bool { get { cache["syncAutoSaveOnIdle"] == "true" } set { cache["syncAutoSaveOnIdle"] = String(newValue) DatabaseService.shared.setSetting(key: "syncAutoSaveOnIdle", value: String(newValue)) } } var syncAutoSaveIdleMinutes: Int { get { cache["syncAutoSaveIdleMinutes"].flatMap(Int.init) ?? 5 } set { cache["syncAutoSaveIdleMinutes"] = String(newValue) DatabaseService.shared.setSetting(key: "syncAutoSaveIdleMinutes", value: String(newValue)) } } var syncLastAutoSaveConversationId: String? { get { cache["syncLastAutoSaveConversationId"] } set { if let value = newValue { cache["syncLastAutoSaveConversationId"] = value DatabaseService.shared.setSetting(key: "syncLastAutoSaveConversationId", value: value) } else { cache.removeValue(forKey: "syncLastAutoSaveConversationId") DatabaseService.shared.deleteSetting(key: "syncLastAutoSaveConversationId") } } } // MARK: - Email Handler Settings var emailHandlerEnabled: Bool { get { cache["emailHandlerEnabled"] == "true" } set { cache["emailHandlerEnabled"] = String(newValue) DatabaseService.shared.setSetting(key: "emailHandlerEnabled", value: String(newValue)) } } var emailHandlerProvider: String { get { cache["emailHandlerProvider"] ?? "openrouter" } set { cache["emailHandlerProvider"] = newValue DatabaseService.shared.setSetting(key: "emailHandlerProvider", value: newValue) } } var emailHandlerModel: String { get { cache["emailHandlerModel"] ?? "" } set { cache["emailHandlerModel"] = newValue DatabaseService.shared.setSetting(key: "emailHandlerModel", value: newValue) } } var emailSubjectIdentifier: String { get { cache["emailSubjectIdentifier"] ?? "[OAIBOT]" } set { cache["emailSubjectIdentifier"] = newValue DatabaseService.shared.setSetting(key: "emailSubjectIdentifier", value: newValue) } } var emailRateLimitEnabled: Bool { get { cache["emailRateLimitEnabled"] == "true" } set { cache["emailRateLimitEnabled"] = String(newValue) DatabaseService.shared.setSetting(key: "emailRateLimitEnabled", value: String(newValue)) } } var emailRateLimitPerHour: Int { get { cache["emailRateLimitPerHour"].flatMap(Int.init) ?? 10 } set { cache["emailRateLimitPerHour"] = String(newValue) DatabaseService.shared.setSetting(key: "emailRateLimitPerHour", value: String(newValue)) } } var emailMaxTokens: Int { get { cache["emailMaxTokens"].flatMap(Int.init) ?? 2000 } set { cache["emailMaxTokens"] = String(newValue) DatabaseService.shared.setSetting(key: "emailMaxTokens", value: String(newValue)) } } var emailHandlerSystemPrompt: String? { get { cache["emailHandlerSystemPrompt"] } set { if let value = newValue, !value.isEmpty { cache["emailHandlerSystemPrompt"] = value DatabaseService.shared.setSetting(key: "emailHandlerSystemPrompt", value: value) } else { cache.removeValue(forKey: "emailHandlerSystemPrompt") DatabaseService.shared.deleteSetting(key: "emailHandlerSystemPrompt") } } } var emailOnlineMode: Bool { get { cache["emailOnlineMode"] == "true" } set { cache["emailOnlineMode"] = String(newValue) DatabaseService.shared.setSetting(key: "emailOnlineMode", value: String(newValue)) } } var emailHandlerConfigured: Bool { guard emailHandlerEnabled else { return false } guard !emailHandlerModel.isEmpty else { return false } // Check if email server is configured guard emailServerConfigured else { return false } return true } // MARK: - Email Server Settings var emailImapHost: String? { get { cache["emailImapHost"] } set { if let value = newValue, !value.isEmpty { cache["emailImapHost"] = value DatabaseService.shared.setSetting(key: "emailImapHost", value: value) } else { cache.removeValue(forKey: "emailImapHost") DatabaseService.shared.deleteSetting(key: "emailImapHost") } } } var emailSmtpHost: String? { get { cache["emailSmtpHost"] } set { if let value = newValue, !value.isEmpty { cache["emailSmtpHost"] = value DatabaseService.shared.setSetting(key: "emailSmtpHost", value: value) } else { cache.removeValue(forKey: "emailSmtpHost") DatabaseService.shared.deleteSetting(key: "emailSmtpHost") } } } var emailUsername: String? { get { try? DatabaseService.shared.getEncryptedSetting(key: "emailUsername") } set { if let value = newValue, !value.isEmpty { try? DatabaseService.shared.setEncryptedSetting(key: "emailUsername", value: value) } else { DatabaseService.shared.deleteEncryptedSetting(key: "emailUsername") } } } var emailPassword: String? { get { try? DatabaseService.shared.getEncryptedSetting(key: "emailPassword") } set { if let value = newValue, !value.isEmpty { try? DatabaseService.shared.setEncryptedSetting(key: "emailPassword", value: value) } else { DatabaseService.shared.deleteEncryptedSetting(key: "emailPassword") } } } var emailImapPort: Int { get { cache["emailImapPort"].flatMap(Int.init) ?? 993 } set { cache["emailImapPort"] = String(newValue) DatabaseService.shared.setSetting(key: "emailImapPort", value: String(newValue)) } } var emailSmtpPort: Int { get { cache["emailSmtpPort"].flatMap(Int.init) ?? 587 } set { cache["emailSmtpPort"] = String(newValue) DatabaseService.shared.setSetting(key: "emailSmtpPort", value: String(newValue)) } } var emailServerConfigured: Bool { guard let imapHost = emailImapHost, !imapHost.isEmpty else { return false } guard let smtpHost = emailSmtpHost, !smtpHost.isEmpty else { return false } guard let username = emailUsername, !username.isEmpty else { return false } guard let password = emailPassword, !password.isEmpty else { return false } return true } // 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 Migration private func migrateFromKeychainIfNeeded() { // Skip if already migrated guard cache["_keychain_migrated"] == nil else { return } Log.settings.info("Migrating API keys from Keychain to encrypted database...") let keysToMigrate: [(keychainKey: String, encryptedKey: String)] = [ (KeychainKeys.openrouterAPIKey, EncryptedKeys.openrouterAPIKey), (KeychainKeys.anthropicAPIKey, EncryptedKeys.anthropicAPIKey), (KeychainKeys.openaiAPIKey, EncryptedKeys.openaiAPIKey), (KeychainKeys.googleAPIKey, EncryptedKeys.googleAPIKey), (KeychainKeys.googleSearchEngineID, EncryptedKeys.googleSearchEngineID), ] var migratedCount = 0 for (keychainKey, encryptedKey) in keysToMigrate { // Read from keychain if let value = getKeychainValue(for: keychainKey), !value.isEmpty { // Write to encrypted database do { try DatabaseService.shared.setEncryptedSetting(key: encryptedKey, value: value) // Delete from keychain after successful migration deleteKeychainValue(for: keychainKey) migratedCount += 1 Log.settings.info("Migrated \(encryptedKey) from Keychain to encrypted database") } catch { Log.settings.error("Failed to migrate \(encryptedKey): \(error.localizedDescription)") } } } Log.settings.info("Keychain migration complete: \(migratedCount) keys migrated") // Mark migration complete cache["_keychain_migrated"] = "true" DatabaseService.shared.setSetting(key: "_keychain_migrated", value: "true") } // MARK: - Keychain Helpers (for migration only) 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) } }