Files
2026-08-25 13:42:00 +08:00

606 lines
27 KiB
Swift

// ProviderConfig.swift
// OSGKeyboard · Shared
//
// User's LLM configuration. Persisted in App Group UserDefaults so both
// the main app and keyboard extension read the same values.
//
// `apiKey` is the exception: it lives in the Keychain (see
// `Keychain.swift`) for at-rest encryption. The first time this struct
// inits after upgrade, a legacy plaintext value from UserDefaults is
// migrated to the Keychain and removed from UserDefaults.
import Combine
import Foundation
/// UI-owned ObservableObject; construct and mutate it on the main thread.
/// `@unchecked Sendable` does not make `@Published` thread-safe. Credential
/// observers write only Keychain, while non-secret configuration uses App Group defaults.
public final class ProviderConfig: ObservableObject, @unchecked Sendable {
public static let shared = ProviderConfig()
@Published public var providerId: String {
didSet {
guard !isApplyingConfiguration, providerId != configuration.providerId else { return }
configuration.providerId = providerId
isSyncingProviderAPIKey = true
apiKey = configuration.apiKey
isSyncingProviderAPIKey = false
persistConfiguration()
}
}
@Published public var baseURL: String {
didSet {
guard !isApplyingConfiguration, baseURL != configuration.baseURL else { return }
configuration.baseURL = baseURL
persistConfiguration()
}
}
/// Its observer updates only the provider-scoped Keychain item; the value
/// must never enter `configuration` or App Group UserDefaults.
@Published public var apiKey: String {
didSet {
guard oldValue != apiKey, !isSyncingProviderAPIKey else { return }
do {
try Keychain.setAPIKey(
apiKey,
for: providerId,
useICloudSync: configuration.settingsICloudSyncEnabled
)
} catch {
isSyncingProviderAPIKey = true
apiKey = oldValue
isSyncingProviderAPIKey = false
OSGLog.config.warning("Keychain write failed: \(error.localizedDescription, privacy: .public)")
}
}
}
@Published public var model: String {
didSet {
guard !isApplyingConfiguration, model != configuration.model else { return }
configuration.model = model
persistConfiguration()
}
}
@Published public var asrProviderId: String {
didSet {
guard !isApplyingConfiguration, asrProviderId != configuration.asrProviderId else { return }
configuration.asrProviderId = asrProviderId
isSyncingASRProviderAPIKey = true
asrApiKey = configuration.asrApiKey
isSyncingASRProviderAPIKey = false
persistConfiguration()
}
}
@Published public var asrBaseURL: String {
didSet {
guard !isApplyingConfiguration, asrBaseURL != configuration.asrBaseURL else { return }
configuration.asrBaseURL = asrBaseURL
persistConfiguration()
}
}
@Published public var asrApiKey: String {
didSet {
guard oldValue != asrApiKey, !isSyncingASRProviderAPIKey else { return }
do {
try Keychain.setASRAPIKey(
asrApiKey,
for: asrProviderId,
useICloudSync: configuration.settingsICloudSyncEnabled
)
} catch {
isSyncingASRProviderAPIKey = true
asrApiKey = oldValue
isSyncingASRProviderAPIKey = false
OSGLog.config.warning("ASR Keychain write failed: \(error.localizedDescription, privacy: .public)")
}
}
}
@Published public var asrModel: String {
didSet {
guard !isApplyingConfiguration, asrModel != configuration.asrModel else { return }
configuration.asrModel = asrModel
persistConfiguration()
}
}
@Published public var modeId: String {
didSet {
guard !isApplyingConfiguration, modeId != configuration.modeId else { return }
configuration.modeId = modeId
persistConfiguration()
}
}
@Published public var localeId: String {
didSet {
guard !isApplyingConfiguration, localeId != configuration.localeId else { return }
configuration.localeId = localeId
persistConfiguration(postConfigChanged: true)
}
}
/// "local" → on-device ASR + user's LLM polish (requires user API key).
/// "cloud" → user's cloud ASR + user's cloud LLM polish (independent picks).
@Published public var engineMode: String {
didSet {
guard !isApplyingConfiguration, engineMode != configuration.engineMode else { return }
configuration.engineMode = engineMode
persistConfiguration(postConfigChanged: true)
}
}
/// Orthogonal to `engineMode`: direct provider credentials or an OSG
/// scope-limited grant. Local and BYOK behavior remain the default.
@Published public var credentialSource: CredentialSource {
didSet {
guard !isApplyingConfiguration,
credentialSource != configuration.credentialSource else { return }
configuration.credentialSource = credentialSource
persistConfiguration(postConfigChanged: true)
}
}
@Published public var hasCompletedOnboarding: Bool {
didSet {
guard !isApplyingConfiguration,
hasCompletedOnboarding != configuration.hasCompletedOnboarding else { return }
configuration.hasCompletedOnboarding = hasCompletedOnboarding
// Mirror to the reboot-durable Keychain marker so a device restart
// can never resurrect the onboarding flow (or lose a replay reset).
let newValue = hasCompletedOnboarding
OSGLog.config.info("[onboarding] didSet → \(newValue, privacy: .public), mirroring to Keychain")
Keychain.setOnboardingCompleted(hasCompletedOnboarding)
if hasCompletedOnboarding {
// Persist page reset immediately, but defer the @Published bump
// so MainAppRoot's OnboardingView → MainTabView swap is not
// coalesced with an in-flow page update (can freeze step 6).
configuration.onboardingPage = 0
let needsPublishedPageReset = onboardingPage != 0
persistConfiguration()
if needsPublishedPageReset {
Task { @MainActor in
guard self.hasCompletedOnboarding else { return }
self.onboardingPage = 0
}
}
return
}
persistConfiguration()
}
}
/// Persisted onboarding step so returning from Settings does not reset progress.
@Published public var onboardingPage: Int {
didSet {
guard !isApplyingConfiguration, onboardingPage != configuration.onboardingPage else { return }
configuration.onboardingPage = onboardingPage
persistConfiguration()
}
}
/// User confirmed that Cloud polish sends transcripts to their configured third-party API.
@Published public var hasAcknowledgedCloudSharing: Bool {
didSet {
guard !isApplyingConfiguration,
hasAcknowledgedCloudSharing != configuration.hasAcknowledgedCloudSharing else { return }
configuration.hasAcknowledgedCloudSharing = hasAcknowledgedCloudSharing
persistConfiguration()
}
}
/// Host-app UI language. Also mirrored to the App Group for the keyboard extension.
@Published public var uiLanguage: AppUILanguage {
didSet {
guard !isApplyingConfiguration, uiLanguage != configuration.uiLanguage else { return }
configuration.uiLanguage = uiLanguage
persistConfiguration()
}
}
/// Whether to translate the transcript into
/// `translationTargetLocaleId` before insertion. **Derived** —
/// translation is on iff the user has selected a target locale
/// (i.e. the persisted id is anything other than
/// `TranslationLanguageCatalog.offLocaleId`). Default off.
public var translationEnabled: Bool {
configuration.translationEnabled
}
/// BCP-47-ish target language id (e.g. `en`, `ja`, `ko`) the
/// translate-and-polish prompt should produce. Default `"off"` —
/// translation is opt-in. Persisted in the App Group so the keyboard
/// extension can honour it (and so the chip on the keyboard reflects
/// the user's choice without a host-app round-trip).
@Published public var translationTargetLocaleId: String {
didSet {
guard !isApplyingConfiguration,
translationTargetLocaleId != configuration.translationTargetLocaleId else { return }
configuration.translationTargetLocaleId = translationTargetLocaleId
persistConfiguration(postConfigChanged: true)
}
}
/// Which hand the user holds the phone with — mirrors to the keyboard
/// extension so delete / space can swap on the bottom row.
@Published public var handednessPreference: HandednessPreference {
didSet {
guard !isApplyingConfiguration,
handednessPreference != configuration.handednessPreference else { return }
configuration.handednessPreference = handednessPreference
persistConfiguration(postConfigChanged: true)
}
}
/// Typing-grid haptic strength (off / light / strong). Default is light.
@Published public var keyboardHapticIntensity: KeyboardHapticIntensity {
didSet {
guard !isApplyingConfiguration,
keyboardHapticIntensity != configuration.keyboardHapticIntensity else { return }
configuration.keyboardHapticIntensity = keyboardHapticIntensity
persistConfiguration(postConfigChanged: true)
}
}
/// Controls whether fun styles use the full safety envelope or the
/// formatting-only high-strength path.
@Published public var polishIntensity: PolishIntensity {
didSet {
guard !isApplyingConfiguration,
polishIntensity != configuration.polishIntensity else { return }
configuration.polishIntensity = polishIntensity
persistConfiguration(postConfigChanged: true)
}
}
/// Soft AI-mode answer length preference (short / medium / detailed).
@Published public var aiResponseLength: AIResponseLength {
didSet {
guard !isApplyingConfiguration,
aiResponseLength != configuration.aiResponseLength else { return }
configuration.aiResponseLength = aiResponseLength
persistConfiguration(postConfigChanged: true)
}
}
/// Whether the pipeline should run translate-and-polish (not just
/// polish). Both engines honour the selected target locale.
public var isTranslationEffective: Bool {
configuration.isTranslationEffective
}
/// Translation picker visibility — available on both engines.
public var isTranslationRowVisible: Bool { true }
/// Enables provider-specific reasoning / thinking controls when the
/// selected polish LLM supports them.
@Published public var llmThinkingEnabled: Bool {
didSet {
guard !isApplyingConfiguration,
llmThinkingEnabled != configuration.llmThinkingEnabled else { return }
configuration.llmThinkingEnabled = llmThinkingEnabled
persistConfiguration(postConfigChanged: true)
}
}
/// When enabled, the keyboard records clipboard text into a local history list.
@Published public var clipboardHistoryEnabled: Bool {
didSet {
guard !isApplyingConfiguration,
clipboardHistoryEnabled != configuration.clipboardHistoryEnabled else { return }
configuration.clipboardHistoryEnabled = clipboardHistoryEnabled
persistConfiguration(postConfigChanged: true)
}
}
/// When enabled (and history is on), show the newest clipboard item as a suggestion strip.
@Published public var clipboardCandidateBarEnabled: Bool {
didSet {
guard !isApplyingConfiguration,
clipboardCandidateBarEnabled != configuration.clipboardCandidateBarEnabled else { return }
configuration.clipboardCandidateBarEnabled = clipboardCandidateBarEnabled
persistConfiguration(postConfigChanged: true)
}
}
/// When enabled, the host app tries to return to the source app after a cold-start handoff.
@Published public var flowSkipAppSwitch: Bool {
didSet {
guard !isApplyingConfiguration, flowSkipAppSwitch != configuration.flowSkipAppSwitch else { return }
configuration.flowSkipAppSwitch = flowSkipAppSwitch
persistConfiguration()
}
}
/// Retained for storage compatibility; persistent PiP sessions do not expire from inactivity.
@Published public var flowInactivityDuration: FlowInactivityDuration {
didSet {
guard !isApplyingConfiguration,
flowInactivityDuration != configuration.flowInactivityDuration else { return }
configuration.flowInactivityDuration = flowInactivityDuration
persistConfiguration()
}
}
/// Diagnostic switch: disable to isolate whether the custom language model
/// is causing local SpeechAnalyzer to return empty results.
@Published public var localASRCustomLanguageModelEnabled: Bool {
didSet {
guard !isApplyingConfiguration,
localASRCustomLanguageModelEnabled != configuration.localASRCustomLanguageModelEnabled else {
return
}
configuration.localASRCustomLanguageModelEnabled = localASRCustomLanguageModelEnabled
persistConfiguration()
}
}
public var isConfigured: Bool {
if isLocalEngine {
return isPolishConfigured
}
return isASRConfigured && isPolishConfigured
}
public var isPolishConfigured: Bool {
if credentialSource == .managed { return true }
guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return false
}
return !baseURL.isEmpty && !model.isEmpty
}
public var isASRConfigured: Bool {
guard !isLocalEngine else { return true }
if credentialSource == .managed { return true }
let key = asrApiKey.trimmingCharacters(in: .whitespacesAndNewlines)
let hasKey: Bool = {
if asrProviderId == "volcengine" {
return VolcengineASRFields.parse(apiKey: key).hasUsableCredentials
}
return !key.isEmpty
}()
return hasKey
&& (!asrBaseURL.isEmpty || CloudASRModelCatalog.strategy(for: asrProviderId) != .prompt)
}
/// On-device ASR only; no cloud API required.
public var isLocalEngine: Bool { configuration.isLocalEngine }
/// Polish provider used by the local engine (same Settings selection as cloud polish).
public var localModeProviderId: String {
providerId
}
private let defaults: UserDefaults
private var configuration: AppGroupConfiguration
private var persistedConfigurationSnapshot: AppGroupConfiguration
/// Suppresses `@Published` observer persistence while a complete snapshot
/// or preset is applied, preventing reentrant writes of partial state.
private var isApplyingConfiguration = false
private var isSyncingProviderAPIKey = false
private var isSyncingASRProviderAPIKey = false
public init(defaults: UserDefaults? = nil) {
guard let resolvedDefaults = defaults ?? AppGroup.defaultsIfAvailable else {
preconditionFailure(
"ProviderConfig requires App Group or injected UserDefaults — " +
"check AppGroup.isAvailable before constructing."
)
}
self.defaults = resolvedDefaults
let loadedConfiguration = AppGroupConfiguration.load(fromAvailable: resolvedDefaults)
self.configuration = loadedConfiguration
self.persistedConfigurationSnapshot = loadedConfiguration
// Fresh app container (reinstall after delete): wipe stale Keychain
// onboarding so the welcome flow shows again. Reboot races still use
// Keychain restore when the install identity already exists.
let isFreshInstall = Keychain.beginInstallIdentityIfNeeded()
if isFreshInstall {
configuration.hasCompletedOnboarding = false
resolvedDefaults.set(false, forKey: AppGroupConfiguration.Keys.hasCompletedOnboarding)
OSGLog.config.info("[onboarding] init: fresh install → force hasCompletedOnboarding=false")
} else {
// Onboarding completion must survive a device reboot. App Group
// UserDefaults can transiently read empty right after boot, which would
// falsely re-show onboarding. Trust the durable Keychain marker when the
// App Group value looks unset, and backfill it once the App Group value
// is confirmed true (covers users onboarded before this safeguard).
let appGroupOnboarding = configuration.hasCompletedOnboarding
let keychainOnboarding = Keychain.hasCompletedOnboarding()
// Distinguish "key absent" (nil → plist not loaded / data-protection race)
// from "key present == false" (something actually wrote false).
let rawKeyPresent = resolvedDefaults.object(
forKey: AppGroupConfiguration.Keys.hasCompletedOnboarding
) != nil
OSGLog.config.info(
"[onboarding] init: appGroup=\(appGroupOnboarding, privacy: .public) (keyPresent=\(rawKeyPresent, privacy: .public)), keychain=\(keychainOnboarding, privacy: .public)"
)
if appGroupOnboarding {
Keychain.setOnboardingCompleted(true)
} else if keychainOnboarding {
configuration.hasCompletedOnboarding = true
OSGLog.config.info(
"[onboarding] init: App Group read false but Keychain true → restored to true"
)
}
}
let finalOnboarding = configuration.hasCompletedOnboarding
OSGLog.config.info("[onboarding] init: final=\(finalOnboarding, privacy: .public)")
isApplyingConfiguration = true
providerId = configuration.providerId
baseURL = configuration.baseURL
apiKey = configuration.apiKey
model = configuration.model
asrProviderId = configuration.asrProviderId
asrBaseURL = configuration.asrBaseURL
asrModel = configuration.asrModel
modeId = configuration.modeId
localeId = configuration.localeId
engineMode = configuration.engineMode
credentialSource = configuration.credentialSource
hasCompletedOnboarding = configuration.hasCompletedOnboarding
onboardingPage = configuration.onboardingPage
hasAcknowledgedCloudSharing = configuration.hasAcknowledgedCloudSharing
uiLanguage = configuration.uiLanguage
translationTargetLocaleId = configuration.translationTargetLocaleId
handednessPreference = configuration.handednessPreference
keyboardHapticIntensity = configuration.keyboardHapticIntensity
polishIntensity = configuration.polishIntensity
aiResponseLength = configuration.aiResponseLength
llmThinkingEnabled = configuration.llmThinkingEnabled
clipboardHistoryEnabled = configuration.clipboardHistoryEnabled
clipboardCandidateBarEnabled = configuration.clipboardCandidateBarEnabled
flowSkipAppSwitch = configuration.flowSkipAppSwitch
flowInactivityDuration = configuration.flowInactivityDuration
localASRCustomLanguageModelEnabled = configuration.localASRCustomLanguageModelEnabled
isSyncingProviderAPIKey = true
apiKey = configuration.apiKey
isSyncingProviderAPIKey = false
isSyncingASRProviderAPIKey = true
asrApiKey = configuration.asrApiKey
isSyncingASRProviderAPIKey = false
isApplyingConfiguration = false
}
public func reset() {
isApplyingConfiguration = true
let polishPreset = LLMProvider.provider(id: AppGroupConfiguration.defaultPolishProviderId)
let asrPreset = LLMProvider.provider(id: AppGroupConfiguration.defaultCloudASRProviderId)
providerId = polishPreset.id
baseURL = polishPreset.defaultBaseURL
apiKey = ""
model = polishPreset.defaultModel
asrProviderId = asrPreset.id
asrBaseURL = asrPreset.defaultBaseURL
asrModel = CloudASRModelCatalog.defaultModel(for: asrPreset.id)
asrApiKey = ""
handednessPreference = .left
keyboardHapticIntensity = .default
polishIntensity = .default
aiResponseLength = .default
localASRCustomLanguageModelEnabled = true
llmThinkingEnabled = false
clipboardHistoryEnabled = false
clipboardCandidateBarEnabled = false
hasAcknowledgedCloudSharing = false
credentialSource = .byok
configuration.providerId = polishPreset.id
configuration.baseURL = polishPreset.defaultBaseURL
configuration.model = polishPreset.defaultModel
configuration.asrProviderId = asrPreset.id
configuration.asrBaseURL = asrPreset.defaultBaseURL
configuration.asrModel = CloudASRModelCatalog.defaultModel(for: asrPreset.id)
configuration.handednessPreference = .left
configuration.keyboardHapticIntensity = .default
configuration.polishIntensity = .default
configuration.aiResponseLength = .default
configuration.localASRCustomLanguageModelEnabled = true
configuration.llmThinkingEnabled = false
configuration.clipboardHistoryEnabled = false
configuration.clipboardCandidateBarEnabled = false
configuration.hasAcknowledgedCloudSharing = false
configuration.credentialSource = .byok
isApplyingConfiguration = false
persistConfiguration()
}
private func persistConfiguration(postConfigChanged: Bool = false) {
configuration.saveChanges(since: persistedConfigurationSnapshot, to: defaults)
persistedConfigurationSnapshot = configuration
if postConfigChanged {
AppGroupConfigDarwin.postConfigChanged()
}
scheduleSettingsCloudPushIfEnabled()
}
/// Re-read App Group defaults after a cloud pull updates the cache.
public func reloadFromPersistedStorage() {
let persisted = AppGroupConfiguration.load(fromAvailable: defaults)
var fresh = persisted
// Keep the reboot-durable onboarding marker authoritative across cloud
// pulls, matching the resilience applied at init.
let freshOnboarding = fresh.hasCompletedOnboarding
let keychainOnboarding = Keychain.hasCompletedOnboarding()
OSGLog.config.info(
"[onboarding] reload: appGroup=\(freshOnboarding, privacy: .public), keychain=\(keychainOnboarding, privacy: .public)"
)
if freshOnboarding {
Keychain.setOnboardingCompleted(true)
} else if keychainOnboarding {
fresh.hasCompletedOnboarding = true
OSGLog.config.info("[onboarding] reload: App Group read false but Keychain true → restored to true")
}
isApplyingConfiguration = true
configuration = fresh
persistedConfigurationSnapshot = persisted
providerId = fresh.providerId
baseURL = fresh.baseURL
model = fresh.model
asrProviderId = fresh.asrProviderId
asrBaseURL = fresh.asrBaseURL
asrModel = fresh.asrModel
modeId = fresh.modeId
localeId = fresh.localeId
engineMode = fresh.engineMode
credentialSource = fresh.credentialSource
hasCompletedOnboarding = fresh.hasCompletedOnboarding
onboardingPage = fresh.onboardingPage
hasAcknowledgedCloudSharing = fresh.hasAcknowledgedCloudSharing
uiLanguage = fresh.uiLanguage
translationTargetLocaleId = fresh.translationTargetLocaleId
handednessPreference = fresh.handednessPreference
keyboardHapticIntensity = fresh.keyboardHapticIntensity
polishIntensity = fresh.polishIntensity
aiResponseLength = fresh.aiResponseLength
llmThinkingEnabled = fresh.llmThinkingEnabled
clipboardHistoryEnabled = fresh.clipboardHistoryEnabled
clipboardCandidateBarEnabled = fresh.clipboardCandidateBarEnabled
flowSkipAppSwitch = fresh.flowSkipAppSwitch
flowInactivityDuration = fresh.flowInactivityDuration
localASRCustomLanguageModelEnabled = fresh.localASRCustomLanguageModelEnabled
isSyncingProviderAPIKey = true
apiKey = fresh.apiKey
isSyncingProviderAPIKey = false
isSyncingASRProviderAPIKey = true
asrApiKey = fresh.asrApiKey
isSyncingASRProviderAPIKey = false
isApplyingConfiguration = false
}
private func scheduleSettingsCloudPushIfEnabled() {
guard configuration.settingsICloudSyncEnabled else { return }
Task { @MainActor in
try? await SettingsCloudSync.shared.pushLocalIfEnabled()
}
}
public func apply(preset: LLMProvider) {
isApplyingConfiguration = true
providerId = preset.id
if !preset.defaultBaseURL.isEmpty {
baseURL = preset.defaultBaseURL
}
if !preset.defaultModel.isEmpty {
model = preset.defaultModel
}
configuration.providerId = providerId
configuration.baseURL = baseURL
configuration.model = model
isSyncingProviderAPIKey = true
apiKey = configuration.apiKey
isSyncingProviderAPIKey = false
isApplyingConfiguration = false
persistConfiguration()
}
public func applyAsr(preset: LLMProvider) {
isApplyingConfiguration = true
engineMode = "cloud"
asrProviderId = preset.id
if !preset.defaultBaseURL.isEmpty {
asrBaseURL = preset.defaultBaseURL
}
asrModel = CloudASRModelCatalog.defaultModel(for: preset.id)
configuration.asrProviderId = asrProviderId
configuration.asrBaseURL = asrBaseURL
configuration.asrModel = asrModel
configuration.engineMode = engineMode
isSyncingASRProviderAPIKey = true
asrApiKey = configuration.asrApiKey
isSyncingASRProviderAPIKey = false
isApplyingConfiguration = false
persistConfiguration(postConfigChanged: true)
}
}