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.
1385 lines
50 KiB
Swift
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)
|
|
}
|
|
}
|