Squashes the working tree that had accumulated on cloud-agent. Two strands of work, kept in one commit at the maintainer's request. Crash and stability (this session): - Subscribe to MetricKit so crash, hang, CPU, and disk-write diagnostics are recorded on device. Keyboard-extension memory kills are the most likely way a user loses the keyboard mid-sentence and iOS does not classify them as crashes, so they never reach App Store Connect. - Upload those reports to POST /v1/analytics/diagnostics from internal builds only (local Debug and TestFlight), reusing the analytics installation identifier and skipping entirely when analytics are off. App Store builds keep everything on device. - Shed the Chinese engine, English lexicon, and in-flight pipeline work at the 40 MiB and 48 MiB budget thresholds instead of waiting for a system memory warning that jetsam often preempts. - Install audio taps with the input node's live format through one shared adaptive resampler, so an audio-route change mid-recording no longer raises an uncatchable format-mismatch exception. Covers the dictation preview and the Mac recorder, which still used the pattern the Flow capture path had already moved away from. - Harden the librime bridge: never pass a nil string across a nonnull boundary when a user dictionary holds malformed UTF-8, null-check the engine API vector, and distrust a candidate count without its array. - Guard the expanded candidate grid against a list that shrinks while the collection view is still laying out against the previous count. - Resolve localized text without the App Group so the screen shown when that container is missing renders its message instead of crashing on launch, which is the one situation it exists to handle. - Gate CI on the App Group and Keychain wiring that the keyboard extension hard-depends on, with a self-test proving the check fails. Clipboard, reply, and semantics (pre-existing working tree): - Unify the reply center, refresh keyboard AI features, and add native event and reminder exporters. - Expand clipboard semantic routing with authored corpora and a product-blind holdout, plus the scripts that build and validate them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
848 lines
41 KiB
Swift
848 lines
41 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"
|
|
/// 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 multipleReplyVariantsEnabled = "config.multipleReplyVariantsEnabled"
|
|
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"
|
|
/// When true (and history is on), auto-route a freshly copied, replyable
|
|
/// message into the Reply flow when the keyboard opens. Device-local.
|
|
public static let clipboardAutoModeEnabled = "config.clipboardAutoModeEnabled"
|
|
/// When true (and history is on), auto-translate a freshly copied,
|
|
/// non-system-language paste on open. Device-local.
|
|
public static let clipboardAutoTranslateEnabled = "config.clipboardAutoTranslateEnabled"
|
|
/// When true (and history is on), auto-draft a reply when a freshly
|
|
/// copied paste is detected as an email. Device-local.
|
|
public static let clipboardAutoEmailReplyEnabled = "config.clipboardAutoEmailReplyEnabled"
|
|
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"
|
|
/// Distilled personal style used for AI replies. Empty means disabled.
|
|
/// Deliberately separate from `activePolishStyleId`: voice input and AI
|
|
/// replies must not share one selection.
|
|
public static let personalReplyStyleId = "config.personalReplyStyleId"
|
|
/// One-shot: split the personal style out of the voice-polish selection.
|
|
public static let personalReplyStyleMigrated = "config.personalReplyStyle.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
|
|
/// Decode-only compatibility field for cursor drag pads removed in 2.0.0.
|
|
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
|
|
/// When true, Reply may provide several sendable variants.
|
|
public var multipleReplyVariantsEnabled: Bool
|
|
/// 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
|
|
/// Opt-in: auto-route a freshly copied replyable message into the Reply
|
|
/// flow when the keyboard opens. Off by default; never syncs via iCloud.
|
|
public var clipboardAutoModeEnabled: Bool
|
|
/// Opt-in: auto-translate a freshly copied non-system-language paste on
|
|
/// open. Off by default; never syncs via iCloud.
|
|
public var clipboardAutoTranslateEnabled: Bool
|
|
/// Opt-in: auto-draft a reply when a freshly copied paste is an email.
|
|
/// Off by default; never syncs via iCloud.
|
|
public var clipboardAutoEmailReplyEnabled: Bool
|
|
public var personalDictionary: PersonalDictionary
|
|
public var polishStyleCatalog: PolishStyleCatalog
|
|
/// Voice-input polish style. Never a distilled personal style.
|
|
public var activePolishStyleId: String
|
|
/// Distilled personal style applied to AI replies; empty when disabled.
|
|
public var personalReplyStyleId: 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.<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(
|
|
taskKind: ManagedGatewayTaskKind? = nil,
|
|
requestPurpose: ManagedGatewayRequestPurpose? = nil,
|
|
oobeFeature: ManagedGatewayOOBEFeature? = nil
|
|
) -> LLMClient {
|
|
if credentialSource == .managed || requestPurpose == .oobe {
|
|
return ManagedLLMClient(
|
|
capability: .resolve(taskKind: taskKind),
|
|
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 false
|
|
}
|
|
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)
|
|
),
|
|
multipleReplyVariantsEnabled: {
|
|
if defaults.object(forKey: Keys.multipleReplyVariantsEnabled) == nil {
|
|
return true
|
|
}
|
|
return defaults.bool(forKey: Keys.multipleReplyVariantsEnabled)
|
|
}(),
|
|
llmThinkingEnabled: defaults.bool(forKey: Keys.llmThinkingEnabled),
|
|
clipboardHistoryEnabled: defaults.bool(forKey: Keys.clipboardHistoryEnabled),
|
|
// Paste capsule is always on now (its opt-in toggle was removed), so
|
|
// default to true when the key was never written.
|
|
clipboardCandidateBarEnabled: defaults.object(forKey: Keys.clipboardCandidateBarEnabled) as? Bool ?? true,
|
|
clipboardAutoModeEnabled: defaults.bool(forKey: Keys.clipboardAutoModeEnabled),
|
|
clipboardAutoTranslateEnabled: defaults.bool(forKey: Keys.clipboardAutoTranslateEnabled),
|
|
clipboardAutoEmailReplyEnabled: defaults.bool(forKey: Keys.clipboardAutoEmailReplyEnabled),
|
|
personalDictionary: decodePersonalDictionary(from: defaults),
|
|
polishStyleCatalog: decodePolishStyleCatalog(from: defaults),
|
|
activePolishStyleId: defaults.string(forKey: Keys.activePolishStyleId)
|
|
?? PolishStylePackCatalog.defaultID,
|
|
personalReplyStyleId: defaults.string(forKey: Keys.personalReplyStyleId) ?? "",
|
|
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<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)
|
|
migratePersonalReplyStyleIfNeeded(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(multipleReplyVariantsEnabled, forKey: Keys.multipleReplyVariantsEnabled)
|
|
defaults.set(llmThinkingEnabled, forKey: Keys.llmThinkingEnabled)
|
|
defaults.set(clipboardHistoryEnabled, forKey: Keys.clipboardHistoryEnabled)
|
|
defaults.set(clipboardCandidateBarEnabled, forKey: Keys.clipboardCandidateBarEnabled)
|
|
defaults.set(clipboardAutoModeEnabled, forKey: Keys.clipboardAutoModeEnabled)
|
|
defaults.set(clipboardAutoTranslateEnabled, forKey: Keys.clipboardAutoTranslateEnabled)
|
|
defaults.set(clipboardAutoEmailReplyEnabled, forKey: Keys.clipboardAutoEmailReplyEnabled)
|
|
defaults.set(activePolishStyleId, forKey: Keys.activePolishStyleId)
|
|
defaults.set(personalReplyStyleId, forKey: Keys.personalReplyStyleId)
|
|
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: Equatable>(_ 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(
|
|
multipleReplyVariantsEnabled,
|
|
previous: baseline.multipleReplyVariantsEnabled,
|
|
key: Keys.multipleReplyVariantsEnabled
|
|
)
|
|
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(
|
|
clipboardAutoModeEnabled,
|
|
previous: baseline.clipboardAutoModeEnabled,
|
|
key: Keys.clipboardAutoModeEnabled
|
|
)
|
|
set(
|
|
clipboardAutoTranslateEnabled,
|
|
previous: baseline.clipboardAutoTranslateEnabled,
|
|
key: Keys.clipboardAutoTranslateEnabled
|
|
)
|
|
set(
|
|
clipboardAutoEmailReplyEnabled,
|
|
previous: baseline.clipboardAutoEmailReplyEnabled,
|
|
key: Keys.clipboardAutoEmailReplyEnabled
|
|
)
|
|
set(
|
|
activePolishStyleId,
|
|
previous: baseline.activePolishStyleId,
|
|
key: Keys.activePolishStyleId
|
|
)
|
|
set(
|
|
personalReplyStyleId,
|
|
previous: baseline.personalReplyStyleId,
|
|
key: Keys.personalReplyStyleId
|
|
)
|
|
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 {
|
|
return try JSONDecoder().decode(PersonalDictionary.self, from: data)
|
|
} 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)")
|
|
}
|
|
}
|
|
|
|
/// Splits the distilled personal style out of the voice-polish selection.
|
|
///
|
|
/// Before this migration one `activePolishStyleId` drove both pipelines, so
|
|
/// selecting a learned pack let it outrank the core filler cleanup, and
|
|
/// selecting a built-in silently disabled personalization for AI replies —
|
|
/// "clean voice input" and "replies that sound like me" were mutually
|
|
/// exclusive. Runs after `migrateLegacyPolishStyleIfNeeded`, which may
|
|
/// itself rewrite `activePolishStyleId`.
|
|
private static func migratePersonalReplyStyleIfNeeded(
|
|
configuration: inout AppGroupConfiguration,
|
|
defaults: UserDefaults
|
|
) {
|
|
guard !defaults.bool(forKey: Keys.personalReplyStyleMigrated) else { return }
|
|
defer { defaults.set(true, forKey: Keys.personalReplyStyleMigrated) }
|
|
|
|
let catalog = configuration.polishStyleCatalog
|
|
let active = catalog.entries.first { $0.id == configuration.activePolishStyleId }
|
|
if let active, PolishStylePackCatalog.isPersonalReplyStyle(active) {
|
|
// Keep the personal style working where it now belongs, and give
|
|
// voice input its cleanup back.
|
|
configuration.personalReplyStyleId = active.id
|
|
configuration.activePolishStyleId = PolishStylePackCatalog.defaultID
|
|
} else if let latest = PolishStylePackCatalog.latestPersonalReplyStyle(userCatalog: catalog) {
|
|
// These users generated a personal style but were not using it: the
|
|
// single selector forced them to pick voice cleanup instead. Turning
|
|
// it on for replies restores the intent behind generating it.
|
|
configuration.personalReplyStyleId = latest.id
|
|
} else {
|
|
configuration.personalReplyStyleId = ""
|
|
}
|
|
defaults.set(configuration.activePolishStyleId, forKey: Keys.activePolishStyleId)
|
|
defaults.set(configuration.personalReplyStyleId, forKey: Keys.personalReplyStyleId)
|
|
}
|
|
|
|
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.<id>`.
|
|
return resolveAPIKey(defaults: defaults, providerId: providerId, preferICloudSync: preferICloudSync)
|
|
}
|
|
}
|