41b8db87b5
Keep voice sessions on silent low-profile PiP only, and fix custom polish style editor presentation via sheet(item:).
531 lines
26 KiB
Swift
531 lines
26 KiB
Swift
// 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"
|
|
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 llmThinkingEnabled = "config.llmThinkingEnabled"
|
|
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"
|
|
/// Keys used by the removed pre-v0.3 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"
|
|
}
|
|
|
|
// 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 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
|
|
/// Enables provider-specific reasoning / thinking controls for polish LLM requests.
|
|
public var llmThinkingEnabled: 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 }
|
|
return apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
}
|
|
|
|
public var isCloudASRKeyMissing: Bool {
|
|
guard engineMode == "cloud" 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 {
|
|
if !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
return false
|
|
}
|
|
return !PreconfiguredKeys.isDeepseekConfigured
|
|
}
|
|
|
|
public var isCloudAPIKeyMissingForVoiceInput: Bool {
|
|
guard engineMode == "cloud" else { return false }
|
|
return isCloudASRKeyMissing || isCloudLLMKeyMissing
|
|
}
|
|
|
|
/// Polish LLM uses `providerId` + Keychain `provider.<id>`.
|
|
public var apiKey: String {
|
|
Self.resolveAPIKey(
|
|
defaults: nil,
|
|
providerId: providerId,
|
|
preferICloudSync: settingsICloudSyncEnabled
|
|
)
|
|
}
|
|
|
|
/// Cloud ASR uses `asrProviderId` + Keychain `asr.<id>` (falls back to legacy `provider.<id>`).
|
|
public var asrApiKey: String {
|
|
Self.resolveASRAPIKey(
|
|
defaults: nil,
|
|
providerId: asrProviderId,
|
|
preferICloudSync: settingsICloudSyncEnabled
|
|
)
|
|
}
|
|
|
|
public func makeClient() -> LLMClient {
|
|
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 configuration from a known-available UserDefaults suite.
|
|
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",
|
|
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)
|
|
),
|
|
llmThinkingEnabled: defaults.bool(forKey: Keys.llmThinkingEnabled),
|
|
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" {
|
|
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<String> = [
|
|
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(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(llmThinkingEnabled, forKey: Keys.llmThinkingEnabled)
|
|
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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
/// Read the API key from the Keychain, falling back to a one-time migration from UserDefaults.
|
|
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 {
|
|
try? Keychain.setAPIKey(legacyKeychain, for: providerId, useICloudSync: preferICloudSync)
|
|
try? Keychain.deleteLegacyAPIKey()
|
|
return legacyKeychain
|
|
}
|
|
if let defaults,
|
|
let legacy = defaults.string(forKey: Keys.apiKeyLegacy),
|
|
!legacy.isEmpty {
|
|
try? Keychain.setAPIKey(legacy, for: providerId, useICloudSync: preferICloudSync)
|
|
defaults.removeObject(forKey: Keys.apiKeyLegacy)
|
|
return legacy
|
|
}
|
|
return ""
|
|
}
|
|
|
|
static func resolveASRAPIKey(
|
|
defaults: UserDefaults?,
|
|
providerId: String,
|
|
preferICloudSync: Bool = false
|
|
) -> String {
|
|
if let stored = Keychain.asrApiKey(for: providerId, preferICloudSync: preferICloudSync), !stored.isEmpty {
|
|
return stored
|
|
}
|
|
// Pre-split installs: one shared key under `provider.<id>`.
|
|
return resolveAPIKey(defaults: defaults, providerId: providerId, preferICloudSync: preferICloudSync)
|
|
}
|
|
}
|