Initial commit
This commit is contained in:
408
oAI/Services/SettingsService.swift
Normal file
408
oAI/Services/SettingsService.swift
Normal file
@@ -0,0 +1,408 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user