537a68552a
Reduce perceived latency from key release to final text: - Adaptive chunking: 2.5s first chunk + 5s follow-ups so short utterances start on-device recognition while still recording. - Session-level ASR warmup and audio-format cache reuse to remove per-utterance cold-start of SpeechAnalyzer. - Mirror live pipelined partials to the keyboard transcript line via a new flow.transcriptionPartial App Group key + Darwin ping. Also commits the accumulated custom language model, Flow session, keyboard extension restructure, and Xiaomi MiMo provider work in progress on this branch.
263 lines
11 KiB
Swift
263 lines
11 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 {
|
|
// 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"
|
|
public static let modeId = "config.modeId"
|
|
public static let localeId = "config.localeId"
|
|
public static let engineMode = "config.engineMode"
|
|
public static let hasCompletedOnboarding = "config.hasCompletedOnboarding"
|
|
public static let onboardingPage = "config.onboardingPage"
|
|
public static let hasAcknowledgedCloudSharing = "config.hasAcknowledgedCloudSharing"
|
|
public static let uiLanguage = "config.uiLanguage"
|
|
public static let translationTargetLocaleId = "config.translationTargetLocaleId"
|
|
public static let handednessPreference = "config.handednessPreference"
|
|
public static let cursorDragNavigationEnabled = "config.cursorDragNavigationEnabled"
|
|
public static let polishIntensity = "config.polishIntensity"
|
|
public static let detectedAppContext = "config.detectedAppContext"
|
|
public static let detectedAppContextAt = "config.detectedAppContextAt"
|
|
public static let personalDictionary = "config.personalDictionary.v1"
|
|
}
|
|
|
|
// MARK: - Stored fields
|
|
|
|
public var providerId: String
|
|
public var baseURL: String
|
|
public var model: String
|
|
public var modeId: String
|
|
public var localeId: String
|
|
public var engineMode: String
|
|
public var hasCompletedOnboarding: Bool
|
|
public var onboardingPage: Int
|
|
public var hasAcknowledgedCloudSharing: Bool
|
|
public var uiLanguage: AppUILanguage
|
|
public var translationTargetLocaleId: String
|
|
public var handednessPreference: HandednessPreference
|
|
public var cursorDragNavigationEnabled: Bool
|
|
public var polishIntensity: PolishIntensity
|
|
public var personalDictionary: PersonalDictionary
|
|
|
|
// 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
|
|
}
|
|
|
|
/// Local engine pins the LLM step to DeepSeek; cloud uses the user's provider.
|
|
public var polishProviderIdOverride: String? {
|
|
engineMode == "local" ? "deepseek" : nil
|
|
}
|
|
|
|
public var isCloudAPIKeyMissingForVoiceInput: Bool {
|
|
guard engineMode == "cloud" else { return false }
|
|
return apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
}
|
|
|
|
/// API key lives in the Keychain (cross-process, encrypted at rest).
|
|
public var apiKey: String {
|
|
Keychain.apiKey(for: providerId) ?? ""
|
|
}
|
|
|
|
public func makeClient() -> LLMClient {
|
|
OpenAICompatibleClient(
|
|
baseURL: baseURL,
|
|
apiKey: apiKey,
|
|
model: model
|
|
)
|
|
}
|
|
|
|
// MARK: - Detected app context
|
|
|
|
public func detectedAppContext(from defaults: UserDefaults) -> (context: AppContext, observedAt: Date)? {
|
|
guard let raw = defaults.string(forKey: Keys.detectedAppContext),
|
|
let value = AppContext(rawValue: raw)
|
|
else { return nil }
|
|
let timestamp = defaults.object(forKey: Keys.detectedAppContextAt) as? Date ?? .distantPast
|
|
return (value, timestamp)
|
|
}
|
|
|
|
public mutating func setDetectedAppContext(_ context: AppContext, at date: Date = Date(), to defaults: UserDefaults) {
|
|
defaults.set(context.rawValue, forKey: Keys.detectedAppContext)
|
|
defaults.set(date, forKey: Keys.detectedAppContextAt)
|
|
}
|
|
|
|
// MARK: - Load / save
|
|
|
|
/// Loads configuration from App Group defaults. Returns `nil` when the suite is unavailable.
|
|
public static func load(from defaults: UserDefaults? = nil) -> AppGroupConfiguration? {
|
|
guard let store = defaults ?? AppGroup.defaultsIfAvailable else { return nil }
|
|
return load(fromAvailable: store)
|
|
}
|
|
|
|
/// Loads configuration from a known-available UserDefaults suite.
|
|
public static func load(fromAvailable defaults: UserDefaults) -> AppGroupConfiguration {
|
|
var config = AppGroupConfiguration(
|
|
providerId: defaults.string(forKey: Keys.providerId) ?? "openai",
|
|
baseURL: "",
|
|
model: "",
|
|
modeId: defaults.string(forKey: Keys.modeId) ?? "polish",
|
|
localeId: defaults.string(forKey: Keys.localeId) ?? "auto",
|
|
engineMode: defaults.string(forKey: Keys.engineMode) ?? "cloud",
|
|
hasCompletedOnboarding: defaults.bool(forKey: Keys.hasCompletedOnboarding),
|
|
onboardingPage: {
|
|
let saved = defaults.integer(forKey: Keys.onboardingPage)
|
|
return saved > 0 ? saved : 0
|
|
}(),
|
|
hasAcknowledgedCloudSharing: defaults.bool(forKey: Keys.hasAcknowledgedCloudSharing),
|
|
uiLanguage: AppUILanguage.fromStored(defaults.string(forKey: Keys.uiLanguage)),
|
|
translationTargetLocaleId: defaults.string(forKey: Keys.translationTargetLocaleId)
|
|
?? TranslationLanguageCatalog.offLocaleId,
|
|
handednessPreference: HandednessPreference.fromStored(
|
|
defaults.string(forKey: Keys.handednessPreference)
|
|
),
|
|
cursorDragNavigationEnabled: {
|
|
if defaults.object(forKey: Keys.cursorDragNavigationEnabled) == nil {
|
|
return true
|
|
}
|
|
return defaults.bool(forKey: Keys.cursorDragNavigationEnabled)
|
|
}(),
|
|
polishIntensity: resolvePolishIntensity(from: defaults),
|
|
personalDictionary: decodePersonalDictionary(from: defaults)
|
|
)
|
|
|
|
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
|
|
}
|
|
|
|
// One-shot legacy migration: plaintext apiKey in UserDefaults → Keychain.
|
|
_ = resolveAPIKey(defaults: defaults, providerId: config.providerId)
|
|
|
|
// 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)
|
|
}
|
|
// DeepSeek is local-engine only — never a cloud picker choice.
|
|
if config.engineMode == "cloud", config.providerId == "deepseek" {
|
|
let openAI = LLMProvider.provider(id: "openai")
|
|
config.providerId = openAI.id
|
|
config.baseURL = openAI.defaultBaseURL
|
|
config.model = openAI.defaultModel
|
|
defaults.set(openAI.id, forKey: Keys.providerId)
|
|
defaults.set(openAI.defaultBaseURL, forKey: Keys.baseURL)
|
|
defaults.set(openAI.defaultModel, forKey: Keys.model)
|
|
}
|
|
|
|
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(modeId, forKey: Keys.modeId)
|
|
defaults.set(localeId, forKey: Keys.localeId)
|
|
defaults.set(engineMode, forKey: Keys.engineMode)
|
|
defaults.set(hasCompletedOnboarding, forKey: Keys.hasCompletedOnboarding)
|
|
defaults.set(onboardingPage, forKey: Keys.onboardingPage)
|
|
defaults.set(hasAcknowledgedCloudSharing, forKey: Keys.hasAcknowledgedCloudSharing)
|
|
defaults.set(uiLanguage.rawValue, forKey: Keys.uiLanguage)
|
|
defaults.set(translationTargetLocaleId, forKey: Keys.translationTargetLocaleId)
|
|
defaults.set(handednessPreference.rawValue, forKey: Keys.handednessPreference)
|
|
defaults.set(cursorDragNavigationEnabled, forKey: Keys.cursorDragNavigationEnabled)
|
|
defaults.set(polishIntensity.rawValue, forKey: Keys.polishIntensity)
|
|
Self.encodePersonalDictionary(personalDictionary, to: defaults)
|
|
}
|
|
|
|
// MARK: - Private helpers
|
|
|
|
private static func resolvePolishIntensity(from defaults: UserDefaults) -> PolishIntensity {
|
|
guard let raw = defaults.string(forKey: Keys.polishIntensity) else {
|
|
return .default
|
|
}
|
|
let resolved = PolishIntensity.resolve(storedRawValue: raw)
|
|
if raw == PolishIntensity.legacyOffRawValue {
|
|
defaults.set(resolved.rawValue, forKey: Keys.polishIntensity)
|
|
}
|
|
return resolved
|
|
}
|
|
|
|
private static func decodePersonalDictionary(from defaults: UserDefaults) -> PersonalDictionary {
|
|
guard let data = defaults.data(forKey: Keys.personalDictionary) else {
|
|
return .empty
|
|
}
|
|
do {
|
|
var dictionary = try JSONDecoder().decode(PersonalDictionary.self, from: data)
|
|
if dictionary.entries.contains(where: { $0.source == .history }) {
|
|
for index in dictionary.entries.indices where dictionary.entries[index].source == .history {
|
|
dictionary.entries[index].source = .manual
|
|
}
|
|
dictionary.version += 1
|
|
if let migrated = try? JSONEncoder().encode(dictionary) {
|
|
defaults.set(migrated, forKey: Keys.personalDictionary)
|
|
}
|
|
}
|
|
return dictionary
|
|
} catch {
|
|
OSGLog.config.warning("personalDictionary decode failed: \(error.localizedDescription, privacy: .public)")
|
|
return .empty
|
|
}
|
|
}
|
|
|
|
private static func encodePersonalDictionary(_ dictionary: PersonalDictionary, to defaults: UserDefaults) {
|
|
do {
|
|
let data = try JSONEncoder().encode(dictionary)
|
|
defaults.set(data, forKey: Keys.personalDictionary)
|
|
} catch {
|
|
OSGLog.config.warning("personalDictionary encode failed: \(error.localizedDescription, privacy: .public)")
|
|
}
|
|
}
|
|
|
|
/// Read the API key from the Keychain, falling back to a one-time migration from UserDefaults.
|
|
static func resolveAPIKey(defaults: UserDefaults?, providerId: String) -> String {
|
|
if let stored = Keychain.apiKey(for: providerId), !stored.isEmpty {
|
|
return stored
|
|
}
|
|
if let legacyKeychain = Keychain.legacyAPIKey(), !legacyKeychain.isEmpty {
|
|
try? Keychain.setAPIKey(legacyKeychain, for: providerId)
|
|
try? Keychain.deleteLegacyAPIKey()
|
|
return legacyKeychain
|
|
}
|
|
if let defaults,
|
|
let legacy = defaults.string(forKey: Keys.apiKeyLegacy),
|
|
!legacy.isEmpty {
|
|
try? Keychain.setAPIKey(legacy, for: providerId)
|
|
defaults.removeObject(forKey: Keys.apiKeyLegacy)
|
|
return legacy
|
|
}
|
|
return ""
|
|
}
|
|
}
|