Files
oai-swift/oAI/Services/SettingsService.swift
T
rune 57b3477903 Fix External MCP server bugs; add npx/Node.js detection and install help
Root-caused two real issues Rune hit with Obsidian/Homepage external MCP
servers:

1. Toggling a server's enable switch silently wiped transportKind/env/
   url/bearerToken/headers back to stdio defaults (only id/name/command/
   args/isEnabled/timeout/createdAt were preserved) — almost certainly
   how Obsidian's config got corrupted into an empty-command stdio entry
   despite never being edited directly. Fixed via
   ExternalMCPServer.withEnabledToggled(), which flips only isEnabled.

2. npx (installed via Homebrew) was invisible to Confab because GUI apps
   only inherit launchd's minimal PATH, not the Terminal PATH. Tried
   spawning the user's login shell to ask for its real PATH — this
   caused two real hangs in one session (first an -ilc pipe deadlock,
   then a waitUntilExit()/CFRunLoop reentrancy issue even after fixing
   that) and was abandoned entirely in favor of LoginShellEnvironment:
   deterministic, subprocess-free directory probing (Homebrew, MacPorts,
   Volta, nvm's alias file) that can't hang by construction.

Also added:
- Edit capability for existing External MCP servers (previously only
  Add/Toggle/Delete) — the second thing Rune explicitly asked for, and
  the way to fix a corrupted entry like Obsidian's without deleting it.
- MCPClientError.commandNotFound: a stdio server's command is checked
  against PATH up front in StdioMCPTransport.prepare() and fails
  immediately with a clear reason instead of cycling through 3 rounds of
  crash/restart backoff (5s/15s/30s) for a permanently-missing binary.
- A "Get Node.js" button appears when this happens, opening a sheet with
  a copyable `brew install node`, a one-click install (via
  NodeInstallHelper, using the terminationHandler/readabilityHandler
  pattern already proven safe elsewhere in this file — deliberately not
  waitUntilExit()), or a nodejs.org link if Homebrew isn't present.
- ExternalMCPManager.retryClient(id:) to manually retry after fixing the
  underlying cause.
- Help book: new "Servers That Use npx" section, updated Server Status
  section, updated Settings blurb.

37 new/changed tests covering the toggle fix, PATH probing, the
commandNotFound fast-fail path, and missing-command detection — full
suite (374 tests) passes clean.
2026-08-26 14:02:17 +02:00

1385 lines
50 KiB
Swift

//
// SettingsService.swift
// Confab
//
// 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 Confab.
//
// Confab 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 Confab 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://confab.no>.
import Foundation
import os
import Security
/// Kill switch for the entire Personal Data tools section (Calendar/Reminders/Contacts/
/// Location & Maps). Hides the UI and forces every `*Enabled` getter to return `false`
/// regardless of the persisted DB value — no code deleted, just inert.
enum PersonalDataTools {
static let isHiddenPendingAppleFix = false
}
/// Kill switch for the Mail integration. Was hidden after first-time Automation consent grants
/// appeared to fail on macOS 27 beta with no dialog and no TCC record — turned out to be a real
/// Confab bug, not an OS bug: `Confab.entitlements` was missing `com.apple.security.automation.
/// apple-events`, so `tccd` refused to even prompt (confirmed via `tccd`'s own log, same failure
/// class as the earlier Calendar/Contacts entitlement bug on `PersonalDataTools`). Fixed and
/// live-verified granting successfully on beta 7 (2026-08-26). Same shape as `PersonalDataTools`
/// above — kept as infrastructure, not deleted, in case a future regression needs it again.
enum MailTools {
static let isHiddenPendingAppleFix = false
}
@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 { $0.id == id ? $0.withEnabledToggled() : $0 }
}
// 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)
}
}
}
// MARK: - Folder Collapse State
/// IDs of folders currently collapsed in the sidebar/conversation list — persisted so the
/// app reopens with folders in the same expanded/collapsed state the user left them in.
var collapsedFolderIds: Set<UUID> {
get {
guard let json = cache["collapsedFolderIds"],
let data = json.data(using: .utf8),
let ids = try? JSONDecoder().decode([String].self, from: data) else { return [] }
return Set(ids.compactMap { UUID(uuidString: $0) })
}
set {
let sorted = newValue.map { $0.uuidString }.sorted()
if let data = try? JSONEncoder().encode(sorted),
let json = String(data: data, encoding: .utf8) {
cache["collapsedFolderIds"] = json
DatabaseService.shared.setSetting(key: "collapsedFolderIds", value: json)
}
}
}
/// ISO8601 timestamp of the last local change to favoriteModelIds — used to
/// resolve last-write-wins conflicts when syncing favorites across machines.
var favoriteModelsUpdatedAt: String {
get { cache["favoriteModelsUpdatedAt"] ?? "" }
set {
cache["favoriteModelsUpdatedAt"] = newValue
DatabaseService.shared.setSetting(key: "favoriteModelsUpdatedAt", value: newValue)
}
}
func toggleFavoriteModel(_ id: String) {
var favs = favoriteModelIds
if favs.contains(id) { favs.remove(id) } else { favs.insert(id) }
favoriteModelIds = favs
favoriteModelsUpdatedAt = ISO8601DateFormatter().string(from: Date())
Task { await BackupService.shared.pushFavorites() }
}
// MARK: - Automatic Backup
/// "manual" (default), "daily", or "weekly"
var autoBackupFrequency: String {
get { cache["autoBackupFrequency"] ?? "manual" }
set {
cache["autoBackupFrequency"] = newValue
DatabaseService.shared.setSetting(key: "autoBackupFrequency", value: newValue)
}
}
// 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 && 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: - Mail Settings (Apple Mail.app via AppleScript)
var mailEnabled: Bool {
get { !MailTools.isHiddenPendingAppleFix && cache["mailEnabled"] == "true" }
set {
cache["mailEnabled"] = String(newValue)
DatabaseService.shared.setSetting(key: "mailEnabled", value: String(newValue))
}
}
var mailRequireApproval: Bool {
get { cache["mailRequireApproval"].map { $0 == "true" } ?? true }
set {
cache["mailRequireApproval"] = String(newValue)
DatabaseService.shared.setSetting(key: "mailRequireApproval", 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: - Crash-Recovery Draft Settings
/// How often (in seconds) the in-progress conversation is mirrored to a local
/// crash-recovery draft, so a force-quit/crash doesn't lose it. 0 = off.
var draftRecoveryIntervalSeconds: Int {
get { cache["draftRecoveryIntervalSeconds"].flatMap(Int.init) ?? 10 }
set {
cache["draftRecoveryIntervalSeconds"] = String(newValue)
DatabaseService.shared.setSetting(key: "draftRecoveryIntervalSeconds", value: String(newValue))
}
}
// 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)
}
}
// MARK: - CLI Server
/// Local Unix-socket server (CLIServerService) for one-shot, non-streaming shell access to a
/// single fixed model — deliberately separate from the chat UI's defaultProvider/defaultModel
/// so switching models in the GUI never changes shell-tool behavior, same reasoning as the
/// email handler's own dedicated provider/model pair above.
var cliServerEnabled: Bool {
get { cache["cliServerEnabled"] == "true" }
set {
cache["cliServerEnabled"] = String(newValue)
DatabaseService.shared.setSetting(key: "cliServerEnabled", value: String(newValue))
}
}
var cliServerProvider: String {
get { cache["cliServerProvider"] ?? "openrouter" }
set {
cache["cliServerProvider"] = newValue
DatabaseService.shared.setSetting(key: "cliServerProvider", value: newValue)
}
}
var cliServerModel: String {
get { cache["cliServerModel"] ?? "" }
set {
cache["cliServerModel"] = newValue
DatabaseService.shared.setSetting(key: "cliServerModel", 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)
}
}