Files
oai-swift/oAI/Services/SettingsService.swift
T
runeandClaude Sonnet 5 0af644456e Update commercial licensing contact URL to oai.pm
Consolidates the mac.oai.pm subdomain references (introduced in the
PolyForm Noncommercial relicense) to the root oai.pm domain, across
the LICENSE file, README, and all Swift source file headers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 11:34:04 +02:00

1342 lines
48 KiB
Swift

//
// SettingsService.swift
// oAI
//
// Settings persistence: SQLite for preferences, Keychain for API keys
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
// You may use, study, modify, and share it for any noncommercial
// purpose. Commercial use — including selling oAI or any part of
// it, standalone or bundled into another product or service —
// requires a separate commercial license from the copyright holder.
//
// See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>.
import Foundation
import os
import Security
/// Kill switch for the Personal Data tools (Calendar/Reminders/Contacts/Location & Maps).
/// Flip `isHiddenPendingAppleFix` back to `false` once Apple fixes the macOS 27 beta TCC bug
/// (filed with Apple). Each flag hides the relevant UI and forces the `*Enabled` getter to
/// return `false` regardless of the persisted DB value — no code deleted, just inert.
enum PersonalDataTools {
/// Hides the entire Personal Data section. Flip to `false` once all four services work.
static let isHiddenPendingAppleFix = false
/// Hides only the Contacts row. Contacts TCC still broken under hardened runtime on
/// macOS 27 beta 2 while Calendar/Reminders/Location are fixed. Flip to `false` once fixed.
static let isContactsHiddenPendingAppleFix = true
}
@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"
static let paperlessAPIToken = "paperlessAPIToken"
static let jarvisAPIKey = "jarvisAPIKey"
}
// 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: - Reasoning Settings
var reasoningEnabled: Bool {
get { cache["reasoningEnabled"] == "true" }
set {
cache["reasoningEnabled"] = String(newValue)
DatabaseService.shared.setSetting(key: "reasoningEnabled", value: String(newValue))
}
}
/// "high", "medium", "low", "minimal" — default "medium"
var reasoningEffort: String {
get { cache["reasoningEffort"] ?? "medium" }
set {
cache["reasoningEffort"] = newValue
DatabaseService.shared.setSetting(key: "reasoningEffort", value: newValue)
}
}
/// When true, model reasons internally but thinking content is excluded from response
var reasoningExclude: Bool {
get { cache["reasoningExclude"] == "true" }
set {
cache["reasoningExclude"] = String(newValue)
DatabaseService.shared.setSetting(key: "reasoningExclude", 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))
}
}
/// Input bar height in points — default 80
var inputBarHeight: Double {
get { cache["inputBarHeight"].flatMap(Double.init) ?? 80.0 }
set {
cache["inputBarHeight"] = String(newValue)
DatabaseService.shared.setSetting(key: "inputBarHeight", value: String(newValue))
}
}
/// Whether the sidebar is visible — default true
var sidebarVisible: Bool {
get { cache["sidebarVisible"] != "false" }
set {
cache["sidebarVisible"] = String(newValue)
DatabaseService.shared.setSetting(key: "sidebarVisible", 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: - External MCP Servers
var externalMCPServers: [ExternalMCPServer] {
get {
guard let json = cache["externalMCPServers"],
let data = json.data(using: .utf8) else { return [] }
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return (try? decoder.decode([ExternalMCPServer].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["externalMCPServers"] = json
DatabaseService.shared.setSetting(key: "externalMCPServers", value: json)
}
Task { @MainActor in ExternalMCPManager.shared.reconfigure(servers: newValue) }
}
}
func addExternalMCPServer(_ server: ExternalMCPServer) {
externalMCPServers = externalMCPServers + [server]
}
func updateExternalMCPServer(_ server: ExternalMCPServer) {
externalMCPServers = externalMCPServers.map { $0.id == server.id ? server : $0 }
}
func deleteExternalMCPServer(id: UUID) {
externalMCPServers = externalMCPServers.filter { $0.id != id }
}
func toggleExternalMCPServer(id: UUID) {
externalMCPServers = externalMCPServers.map { s in
s.id == id ? ExternalMCPServer(id: s.id, name: s.name, command: s.command,
args: s.args, isEnabled: !s.isEnabled,
timeout: s.timeout, createdAt: s.createdAt) : s
}
}
// MARK: - Favorite Models
var favoriteModelIds: Set<String> {
get {
guard let json = cache["favoriteModelIds"],
let data = json.data(using: .utf8),
let ids = try? JSONDecoder().decode([String].self, from: data) else { return [] }
return Set(ids)
}
set {
let sorted = newValue.sorted()
if let data = try? JSONEncoder().encode(sorted),
let json = String(data: data, encoding: .utf8) {
cache["favoriteModelIds"] = json
DatabaseService.shared.setSetting(key: "favoriteModelIds", value: json)
}
}
}
func toggleFavoriteModel(_ id: String) {
var favs = favoriteModelIds
if favs.contains(id) { favs.remove(id) } else { favs.insert(id) }
favoriteModelIds = favs
}
// 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: - Jarvis Settings
var jarvisEnabled: Bool {
get { cache["jarvisEnabled"] == "true" }
set {
cache["jarvisEnabled"] = String(newValue)
DatabaseService.shared.setSetting(key: "jarvisEnabled", value: String(newValue))
}
}
var jarvisURL: String {
get { cache["jarvisURL"] ?? "" }
set {
let trimmed = newValue.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty {
cache.removeValue(forKey: "jarvisURL")
DatabaseService.shared.deleteSetting(key: "jarvisURL")
} else {
cache["jarvisURL"] = trimmed
DatabaseService.shared.setSetting(key: "jarvisURL", value: trimmed)
}
}
}
var jarvisAPIKey: String? {
get { try? DatabaseService.shared.getEncryptedSetting(key: EncryptedKeys.jarvisAPIKey) }
set {
if let value = newValue, !value.isEmpty {
try? DatabaseService.shared.setEncryptedSetting(key: EncryptedKeys.jarvisAPIKey, value: value)
} else {
DatabaseService.shared.deleteEncryptedSetting(key: EncryptedKeys.jarvisAPIKey)
}
}
}
var jarvisConfigured: Bool {
guard let key = jarvisAPIKey else { return false }
return !jarvisURL.isEmpty && !key.isEmpty
}
// MARK: - Bash Execution Settings
var bashEnabled: Bool {
get { cache["bashEnabled"] == "true" }
set {
cache["bashEnabled"] = String(newValue)
DatabaseService.shared.setSetting(key: "bashEnabled", value: String(newValue))
}
}
var bashRequireApproval: Bool {
get { cache["bashRequireApproval"].map { $0 == "true" } ?? true }
set {
cache["bashRequireApproval"] = String(newValue)
DatabaseService.shared.setSetting(key: "bashRequireApproval", value: String(newValue))
}
}
// MARK: - Research Agents Settings
/// When true, the AI can call `spawn_research_agents` to run multiple read-only
/// sub-agents in parallel. Each sub-agent is its own full chain of model calls, so
/// this can noticeably increase cost — opt-in, off by default.
var agentsEnabled: Bool {
get { cache["agentsEnabled"] == "true" }
set {
cache["agentsEnabled"] = String(newValue)
DatabaseService.shared.setSetting(key: "agentsEnabled", value: String(newValue))
}
}
var maxConcurrentAgents: Int {
get { cache["maxConcurrentAgents"].flatMap(Int.init) ?? 3 }
set {
cache["maxConcurrentAgents"] = String(newValue)
DatabaseService.shared.setSetting(key: "maxConcurrentAgents", value: String(newValue))
}
}
/// When true (and an active "2nd Brain" Agent Skill is installed), bash commands that
/// invoke the 2nd Brain helper script skip the approval dialog entirely.
var trustSecondBrainSkill: Bool {
get { cache["trustSecondBrainSkill"] == "true" }
set {
cache["trustSecondBrainSkill"] = String(newValue)
DatabaseService.shared.setSetting(key: "trustSecondBrainSkill", value: String(newValue))
}
}
var bashWorkingDirectory: String {
get { cache["bashWorkingDirectory"] ?? "~" }
set {
cache["bashWorkingDirectory"] = newValue
DatabaseService.shared.setSetting(key: "bashWorkingDirectory", value: newValue)
}
}
var bashTimeout: Int {
get { cache["bashTimeout"].flatMap(Int.init) ?? 30 }
set {
cache["bashTimeout"] = String(newValue)
DatabaseService.shared.setSetting(key: "bashTimeout", value: String(newValue))
}
}
// MARK: - Personal Data Settings (Calendar/Reminders/Contacts/Location/Maps)
var calendarEnabled: Bool {
get { !PersonalDataTools.isHiddenPendingAppleFix && cache["calendarEnabled"] == "true" }
set {
cache["calendarEnabled"] = String(newValue)
DatabaseService.shared.setSetting(key: "calendarEnabled", value: String(newValue))
}
}
var remindersEnabled: Bool {
get { !PersonalDataTools.isHiddenPendingAppleFix && cache["remindersEnabled"] == "true" }
set {
cache["remindersEnabled"] = String(newValue)
DatabaseService.shared.setSetting(key: "remindersEnabled", value: String(newValue))
}
}
var contactsEnabled: Bool {
get { !PersonalDataTools.isHiddenPendingAppleFix && !PersonalDataTools.isContactsHiddenPendingAppleFix && cache["contactsEnabled"] == "true" }
set {
cache["contactsEnabled"] = String(newValue)
DatabaseService.shared.setSetting(key: "contactsEnabled", value: String(newValue))
}
}
var locationMapsEnabled: Bool {
get { !PersonalDataTools.isHiddenPendingAppleFix && cache["locationMapsEnabled"] == "true" }
set {
cache["locationMapsEnabled"] = String(newValue)
DatabaseService.shared.setSetting(key: "locationMapsEnabled", value: String(newValue))
}
}
var personalDataRequireApproval: Bool {
get { cache["personalDataRequireApproval"].map { $0 == "true" } ?? true }
set {
cache["personalDataRequireApproval"] = String(newValue)
DatabaseService.shared.setSetting(key: "personalDataRequireApproval", value: String(newValue))
}
}
// MARK: - Paperless-NGX Settings
var paperlessEnabled: Bool {
get { cache["paperlessEnabled"] == "true" }
set {
cache["paperlessEnabled"] = String(newValue)
DatabaseService.shared.setSetting(key: "paperlessEnabled", value: String(newValue))
}
}
var paperlessURL: String {
get { cache["paperlessURL"] ?? "" }
set {
var trimmed = newValue.trimmingCharacters(in: .whitespaces)
// Remove trailing slash for consistency
while trimmed.hasSuffix("/") { trimmed = String(trimmed.dropLast()) }
if trimmed.isEmpty {
cache.removeValue(forKey: "paperlessURL")
DatabaseService.shared.deleteSetting(key: "paperlessURL")
} else {
cache["paperlessURL"] = trimmed
DatabaseService.shared.setSetting(key: "paperlessURL", value: trimmed)
}
}
}
var paperlessAPIToken: String? {
get { try? DatabaseService.shared.getEncryptedSetting(key: EncryptedKeys.paperlessAPIToken) }
set {
if let value = newValue, !value.isEmpty {
try? DatabaseService.shared.setEncryptedSetting(key: EncryptedKeys.paperlessAPIToken, value: value)
} else {
DatabaseService.shared.deleteEncryptedSetting(key: EncryptedKeys.paperlessAPIToken)
}
}
}
var paperlessConfigured: Bool {
guard !paperlessURL.isEmpty else { return false }
guard let token = paperlessAPIToken else { return false }
return !token.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: - Cache Reload
/// Replace the in-memory cache with a fresh read from the database.
/// Called by BackupService after restoring a backup.
func reloadFromDatabase() {
cache = (try? DatabaseService.shared.loadAllSettings()) ?? [:]
Log.settings.info("Settings cache reloaded (\(self.cache.count) entries)")
}
// 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)
}
}