Files
OSGKeyboard/OSGKeyboardShared/Models/ProviderConfig.swift
T
rockyandClaude Opus 5 0023741455 feat(app): add crash visibility, memory relief, and clipboard reply work
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>
2026-09-18 17:31:29 -07:00

667 lines
30 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 Reply may return several sendable variants. Default is on.
@Published public var multipleReplyVariantsEnabled: Bool {
didSet {
guard !isApplyingConfiguration,
multipleReplyVariantsEnabled != configuration.multipleReplyVariantsEnabled else {
return
}
configuration.multipleReplyVariantsEnabled = multipleReplyVariantsEnabled
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 (and history is on), a freshly copied replyable message is
/// auto-routed into the Reply flow when the keyboard opens. Default off.
@Published public var clipboardAutoModeEnabled: Bool {
didSet {
guard !isApplyingConfiguration,
clipboardAutoModeEnabled != configuration.clipboardAutoModeEnabled else { return }
configuration.clipboardAutoModeEnabled = clipboardAutoModeEnabled
persistConfiguration(postConfigChanged: true)
}
}
/// When enabled (and history is on), a freshly copied non-system-language
/// paste is auto-translated on open. Default off.
@Published public var clipboardAutoTranslateEnabled: Bool {
didSet {
guard !isApplyingConfiguration,
clipboardAutoTranslateEnabled != configuration.clipboardAutoTranslateEnabled else { return }
configuration.clipboardAutoTranslateEnabled = clipboardAutoTranslateEnabled
persistConfiguration(postConfigChanged: true)
}
}
/// When enabled (and history is on), a freshly copied email auto-drafts a
/// reply on open. Default off.
@Published public var clipboardAutoEmailReplyEnabled: Bool {
didSet {
guard !isApplyingConfiguration,
clipboardAutoEmailReplyEnabled != configuration.clipboardAutoEmailReplyEnabled else { return }
configuration.clipboardAutoEmailReplyEnabled = clipboardAutoEmailReplyEnabled
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
multipleReplyVariantsEnabled = configuration.multipleReplyVariantsEnabled
llmThinkingEnabled = configuration.llmThinkingEnabled
clipboardHistoryEnabled = configuration.clipboardHistoryEnabled
clipboardCandidateBarEnabled = configuration.clipboardCandidateBarEnabled
clipboardAutoModeEnabled = configuration.clipboardAutoModeEnabled
clipboardAutoTranslateEnabled = configuration.clipboardAutoTranslateEnabled
clipboardAutoEmailReplyEnabled = configuration.clipboardAutoEmailReplyEnabled
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
multipleReplyVariantsEnabled = true
localASRCustomLanguageModelEnabled = true
llmThinkingEnabled = false
clipboardHistoryEnabled = false
clipboardCandidateBarEnabled = false
clipboardAutoModeEnabled = false
clipboardAutoTranslateEnabled = false
clipboardAutoEmailReplyEnabled = 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.multipleReplyVariantsEnabled = true
configuration.localASRCustomLanguageModelEnabled = true
configuration.llmThinkingEnabled = false
configuration.clipboardHistoryEnabled = false
configuration.clipboardCandidateBarEnabled = false
configuration.clipboardAutoModeEnabled = false
configuration.clipboardAutoTranslateEnabled = false
configuration.clipboardAutoEmailReplyEnabled = 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
multipleReplyVariantsEnabled = fresh.multipleReplyVariantsEnabled
llmThinkingEnabled = fresh.llmThinkingEnabled
clipboardHistoryEnabled = fresh.clipboardHistoryEnabled
clipboardCandidateBarEnabled = fresh.clipboardCandidateBarEnabled
clipboardAutoModeEnabled = fresh.clipboardAutoModeEnabled
clipboardAutoTranslateEnabled = fresh.clipboardAutoTranslateEnabled
clipboardAutoEmailReplyEnabled = fresh.clipboardAutoEmailReplyEnabled
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)
}
}