// ProviderConfig.swift // OSGKeyboard · Shared // // User's LLM configuration. Persisted in App Group UserDefaults so both // the main app and keyboard extension read the same values. // // `apiKey` is the exception: it lives in the Keychain (see // `Keychain.swift`) for at-rest encryption. The first time this struct // inits after upgrade, a legacy plaintext value from UserDefaults is // migrated to the Keychain and removed from UserDefaults. import Foundation import Combine public final class ProviderConfig: ObservableObject, @unchecked Sendable { public static let shared = ProviderConfig() private enum Key { static let providerId = "config.providerId" static let baseURL = "config.baseURL" // Legacy: apiKey used to live in UserDefaults before the // migration. We still read it once (see init below) and then // delete the entry, but no other code path touches this key. static let apiKeyLegacy = "config.apiKey" static let model = "config.model" static let systemPrompt = "config.systemPrompt" static let modeId = "config.modeId" static let localeId = "config.localeId" static let engineMode = "config.engineMode" static let hasCompletedOnboarding = "config.hasCompletedOnboarding" static let onboardingPage = "config.onboardingPage" } @Published public var providerId: String { didSet { defaults.set(providerId, forKey: Key.providerId) } } @Published public var baseURL: String { didSet { defaults.set(baseURL, forKey: Key.baseURL) } } @Published public var apiKey: String { didSet { // Skip the round-trip on init — we read from Keychain and // writing the same value back is wasteful. guard oldValue != apiKey else { return } do { try Keychain.setAPIKey(apiKey) } catch { #if DEBUG print("⚠️ [OSGKeyboard] Keychain write failed: \(error)") #endif } } } @Published public var model: String { didSet { defaults.set(model, forKey: Key.model) } } @Published public var systemPrompt: String { didSet { defaults.set(systemPrompt, forKey: Key.systemPrompt) } } @Published public var modeId: String { didSet { defaults.set(modeId, forKey: Key.modeId) } } @Published public var localeId: String { didSet { defaults.set(localeId, forKey: Key.localeId) } } /// "local" → on-device ASR only, no LLM polishing. /// "cloud" → ASR + LLM polish (default). @Published public var engineMode: String { didSet { defaults.set(engineMode, forKey: Key.engineMode) } } @Published public var hasCompletedOnboarding: Bool { didSet { defaults.set(hasCompletedOnboarding, forKey: Key.hasCompletedOnboarding) if hasCompletedOnboarding { onboardingPage = 0 } } } /// Persisted onboarding step so returning from Settings does not reset progress. @Published public var onboardingPage: Int { didSet { defaults.set(onboardingPage, forKey: Key.onboardingPage) } } public var isConfigured: Bool { // Local engine (on-device ASR only) doesn't need an API key, // base URL, or model — the LLM round-trip is skipped entirely. // Treat it as always-configured so onboarding's "Next" button // enables the moment the user picks the local path, instead // of forcing them to fill in cloud fields they won't use. if isLocalEngine { return true } return !baseURL.isEmpty && !apiKey.isEmpty && !model.isEmpty } /// On-device ASR only — no cloud LLM polish. public var isLocalEngine: Bool { engineMode == "local" } /// The system prompt the user *sees* in the editor — fall back to the /// provider-aware default from `AppGroupStore` when nothing is set. public var defaultSystemPrompt: String { AppGroupStore.defaultSystemPrompt(for: providerId) } private let defaults: UserDefaults public init(defaults: UserDefaults? = nil) { let resolvedDefaults: UserDefaults = defaults ?? (AppGroup.isAvailable ? AppGroup.defaults : .standard) self.defaults = resolvedDefaults let pid = resolvedDefaults.string(forKey: Key.providerId) ?? "openai" let preset = LLMProvider.provider(id: pid) self.providerId = pid self.baseURL = resolvedDefaults.string(forKey: Key.baseURL) ?? preset.defaultBaseURL // Resolve the API key with a one-shot migration from the legacy // UserDefaults slot. After this runs once, `Key.apiKeyLegacy` // is empty in the suite and all subsequent reads go through the // Keychain. self.apiKey = ProviderConfig.resolveAPIKey(defaults: resolvedDefaults) self.model = resolvedDefaults.string(forKey: Key.model) ?? preset.defaultModel self.systemPrompt = resolvedDefaults.string(forKey: Key.systemPrompt) ?? AppGroupStore.defaultSystemPrompt(for: pid) self.modeId = resolvedDefaults.string(forKey: Key.modeId) ?? "polish" self.localeId = resolvedDefaults.string(forKey: Key.localeId) ?? "auto" self.engineMode = resolvedDefaults.string(forKey: Key.engineMode) ?? "cloud" self.hasCompletedOnboarding = resolvedDefaults.bool(forKey: Key.hasCompletedOnboarding) let savedPage = resolvedDefaults.integer(forKey: Key.onboardingPage) self.onboardingPage = savedPage > 0 ? savedPage : 0 } /// Read the API key from the Keychain, falling back to a one-time /// migration from the legacy UserDefaults slot. private static func resolveAPIKey(defaults: UserDefaults) -> String { if let stored = Keychain.apiKey(), !stored.isEmpty { return stored } if let legacy = defaults.string(forKey: Key.apiKeyLegacy), !legacy.isEmpty { try? Keychain.setAPIKey(legacy) defaults.removeObject(forKey: Key.apiKeyLegacy) return legacy } return "" } public func apply(preset: LLMProvider) { // Capture the *previous* provider id BEFORE we mutate, so the // system-prompt reset check below can compare against the actual // prior default. let oldId = providerId providerId = preset.id if !preset.defaultBaseURL.isEmpty { baseURL = preset.defaultBaseURL } if !preset.defaultModel.isEmpty { model = preset.defaultModel } // When switching providers, reset the system prompt to the new // provider's default — otherwise the user is left editing a // Chinese prompt on a US-English model. if systemPrompt.isEmpty || systemPrompt == AppGroupStore.defaultSystemPrompt(for: oldId) { systemPrompt = AppGroupStore.defaultSystemPrompt(for: preset.id) } } public func reset() { providerId = "openai" let preset = LLMProvider.provider(id: "openai") baseURL = preset.defaultBaseURL apiKey = "" model = preset.defaultModel systemPrompt = AppGroupStore.defaultSystemPrompt(for: "openai") } }