feat(account): add managed credits and cloud gateway

Introduce optional Apple account-backed credits with scoped gateway access while preserving local and BYOK paths. Refresh assistant behavior, tests, privacy disclosures, docs, and the website for the 2.0 experience.
This commit is contained in:
Rocky
2026-08-20 11:43:21 +08:00
parent 0f9280bd00
commit 498f407585
301 changed files with 19221 additions and 10891 deletions
@@ -13,7 +13,7 @@ public struct AIAgentSkillLayout: Codable, Equatable, Sendable {
public static let defaultEnabledIDs = [
AIClipboardSkillCatalog.replyID,
AIClipboardSkillCatalog.summarizeID,
AIClipboardSkillCatalog.translateID,
AIClipboardSkillCatalog.translateID
]
/// Keyboard chip order. Unknown / unconfirmed IDs are dropped on sanitize.
+1 -1
View File
@@ -110,7 +110,7 @@ public enum AIUserSkillLimits {
"number",
"at",
"tray.fill",
"quote.bubble.fill",
"quote.bubble.fill"
]
}
@@ -28,6 +28,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
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"
public static let hasCompletedOnboarding = "config.hasCompletedOnboarding"
public static let onboardingPage = "config.onboardingPage"
public static let hasAcknowledgedCloudSharing = "config.hasAcknowledgedCloudSharing"
@@ -87,6 +89,7 @@ public struct AppGroupConfiguration: Sendable, Equatable {
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
@@ -146,11 +149,13 @@ public struct AppGroupConfiguration: Sendable, Equatable {
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.
@@ -161,7 +166,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
}
public var isPolishKeyMissing: Bool {
apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
guard credentialSource == .byok else { return false }
return apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
public var isCloudAPIKeyMissingForVoiceInput: Bool {
@@ -187,8 +193,15 @@ public struct AppGroupConfiguration: Sendable, Equatable {
)
}
public func makeClient() -> LLMClient {
OpenAICompatibleClient(
public func makeClient(taskKind: ManagedGatewayTaskKind? = nil) -> LLMClient {
if credentialSource == .managed {
return ManagedLLMClient(
capability: .polish,
taskKind: taskKind,
grants: GatewayGrantCoordinator()
)
}
return OpenAICompatibleClient(
baseURL: baseURL,
apiKey: apiKey,
model: model,
@@ -255,6 +268,9 @@ public struct AppGroupConfiguration: Sendable, Equatable {
// 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)
@@ -387,7 +403,7 @@ public struct AppGroupConfiguration: Sendable, Equatable {
// who later choose 30m / 10m again keep that choice.
let previousDefaults: Set<String> = [
FlowInactivityDuration.thirtyMinutes.rawValue,
FlowInactivityDuration.tenMinutes.rawValue,
FlowInactivityDuration.tenMinutes.rawValue
]
if previousDefaults.contains(config.flowInactivityDuration.rawValue) {
config.flowInactivityDuration = .default
@@ -415,6 +431,7 @@ public struct AppGroupConfiguration: Sendable, Equatable {
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)
@@ -438,6 +455,123 @@ public struct AppGroupConfiguration: Sendable, Equatable {
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(
llmThinkingEnabled,
previous: baseline.llmThinkingEnabled,
key: Keys.llmThinkingEnabled
)
set(
clipboardHistoryEnabled,
previous: baseline.clipboardHistoryEnabled,
key: Keys.clipboardHistoryEnabled
)
set(
clipboardCandidateBarEnabled,
previous: baseline.clipboardCandidateBarEnabled,
key: Keys.clipboardCandidateBarEnabled
)
set(
activePolishStyleId,
previous: baseline.activePolishStyleId,
key: Keys.activePolishStyleId
)
set(flowSkipAppSwitch, previous: baseline.flowSkipAppSwitch, key: Keys.flowSkipAppSwitch)
set(
flowInactivityDuration.rawValue,
previous: baseline.flowInactivityDuration.rawValue,
key: Keys.flowInactivityDuration
)
set(
localASRCustomLanguageModelEnabled,
previous: baseline.localASRCustomLanguageModelEnabled,
key: Keys.localASRCustomLanguageModelEnabled
)
set(
personalDictionaryICloudSyncEnabled,
previous: baseline.personalDictionaryICloudSyncEnabled,
key: Keys.personalDictionaryICloudSyncEnabled
)
set(
settingsICloudSyncEnabled,
previous: baseline.settingsICloudSyncEnabled,
key: Keys.settingsICloudSyncEnabled
)
if personalDictionary != baseline.personalDictionary {
Self.encodePersonalDictionary(personalDictionary, to: defaults)
}
if polishStyleCatalog != baseline.polishStyleCatalog {
Self.encodePolishStyleCatalog(polishStyleCatalog, to: defaults)
}
}
// MARK: - Private helpers
private static func decodePersonalDictionary(from defaults: UserDefaults) -> PersonalDictionary {
@@ -514,7 +648,7 @@ public struct AppGroupConfiguration: Sendable, Equatable {
"work": "builtin.formal",
"document": "builtin.structured",
"todo": "builtin.structured",
"social_lifestyle": "builtin.xhs",
"social_lifestyle": "builtin.xhs"
]
if let legacyID = defaults.string(forKey: Keys.legacyPolishScenarioId),
let mappedID = legacyMappings[legacyID] {
@@ -96,7 +96,7 @@ enum BuiltinPolishStyleLoader {
// fall back to main / class bundle the same way other Shared resources do.
var bundles: [Bundle] = [
Bundle(for: BundleToken.self),
Bundle.main,
Bundle.main
]
#if !os(macOS)
if let shared = Bundle(identifier: "com.osgkeyboard.ios.shared") {
@@ -71,7 +71,7 @@ public enum CloudASRModelCatalog {
"openrouter",
"mimo",
"volcengine",
"custom",
"custom"
]
/// Sync Fun-ASR Flash base64 upload, 5 min, supports context + vocabulary.
@@ -0,0 +1,16 @@
// CredentialSource.swift
// OSGKeyboard · Shared
//
// Credential ownership is independent from the ASR engine. This keeps local
// ASR + managed polish, direct BYOK cloud, and fully managed flows composable.
import Foundation
public enum CredentialSource: String, CaseIterable, Codable, Sendable {
case byok
case managed
public static func fromStored(_ value: String?) -> CredentialSource {
CredentialSource(rawValue: value ?? "") ?? .byok
}
}
@@ -20,8 +20,8 @@ public struct FlowCommand: Codable, Equatable, Sendable {
case submitAIQuestion
}
/// Wire version that includes submitAIQuestion + aiQuestionText.
public static let currentProtocolVersion = 5
/// Wire version that includes managed-gateway AI task intent.
public static let currentProtocolVersion = 6
public let protocolVersion: Int
public let sessionId: UUID
@@ -41,6 +41,8 @@ public struct FlowCommand: Codable, Equatable, Sendable {
public let aiConversationID: UUID?
/// Prefilled question used only by `.submitAIQuestion`.
public let aiQuestionText: String?
/// Fine-grained managed-gateway intent for AI question submissions.
public let aiTaskKind: ManagedGatewayTaskKind?
/// Clipboard-skill thinking override. Nil keeps AI-mode default (on).
public let aiThinkingEnabled: Bool?
/// Absolute wall-clock deadlines survive extension reconstruction.
@@ -62,6 +64,7 @@ public struct FlowCommand: Codable, Equatable, Sendable {
sourceHistoryEntryRevision: Int64? = nil,
aiConversationID: UUID? = nil,
aiQuestionText: String? = nil,
aiTaskKind: ManagedGatewayTaskKind? = nil,
aiThinkingEnabled: Bool? = nil,
startDeadlineAt: TimeInterval? = nil,
processingDeadlineAt: TimeInterval? = nil
@@ -80,6 +83,7 @@ public struct FlowCommand: Codable, Equatable, Sendable {
self.sourceHistoryEntryRevision = sourceHistoryEntryRevision
self.aiConversationID = aiConversationID
self.aiQuestionText = aiQuestionText
self.aiTaskKind = aiTaskKind
self.aiThinkingEnabled = aiThinkingEnabled
self.startDeadlineAt = startDeadlineAt
self.processingDeadlineAt = processingDeadlineAt
@@ -37,7 +37,7 @@ public struct FlowFieldContext: Codable, Equatable, Sendable {
keyboardType ?? "",
returnKeyType ?? "",
precedingText.map { String($0.suffix(80)) } ?? "",
followingText.map { String($0.prefix(40)) } ?? "",
followingText.map { String($0.prefix(40)) } ?? ""
].joined(separator: "|")
}
}
@@ -13,6 +13,8 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
public let aiConversationID: UUID?
/// When set with `.aiQuestion`, host skips ASR and answers this text.
public let aiQuestionText: String?
/// Fine-grained managed-gateway intent. Regular questions keep the default.
public let aiTaskKind: ManagedGatewayTaskKind?
/// Clipboard-skill thinking override. Nil keeps AI-mode default (on).
public let aiThinkingEnabled: Bool?
@@ -25,6 +27,7 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
sourceHistoryEntryRevision: Int64? = nil,
aiConversationID: UUID? = nil,
aiQuestionText: String? = nil,
aiTaskKind: ManagedGatewayTaskKind? = nil,
aiThinkingEnabled: Bool? = nil
) {
self.mode = mode
@@ -33,6 +36,7 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
self.sourceHistoryEntryRevision = sourceHistoryEntryRevision
self.aiConversationID = aiConversationID
self.aiQuestionText = aiQuestionText
self.aiTaskKind = aiTaskKind
self.aiThinkingEnabled = aiThinkingEnabled
}
@@ -53,12 +57,14 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
public static func aiQuestion(
conversationID: UUID,
prefilledQuestion: String? = nil,
taskKind: ManagedGatewayTaskKind = .aiQuestion,
thinkingEnabled: Bool? = nil
) -> FlowUtteranceRequest {
FlowUtteranceRequest(
mode: .aiQuestion,
aiConversationID: conversationID,
aiQuestionText: prefilledQuestion,
aiTaskKind: taskKind,
aiThinkingEnabled: thinkingEnabled
)
}
@@ -4,8 +4,8 @@
// Formats the user dictionary for cloud ASR bias (hotwords, Alibaba
// vocabulary entries, or Whisper-style prompt fragments).
import Foundation
import CryptoKit
import Foundation
public struct AlibabaHotwordEntry: Codable, Sendable, Equatable {
public let text: String
@@ -225,7 +225,7 @@ extension PersonalDictionary {
createdAt: Date(timeIntervalSince1970: 0),
updatedAt: Date(timeIntervalSince1970: 0),
usageCount: 0
),
)
]
/// User entries plus built-in system terms (deduped by term).
@@ -64,7 +64,7 @@ public struct PolishStylePack: Codable, Equatable, Identifiable, Sendable {
"outranks global R5",
"may add emojis",
"allow mood emoji",
"allowsAddedEmoji",
"allowsAddedEmoji"
]
return markers.contains { prompt.localizedCaseInsensitiveContains($0) }
}
+29 -5
View File
@@ -9,8 +9,8 @@
// inits after upgrade, a legacy plaintext value from UserDefaults is
// migrated to the Keychain and removed from UserDefaults.
import Foundation
import Combine
import Foundation
/// UI-owned ObservableObject; construct and mutate it on the main thread.
/// `@unchecked Sendable` does not make `@Published` thread-safe. Credential
@@ -125,6 +125,16 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
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,
@@ -331,6 +341,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
}
public var isPolishConfigured: Bool {
if credentialSource == .managed { return true }
guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return false
}
@@ -339,6 +350,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
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" {
@@ -360,6 +372,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
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
@@ -374,7 +387,9 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
)
}
self.defaults = resolvedDefaults
self.configuration = AppGroupConfiguration.load(fromAvailable: 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
@@ -423,6 +438,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
modeId = configuration.modeId
localeId = configuration.localeId
engineMode = configuration.engineMode
credentialSource = configuration.credentialSource
hasCompletedOnboarding = configuration.hasCompletedOnboarding
onboardingPage = configuration.onboardingPage
hasAcknowledgedCloudSharing = configuration.hasAcknowledgedCloudSharing
@@ -469,6 +485,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
clipboardHistoryEnabled = false
clipboardCandidateBarEnabled = false
hasAcknowledgedCloudSharing = false
credentialSource = .byok
configuration.providerId = polishPreset.id
configuration.baseURL = polishPreset.defaultBaseURL
configuration.model = polishPreset.defaultModel
@@ -484,12 +501,14 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
configuration.clipboardHistoryEnabled = false
configuration.clipboardCandidateBarEnabled = false
configuration.hasAcknowledgedCloudSharing = false
configuration.credentialSource = .byok
isApplyingConfiguration = false
persistConfiguration()
}
private func persistConfiguration(postConfigChanged: Bool = false) {
configuration.save(to: defaults)
configuration.saveChanges(since: persistedConfigurationSnapshot, to: defaults)
persistedConfigurationSnapshot = configuration
if postConfigChanged {
AppGroupConfigDarwin.postConfigChanged()
}
@@ -498,7 +517,8 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
/// Re-read App Group defaults after a cloud pull updates the cache.
public func reloadFromPersistedStorage() {
var fresh = AppGroupConfiguration.load(fromAvailable: defaults)
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
@@ -514,6 +534,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
}
isApplyingConfiguration = true
configuration = fresh
persistedConfigurationSnapshot = persisted
providerId = fresh.providerId
baseURL = fresh.baseURL
model = fresh.model
@@ -523,6 +544,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
modeId = fresh.modeId
localeId = fresh.localeId
engineMode = fresh.engineMode
credentialSource = fresh.credentialSource
hasCompletedOnboarding = fresh.hasCompletedOnboarding
onboardingPage = fresh.onboardingPage
hasAcknowledgedCloudSharing = fresh.hasAcknowledgedCloudSharing
@@ -576,6 +598,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
public func applyAsr(preset: LLMProvider) {
isApplyingConfiguration = true
engineMode = "cloud"
asrProviderId = preset.id
if !preset.defaultBaseURL.isEmpty {
asrBaseURL = preset.defaultBaseURL
@@ -584,10 +607,11 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
configuration.asrProviderId = asrProviderId
configuration.asrBaseURL = asrBaseURL
configuration.asrModel = asrModel
configuration.engineMode = engineMode
isSyncingASRProviderAPIKey = true
asrApiKey = configuration.asrApiKey
isSyncingASRProviderAPIKey = false
isApplyingConfiguration = false
persistConfiguration()
persistConfiguration(postConfigChanged: true)
}
}
@@ -254,7 +254,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
activePolishStyleId.updatedAt,
llmThinkingEnabled.updatedAt,
flowSkipAppSwitch.updatedAt,
flowInactivityDuration.updatedAt,
flowInactivityDuration.updatedAt
].max() ?? .distantPast
}
@@ -184,7 +184,7 @@ public struct SyncedUsageStatisticsV2: Codable, Equatable, Sendable {
dictationCharacterCount: legacy.dictationCharacterCount,
translationCharacterCount: legacy.translationCharacterCount,
aiCharacterCount: legacy.aiCharacterCount
),
)
])
}
}
@@ -82,16 +82,16 @@ public enum TranslationLanguageCatalog {
/// "turn off" action is one tap away from any enabled state.
public static let all: [TranslationLanguage] = [
TranslationLanguage(id: offLocaleId, promptLanguageName: "", nativeName: ""),
TranslationLanguage(id: "en", promptLanguageName: "English", nativeName: "English"),
TranslationLanguage(id: "en", promptLanguageName: "English", nativeName: "English"),
TranslationLanguage(id: "zh-Hans", promptLanguageName: "Simplified Chinese", nativeName: "简体中文"),
TranslationLanguage(id: "zh-Hant", promptLanguageName: "Traditional Chinese", nativeName: "繁體中文"),
TranslationLanguage(id: "ja", promptLanguageName: "Japanese", nativeName: "日本語"),
TranslationLanguage(id: "ko", promptLanguageName: "Korean", nativeName: "한국어"),
TranslationLanguage(id: "fr", promptLanguageName: "French", nativeName: "Français"),
TranslationLanguage(id: "de", promptLanguageName: "German", nativeName: "Deutsch"),
TranslationLanguage(id: "es", promptLanguageName: "Spanish", nativeName: "Español"),
TranslationLanguage(id: "ru", promptLanguageName: "Russian", nativeName: "Русский"),
TranslationLanguage(id: "pt", promptLanguageName: "Portuguese", nativeName: "Português"),
TranslationLanguage(id: "ja", promptLanguageName: "Japanese", nativeName: "日本語"),
TranslationLanguage(id: "ko", promptLanguageName: "Korean", nativeName: "한국어"),
TranslationLanguage(id: "fr", promptLanguageName: "French", nativeName: "Français"),
TranslationLanguage(id: "de", promptLanguageName: "German", nativeName: "Deutsch"),
TranslationLanguage(id: "es", promptLanguageName: "Spanish", nativeName: "Español"),
TranslationLanguage(id: "ru", promptLanguageName: "Russian", nativeName: "Русский"),
TranslationLanguage(id: "pt", promptLanguageName: "Portuguese", nativeName: "Português")
]
/// True when the given id is the "off" sentinel. Used by the picker
@@ -113,4 +113,4 @@ public enum TranslationLanguageCatalog {
}
return all.first { $0.id == offLocaleId } ?? all[0]
}
}
}
@@ -4,8 +4,8 @@
// App Group-backed Chinese input settings shared by the host app and
// keyboard extension. Fuzzy pairs are opt-in to avoid noisy candidates.
import Foundation
import Combine
import Foundation
public enum TypingInputSchema: String, CaseIterable, Identifiable, Codable, Sendable {
case fullPinyin = "osg_pinyin"
@@ -55,7 +55,7 @@ public struct VolcengineASRFields: Sendable, Equatable {
public var encodedAPIKey: String {
var object: [String: String] = [
"auth_mode": authMode.rawValue,
"resource_id": Self.fixedResourceID,
"resource_id": Self.fixedResourceID
]
// Persist both credential sets so toggling auth mode is non-destructive.
if !appID.isEmpty { object["app_id"] = appID }