// AppGroupConfiguration.swift // OSGKeyboard · Shared // // Single source of truth for App Group UserDefaults keys (`config.*`). // Both `ProviderConfig` (main app) and `AppGroupStore` (keyboard ext) // should read/write through this type so keys and defaults stay aligned. import Foundation public struct AppGroupConfiguration: Sendable, Equatable { /// Default polish LLM for fresh installs (local + cloud pickers). public static let defaultPolishProviderId = "deepseek" /// Default cloud ASR provider for fresh installs (independent from polish). public static let defaultCloudASRProviderId = "volcengine" // MARK: - Keys public enum Keys { public static let providerId = "config.providerId" public static let baseURL = "config.baseURL" /// Legacy plaintext slot — migrated to Keychain on first read. public static let apiKeyLegacy = "config.apiKey" public static let model = "config.model" /// Cloud ASR provider — independent from polish `providerId`. public static let asrProviderId = "config.asrProviderId" public static let asrBaseURL = "config.asrBaseURL" public static let asrModel = "config.asrModel" public static let modeId = "config.modeId" public static let localeId = "config.localeId" public static let engineMode = "config.engineMode" /// Credential ownership is independent from local/cloud ASR selection. public static let credentialSource = "config.credentialSource" /// Device-local, non-secret gate for account-funded managed requests. /// This key is deliberately excluded from iCloud settings payloads. public static let managedGatewayAccountSessionAvailable = "account.managedGateway.sessionAvailable.v1" public static let hasCompletedOnboarding = "config.hasCompletedOnboarding" public static let onboardingPage = "config.onboardingPage" public static let hasAcknowledgedCloudSharing = "config.hasAcknowledgedCloudSharing" public static let uiLanguage = "config.uiLanguage" public static let translationTargetLocaleId = "config.translationTargetLocaleId" public static let handednessPreference = "config.handednessPreference" public static let cursorDragNavigationEnabled = "config.cursorDragNavigationEnabled" public static let keyboardHapticIntensity = "config.keyboardHapticIntensity" public static let polishIntensity = "config.polishIntensity" public static let aiResponseLength = "config.aiResponseLength" public static let llmThinkingEnabled = "config.llmThinkingEnabled" /// When true, the keyboard records system clipboard text into local history. public static let clipboardHistoryEnabled = "config.clipboardHistoryEnabled" /// When true (and history is on), show the newest clipboard item as a suggestion strip. public static let clipboardCandidateBarEnabled = "config.clipboardCandidateBarEnabled" public static let detectedAppContext = "config.detectedAppContext" public static let detectedAppContextAt = "config.detectedAppContextAt" public static let personalDictionary = "config.personalDictionary.v1" public static let polishStyleCatalog = "config.polishStyles.v1" public static let activePolishStyleId = "config.activePolishStyleId" public static let polishStylesMigrated = "config.polishStyles.migrated" /// Legacy keys from the removed manual scenario implementation. public static let legacyPolishScenarioId = "config.polishScenarioId" public static let legacySystemPrompt = "config.systemPrompt" /// When true, the main app mirrors the personal dictionary via iCloud KVS. public static let personalDictionaryICloudSyncEnabled = "config.personalDictionary.iCloudSyncEnabled" /// When true, the main app mirrors user settings via iCloud KVS. public static let settingsICloudSyncEnabled = "config.settings.iCloudSyncEnabled" /// Wall-clock stamp of the last settings blob applied from iCloud KVS. public static let settingsCloudUpdatedAt = "config.settings.cloudUpdatedAt" /// Cached per-field settings merge payload (`SyncedAppSettingsV2`). public static let settingsCloudPayloadV2 = "config.settings.cloudPayload.v2" /// When true, the host app auto-returns to the source app after a cold-start handoff. public static let flowSkipAppSwitch = "config.flowSkipAppSwitch" /// Raw `FlowInactivityDuration` value; session expires after this idle window. public static let flowInactivityDuration = "config.flowInactivityDuration" /// One-shot: remap previous product defaults (30m / 10m) → 5m. public static let flowInactivityMigratedToFiveMinuteDefault = "config.flowInactivityDuration.migratedToFiveMinuteDefault" /// Diagnostic switch: when false, local ASR skips the custom language model. public static let localASRCustomLanguageModelEnabled = "config.localASR.customLanguageModelEnabled" /// Enabled AI Agent skill IDs (order) + confirmed companion Shortcuts. public static let agentSkillLayout = "config.aiAgentSkills.layout.v1" /// One-shot migrations for newly default-installed built-in and official skills. public static let agentSkillDefaultsMigrationVersion = "config.aiAgentSkills.defaultsMigrationVersion" /// User-created clipboard skills (no cloud sync; App Group only). public static let agentUserSkillCatalog = "config.aiAgentSkills.userCatalog.v1" /// Last-known-good official catalog plus revision, freshness, and ETag metadata. public static let officialSkillCatalog = "content.officialSkills.snapshot.v1" } // MARK: - Stored fields public var providerId: String public var baseURL: String public var model: String /// Cloud-engine speech-to-text provider (OpenLess-style split from polish). public var asrProviderId: String public var asrBaseURL: String public var asrModel: String public var modeId: String public var localeId: String public var engineMode: String public var credentialSource: CredentialSource public var hasCompletedOnboarding: Bool public var onboardingPage: Int public var hasAcknowledgedCloudSharing: Bool public var uiLanguage: AppUILanguage public var translationTargetLocaleId: String public var handednessPreference: HandednessPreference public var cursorDragNavigationEnabled: Bool /// Typing-grid haptic strength (off / light / strong). public var keyboardHapticIntensity: KeyboardHapticIntensity /// Safety envelope for built-in fun polish styles (light by default). public var polishIntensity: PolishIntensity /// Soft AI-mode answer length preference (medium by default). public var aiResponseLength: AIResponseLength /// Enables provider-specific reasoning / thinking controls for polish LLM requests. public var llmThinkingEnabled: Bool /// Opt-in clipboard history capture in the keyboard extension. public var clipboardHistoryEnabled: Bool /// Opt-in clipboard suggestion strip above the key surfaces. public var clipboardCandidateBarEnabled: Bool public var personalDictionary: PersonalDictionary public var polishStyleCatalog: PolishStyleCatalog public var activePolishStyleId: String /// Opt-in iCloud KVS sync for the personal dictionary (main app only). public var personalDictionaryICloudSyncEnabled: Bool /// Opt-in iCloud KVS sync for user settings (main app only). public var settingsICloudSyncEnabled: Bool /// Auto-return to the host app after `startflow` cold start (default on). public var flowSkipAppSwitch: Bool /// Idle timeout before the Flow session ends; resets on each utterance. public var flowInactivityDuration: FlowInactivityDuration /// Whether local `SpeechAnalyzer` should attach the prepared custom language model. public var localASRCustomLanguageModelEnabled: Bool // MARK: - Derived /// Translation is on iff a target locale other than `offLocaleId` is selected. public var translationEnabled: Bool { translationTargetLocaleId != TranslationLanguageCatalog.offLocaleId } public var isTranslationEffective: Bool { translationEnabled } public var isLocalEngine: Bool { engineMode == "local" } public var polishModeForPipeline: PolishingService.PolishMode { isTranslationEffective ? .translate(targetLocaleId: translationTargetLocaleId) : .polish } /// Polish LLM provider. Local engine no longer pins DeepSeek — user picks in Settings. public var polishProviderIdOverride: String? { nil } public var isCloudLLMKeyMissing: Bool { guard engineMode == "cloud" else { return false } guard credentialSource == .byok else { return false } return apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } public var isCloudASRKeyMissing: Bool { guard engineMode == "cloud" else { return false } guard credentialSource == .byok else { return false } let key = asrApiKey.trimmingCharacters(in: .whitespacesAndNewlines) guard !key.isEmpty else { return true } // Volcengine may store auth_mode JSON before credentials are filled. if asrProviderId == "volcengine" { return !VolcengineASRFields.parse(apiKey: key).hasUsableCredentials } return false } public var isPolishKeyMissing: Bool { guard credentialSource == .byok else { return false } return apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } public var isCloudAPIKeyMissingForVoiceInput: Bool { guard engineMode == "cloud" else { return false } return isCloudASRKeyMissing || isCloudLLMKeyMissing } /// Polish LLM uses `providerId` + Keychain `provider.`. public var apiKey: String { Self.resolveAPIKey( defaults: nil, providerId: providerId, preferICloudSync: settingsICloudSyncEnabled ) } /// Cloud ASR uses `asrProviderId` + Keychain `asr.` (falls back to legacy `provider.`). public var asrApiKey: String { Self.resolveASRAPIKey( defaults: nil, providerId: asrProviderId, preferICloudSync: settingsICloudSyncEnabled ) } public func makeClient( taskKind: ManagedGatewayTaskKind? = nil, requestPurpose: ManagedGatewayRequestPurpose? = nil, oobeFeature: ManagedGatewayOOBEFeature? = nil ) -> LLMClient { if credentialSource == .managed || requestPurpose == .oobe { return ManagedLLMClient( capability: .polish, taskKind: taskKind, requestPurpose: requestPurpose, oobeFeature: oobeFeature, grants: GatewayGrantCoordinator() ) } return OpenAICompatibleClient( baseURL: baseURL, apiKey: apiKey, model: model, providerId: providerId, thinkingEnabled: llmThinkingEnabled ) } /// Resolved cloud ASR model — user override or catalog default. public var resolvedASRModel: String { let trimmed = asrModel.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { return trimmed } return CloudASRModelCatalog.defaultModel(for: asrProviderId) } /// Resolved cloud ASR base URL for prompt-style providers. public var resolvedASRBaseURL: String { let trimmed = asrBaseURL.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { return trimmed } return LLMProvider.provider(id: asrProviderId).defaultBaseURL } // MARK: - Detected app context public func detectedAppContext(from defaults: UserDefaults) -> (context: AppContext, observedAt: Date)? { guard let raw = defaults.string(forKey: Keys.detectedAppContext), let value = AppContext(rawValue: raw) else { return nil } let timestamp = defaults.object(forKey: Keys.detectedAppContextAt) as? Date ?? .distantPast return (value, timestamp) } public mutating func setDetectedAppContext(_ context: AppContext, at date: Date = Date(), to defaults: UserDefaults) { defaults.set(context.rawValue, forKey: Keys.detectedAppContext) defaults.set(date, forKey: Keys.detectedAppContextAt) } // MARK: - Load / save /// Loads configuration from App Group defaults. Returns `nil` when the suite is unavailable. public static func load(from defaults: UserDefaults? = nil) -> AppGroupConfiguration? { guard let store = defaults ?? AppGroup.defaultsIfAvailable else { return nil } return load(fromAvailable: store) } /// Loads and idempotently migrates a known-available suite; this is not a /// pure read. Missing defaults distinguish upgrades (legacy cloud engine) /// from fresh installs (local engine) before being persisted. public static func load(fromAvailable defaults: UserDefaults) -> AppGroupConfiguration { let storedProviderId = defaults.string(forKey: Keys.providerId) var config = AppGroupConfiguration( providerId: storedProviderId ?? defaultPolishProviderId, baseURL: "", model: "", asrProviderId: defaults.string(forKey: Keys.asrProviderId) ?? "", asrBaseURL: "", asrModel: "", modeId: defaults.string(forKey: Keys.modeId) ?? "polish", localeId: defaults.string(forKey: Keys.localeId) ?? "auto", // Privacy-critical default: `local` keeps raw audio on-device // (SpeechAnalyzer). The `cloud` engine uploads recorded audio to // the user's configured ASR provider and must stay an explicit, // acknowledged opt-in (see `hasAcknowledgedCloudSharing`) — a // cloud default would contradict every privacy claim the app // makes in its docs, App Store listing, and permission prompts. engineMode: defaults.string(forKey: Keys.engineMode) ?? "local", credentialSource: CredentialSource.fromStored( defaults.string(forKey: Keys.credentialSource) ), hasCompletedOnboarding: defaults.bool(forKey: Keys.hasCompletedOnboarding), onboardingPage: { let saved = defaults.integer(forKey: Keys.onboardingPage) return saved > 0 ? saved : 0 }(), hasAcknowledgedCloudSharing: defaults.bool(forKey: Keys.hasAcknowledgedCloudSharing), uiLanguage: AppUILanguage.fromStored(defaults.string(forKey: Keys.uiLanguage)), translationTargetLocaleId: defaults.string(forKey: Keys.translationTargetLocaleId) ?? TranslationLanguageCatalog.offLocaleId, handednessPreference: HandednessPreference.fromStored( defaults.string(forKey: Keys.handednessPreference) ), cursorDragNavigationEnabled: { if defaults.object(forKey: Keys.cursorDragNavigationEnabled) == nil { return true } return defaults.bool(forKey: Keys.cursorDragNavigationEnabled) }(), keyboardHapticIntensity: KeyboardHapticIntensity.fromStored( defaults.string(forKey: Keys.keyboardHapticIntensity) ), polishIntensity: PolishIntensity.resolve( storedRawValue: defaults.string(forKey: Keys.polishIntensity) ), aiResponseLength: AIResponseLength.resolve( storedRawValue: defaults.string(forKey: Keys.aiResponseLength) ), llmThinkingEnabled: defaults.bool(forKey: Keys.llmThinkingEnabled), clipboardHistoryEnabled: defaults.bool(forKey: Keys.clipboardHistoryEnabled), clipboardCandidateBarEnabled: defaults.bool(forKey: Keys.clipboardCandidateBarEnabled), personalDictionary: decodePersonalDictionary(from: defaults), polishStyleCatalog: decodePolishStyleCatalog(from: defaults), activePolishStyleId: defaults.string(forKey: Keys.activePolishStyleId) ?? PolishStylePackCatalog.defaultID, personalDictionaryICloudSyncEnabled: { if defaults.object(forKey: Keys.personalDictionaryICloudSyncEnabled) == nil { return true } return defaults.bool(forKey: Keys.personalDictionaryICloudSyncEnabled) }(), settingsICloudSyncEnabled: { if defaults.object(forKey: Keys.settingsICloudSyncEnabled) == nil { return true } return defaults.bool(forKey: Keys.settingsICloudSyncEnabled) }(), flowSkipAppSwitch: { if defaults.object(forKey: Keys.flowSkipAppSwitch) == nil { return true } return defaults.bool(forKey: Keys.flowSkipAppSwitch) }(), flowInactivityDuration: FlowInactivityDuration.fromStored( defaults.string(forKey: Keys.flowInactivityDuration) ), localASRCustomLanguageModelEnabled: { if defaults.object(forKey: Keys.localASRCustomLanguageModelEnabled) == nil { return true } return defaults.bool(forKey: Keys.localASRCustomLanguageModelEnabled) }() ) let preset = LLMProvider.provider(id: config.providerId) if config.baseURL.isEmpty { config.baseURL = defaults.string(forKey: Keys.baseURL) ?? preset.defaultBaseURL } if config.model.isEmpty { config.model = defaults.string(forKey: Keys.model) ?? preset.defaultModel } if config.asrProviderId.isEmpty { // Pre-split installs only stored `providerId`; copy it so ASR keeps working. config.asrProviderId = storedProviderId ?? defaultCloudASRProviderId defaults.set(config.asrProviderId, forKey: Keys.asrProviderId) } let asrPreset = LLMProvider.provider(id: config.asrProviderId) if config.asrBaseURL.isEmpty { config.asrBaseURL = defaults.string(forKey: Keys.asrBaseURL) ?? asrPreset.defaultBaseURL } if config.asrModel.isEmpty { config.asrModel = defaults.string(forKey: Keys.asrModel) ?? CloudASRModelCatalog.defaultModel(for: config.asrProviderId) } // Legacy qwen cloud ASR → bailian realtime (HTTP Flash path removed). if config.asrProviderId == "qwen" { do { try Keychain.copyQwenASRKeyToBailian( useICloudSync: config.settingsICloudSyncEnabled ) } catch { OSGLog.config.warning( "qwen ASR credential migration deferred: \(String(describing: error), privacy: .public)" ) } let bailian = LLMProvider.provider(id: "bailian") config.asrProviderId = "bailian" config.asrBaseURL = bailian.defaultBaseURL config.asrModel = CloudASRModelCatalog.alibabaFunASRRealtime defaults.set(config.asrProviderId, forKey: Keys.asrProviderId) defaults.set(config.asrBaseURL, forKey: Keys.asrBaseURL) defaults.set(config.asrModel, forKey: Keys.asrModel) } // One-shot legacy migration: plaintext apiKey in UserDefaults → Keychain. _ = resolveAPIKey( defaults: defaults, providerId: config.providerId, preferICloudSync: config.settingsICloudSyncEnabled ) // One-shot defaults for installs that predate explicit settings. // Preserve the legacy engine choice, but use the current privacy-safe // inactivity duration when the user has never selected one. let isExistingInstall = defaults.bool(forKey: Keys.hasCompletedOnboarding) if defaults.string(forKey: Keys.engineMode) == nil { let resolved = isExistingInstall ? "cloud" : "local" config.engineMode = resolved defaults.set(resolved, forKey: Keys.engineMode) } if defaults.string(forKey: Keys.flowInactivityDuration) == nil { let resolved = FlowInactivityDuration.default config.flowInactivityDuration = resolved defaults.set(resolved.rawValue, forKey: Keys.flowInactivityDuration) defaults.set(true, forKey: Keys.flowInactivityMigratedToFiveMinuteDefault) } else if !defaults.bool(forKey: Keys.flowInactivityMigratedToFiveMinuteDefault) { // Previous product defaults were 30m then briefly 10m. Remap those // once so existing installs pick up the new 5-minute default; users // who later choose 30m / 10m again keep that choice. let previousDefaults: Set = [ FlowInactivityDuration.thirtyMinutes.rawValue, FlowInactivityDuration.tenMinutes.rawValue ] if previousDefaults.contains(config.flowInactivityDuration.rawValue) { config.flowInactivityDuration = .default defaults.set(FlowInactivityDuration.default.rawValue, forKey: Keys.flowInactivityDuration) } defaults.set(true, forKey: Keys.flowInactivityMigratedToFiveMinuteDefault) } // Cloud no longer exposes off/transcribe; migrate legacy values. if config.engineMode == "cloud", config.modeId != "polish" { config.modeId = "polish" defaults.set("polish", forKey: Keys.modeId) } migrateLegacyPolishStyleIfNeeded(configuration: &config, defaults: defaults) return config } public func save(to defaults: UserDefaults) { defaults.set(providerId, forKey: Keys.providerId) defaults.set(baseURL, forKey: Keys.baseURL) defaults.set(model, forKey: Keys.model) defaults.set(asrProviderId, forKey: Keys.asrProviderId) defaults.set(asrBaseURL, forKey: Keys.asrBaseURL) defaults.set(asrModel, forKey: Keys.asrModel) defaults.set(modeId, forKey: Keys.modeId) defaults.set(localeId, forKey: Keys.localeId) defaults.set(engineMode, forKey: Keys.engineMode) defaults.set(credentialSource.rawValue, forKey: Keys.credentialSource) defaults.set(hasCompletedOnboarding, forKey: Keys.hasCompletedOnboarding) defaults.set(onboardingPage, forKey: Keys.onboardingPage) defaults.set(hasAcknowledgedCloudSharing, forKey: Keys.hasAcknowledgedCloudSharing) defaults.set(uiLanguage.rawValue, forKey: Keys.uiLanguage) defaults.set(translationTargetLocaleId, forKey: Keys.translationTargetLocaleId) defaults.set(handednessPreference.rawValue, forKey: Keys.handednessPreference) defaults.set(cursorDragNavigationEnabled, forKey: Keys.cursorDragNavigationEnabled) defaults.set(keyboardHapticIntensity.rawValue, forKey: Keys.keyboardHapticIntensity) defaults.set(polishIntensity.rawValue, forKey: Keys.polishIntensity) defaults.set(aiResponseLength.rawValue, forKey: Keys.aiResponseLength) defaults.set(llmThinkingEnabled, forKey: Keys.llmThinkingEnabled) defaults.set(clipboardHistoryEnabled, forKey: Keys.clipboardHistoryEnabled) defaults.set(clipboardCandidateBarEnabled, forKey: Keys.clipboardCandidateBarEnabled) defaults.set(activePolishStyleId, forKey: Keys.activePolishStyleId) defaults.set(flowSkipAppSwitch, forKey: Keys.flowSkipAppSwitch) defaults.set(flowInactivityDuration.rawValue, forKey: Keys.flowInactivityDuration) defaults.set(localASRCustomLanguageModelEnabled, forKey: Keys.localASRCustomLanguageModelEnabled) defaults.set(personalDictionaryICloudSyncEnabled, forKey: Keys.personalDictionaryICloudSyncEnabled) defaults.set(settingsICloudSyncEnabled, forKey: Keys.settingsICloudSyncEnabled) Self.encodePersonalDictionary(personalDictionary, to: defaults) Self.encodePolishStyleCatalog(polishStyleCatalog, to: defaults) } /// Persists only fields changed from the caller's last observed snapshot. /// /// Main app and keyboard extension are separate processes. Rewriting every /// key from a stale snapshot can undo a newer, unrelated setting written by /// the other process. Field-level writes keep unrelated updates intact. public func saveChanges(since baseline: Self, to defaults: UserDefaults) { func set(_ value: Value, previous: Value, key: String) { guard value != previous else { return } defaults.set(value, forKey: key) } set(providerId, previous: baseline.providerId, key: Keys.providerId) set(baseURL, previous: baseline.baseURL, key: Keys.baseURL) set(model, previous: baseline.model, key: Keys.model) set(asrProviderId, previous: baseline.asrProviderId, key: Keys.asrProviderId) set(asrBaseURL, previous: baseline.asrBaseURL, key: Keys.asrBaseURL) set(asrModel, previous: baseline.asrModel, key: Keys.asrModel) set(modeId, previous: baseline.modeId, key: Keys.modeId) set(localeId, previous: baseline.localeId, key: Keys.localeId) set(engineMode, previous: baseline.engineMode, key: Keys.engineMode) set( credentialSource.rawValue, previous: baseline.credentialSource.rawValue, key: Keys.credentialSource ) set( hasCompletedOnboarding, previous: baseline.hasCompletedOnboarding, key: Keys.hasCompletedOnboarding ) set(onboardingPage, previous: baseline.onboardingPage, key: Keys.onboardingPage) set( hasAcknowledgedCloudSharing, previous: baseline.hasAcknowledgedCloudSharing, key: Keys.hasAcknowledgedCloudSharing ) set(uiLanguage.rawValue, previous: baseline.uiLanguage.rawValue, key: Keys.uiLanguage) set( translationTargetLocaleId, previous: baseline.translationTargetLocaleId, key: Keys.translationTargetLocaleId ) set( handednessPreference.rawValue, previous: baseline.handednessPreference.rawValue, key: Keys.handednessPreference ) set( cursorDragNavigationEnabled, previous: baseline.cursorDragNavigationEnabled, key: Keys.cursorDragNavigationEnabled ) set( keyboardHapticIntensity.rawValue, previous: baseline.keyboardHapticIntensity.rawValue, key: Keys.keyboardHapticIntensity ) set( polishIntensity.rawValue, previous: baseline.polishIntensity.rawValue, key: Keys.polishIntensity ) set( aiResponseLength.rawValue, previous: baseline.aiResponseLength.rawValue, key: Keys.aiResponseLength ) set( llmThinkingEnabled, previous: baseline.llmThinkingEnabled, key: Keys.llmThinkingEnabled ) set( clipboardHistoryEnabled, previous: baseline.clipboardHistoryEnabled, key: Keys.clipboardHistoryEnabled ) set( clipboardCandidateBarEnabled, previous: baseline.clipboardCandidateBarEnabled, key: Keys.clipboardCandidateBarEnabled ) set( activePolishStyleId, previous: baseline.activePolishStyleId, key: Keys.activePolishStyleId ) set(flowSkipAppSwitch, previous: baseline.flowSkipAppSwitch, key: Keys.flowSkipAppSwitch) set( flowInactivityDuration.rawValue, previous: baseline.flowInactivityDuration.rawValue, key: Keys.flowInactivityDuration ) set( localASRCustomLanguageModelEnabled, previous: baseline.localASRCustomLanguageModelEnabled, key: Keys.localASRCustomLanguageModelEnabled ) set( personalDictionaryICloudSyncEnabled, previous: baseline.personalDictionaryICloudSyncEnabled, key: Keys.personalDictionaryICloudSyncEnabled ) set( settingsICloudSyncEnabled, previous: baseline.settingsICloudSyncEnabled, key: Keys.settingsICloudSyncEnabled ) if personalDictionary != baseline.personalDictionary { Self.encodePersonalDictionary(personalDictionary, to: defaults) } if polishStyleCatalog != baseline.polishStyleCatalog { Self.encodePolishStyleCatalog(polishStyleCatalog, to: defaults) } } // MARK: - Private helpers private static func decodePersonalDictionary(from defaults: UserDefaults) -> PersonalDictionary { guard let data = defaults.data(forKey: Keys.personalDictionary) else { return .empty } do { var dictionary = try JSONDecoder().decode(PersonalDictionary.self, from: data) if dictionary.entries.contains(where: { $0.source == .history }) { for index in dictionary.entries.indices where dictionary.entries[index].source == .history { dictionary.entries[index].source = .manual } dictionary.version += 1 if let migrated = try? JSONEncoder().encode(dictionary) { defaults.set(migrated, forKey: Keys.personalDictionary) } } return dictionary } catch { OSGLog.config.warning("personalDictionary decode failed: \(error.localizedDescription, privacy: .public)") return .empty } } private static func encodePersonalDictionary(_ dictionary: PersonalDictionary, to defaults: UserDefaults) { do { let data = try JSONEncoder().encode(dictionary) defaults.set(data, forKey: Keys.personalDictionary) } catch { OSGLog.config.warning("personalDictionary encode failed: \(error.localizedDescription, privacy: .public)") } } private static func decodePolishStyleCatalog(from defaults: UserDefaults) -> PolishStyleCatalog { guard let data = defaults.data(forKey: Keys.polishStyleCatalog) else { return .empty } do { return try JSONDecoder().decode(PolishStyleCatalog.self, from: data) } catch { OSGLog.config.warning("polishStyleCatalog decode failed: \(error.localizedDescription, privacy: .public)") return .empty } } private static func encodePolishStyleCatalog(_ catalog: PolishStyleCatalog, to defaults: UserDefaults) { do { defaults.set(try JSONEncoder().encode(catalog), forKey: Keys.polishStyleCatalog) } catch { OSGLog.config.warning("polishStyleCatalog encode failed: \(error.localizedDescription, privacy: .public)") } } private static func migrateLegacyPolishStyleIfNeeded( configuration: inout AppGroupConfiguration, defaults: UserDefaults ) { guard !defaults.bool(forKey: Keys.polishStylesMigrated) else { return } defer { defaults.set(true, forKey: Keys.polishStylesMigrated) } if let legacyPrompt = defaults.string(forKey: Keys.legacySystemPrompt)? .trimmingCharacters(in: .whitespacesAndNewlines), !legacyPrompt.isEmpty { let boundedPrompt = String(legacyPrompt.prefix(PolishStyleLimits.maximumPromptCharacters)) let custom = PolishStylePack(name: "自定义", prompt: boundedPrompt) if (try? configuration.polishStyleCatalog.upsert(custom)) != nil { configuration.activePolishStyleId = custom.id defaults.set(custom.id, forKey: Keys.activePolishStyleId) encodePolishStyleCatalog(configuration.polishStyleCatalog, to: defaults) } return } let legacyMappings = [ "daily_chat": "builtin.chat", "work": "builtin.formal", "document": "builtin.structured", "todo": "builtin.structured", "social_lifestyle": "builtin.xhs" ] if let legacyID = defaults.string(forKey: Keys.legacyPolishScenarioId), let mappedID = legacyMappings[legacyID] { configuration.activePolishStyleId = mappedID defaults.set(mappedID, forKey: Keys.activePolishStyleId) } } /// Resolves the provider-scoped Keychain item, then the legacy `current` /// account, then plaintext defaults. Legacy sources are removed after the /// selected local or synchronizable target is read back exactly. static func resolveAPIKey( defaults: UserDefaults?, providerId: String, preferICloudSync: Bool = false ) -> String { if let stored = Keychain.apiKey(for: providerId, preferICloudSync: preferICloudSync), !stored.isEmpty { return stored } if let legacyKeychain = Keychain.legacyAPIKey(), !legacyKeychain.isEmpty { do { try Keychain.migrateLegacyAPIKey( to: providerId, useICloudSync: preferICloudSync ) } catch { OSGLog.config.warning( "legacy Keychain credential migration deferred: \(String(describing: error), privacy: .public)" ) } return legacyKeychain } if let defaults, let legacy = defaults.string(forKey: Keys.apiKeyLegacy), !legacy.isEmpty { do { try Keychain.copyAPIKeyToSelectedStorage( legacy, providerId: providerId, useICloudSync: preferICloudSync ) defaults.removeObject(forKey: Keys.apiKeyLegacy) } catch { OSGLog.config.warning( "legacy defaults credential migration deferred: \(String(describing: error), privacy: .public)" ) } return legacy } return "" } /// Resolves the ASR-scoped account first, then falls back to the matching /// polish-provider account used before ASR credentials were split. static func resolveASRAPIKey( defaults: UserDefaults?, providerId: String, preferICloudSync: Bool = false ) -> String { if let stored = Keychain.asrApiKey(for: providerId, preferICloudSync: preferICloudSync), !stored.isEmpty { return stored } if providerId == "bailian" { try? Keychain.copyQwenASRKeyToBailian(useICloudSync: preferICloudSync) if let migrated = Keychain.asrApiKey( for: providerId, preferICloudSync: preferICloudSync ), !migrated.isEmpty { return migrated } // Keep a compatibility read while older signed installs may still // hold the DashScope credential under qwen accounts. if let legacyQwen = Keychain.asrApiKey( for: "qwen", preferICloudSync: preferICloudSync ), !legacyQwen.isEmpty { return legacyQwen } } // Pre-split installs: one shared key under `provider.`. return resolveAPIKey(defaults: defaults, providerId: providerId, preferICloudSync: preferICloudSync) } }