// AppGroupStore.swift // OSGKeyboard · Shared // // Convenience wrapper around App Group UserDefaults for non-Published reads. // Used by the keyboard extension (no SwiftUI) to read config without // instantiating an ObservableObject. // // `apiKey` is NOT read from UserDefaults — see `Keychain.swift`. We // share access between the host app and the keyboard extension via a // shared keychain-access-group declared in both targets' entitlements. import Foundation public struct AppGroupStore: @unchecked Sendable { public let defaults: UserDefaults public init(defaults: UserDefaults? = nil) { if let defaults { self.defaults = defaults return } // Never hard-crash on implicit construction sites (e.g. default // service initializers). If App Group is unavailable, use .standard // so callers can still surface a user-facing setup error. self.defaults = AppGroup.isAvailable ? AppGroup.defaults : .standard } // MARK: - Keys private enum Key { static let providerId = "config.providerId" static let baseURL = "config.baseURL" static let model = "config.model" static let systemPrompt = "config.systemPrompt" static let modeId = "config.modeId" static let localeId = "config.localeId" static let engineMode = "config.engineMode" static let localASRBackend = "config.localASRBackend" static let uiLanguage = "config.uiLanguage" // v0.2.0: opt-in cloud polish step after local-mode ASR. static let localModeCloudPolishEnabled = "config.localModeCloudPolishEnabled" // v0.2.1 follow-up: `config.translationEnabled` was *removed* as a // persisted key — translation is derived from the target locale // id. New code should only write/read `translationTargetLocaleId`; // the `translationEnabled` Bool accessor below is kept as a // computed shim for source compatibility. static let translationTargetLocaleId = "config.translationTargetLocaleId" static let polishScenarioId = "config.polishScenarioId" static let handednessPreference = "config.handednessPreference" // v0.3.0: polish intensity (off / light / medium / heavy). static let polishIntensity = "config.polishIntensity" // v0.3.0: last app context detected by the keyboard extension. // Reused across calls within a 30-minute window so the LLM // prompt remains consistent during a single typing session. static let detectedAppContext = "config.detectedAppContext" static let detectedAppContextAt = "config.detectedAppContextAt" // v0.3.0: personal dictionary — JSON-encoded `PersonalDictionary`. static let personalDictionary = "config.personalDictionary.v1" } // MARK: - Reads public var providerId: String { defaults.string(forKey: Key.providerId) ?? "openai" } public var baseURL: String { defaults.string(forKey: Key.baseURL) ?? LLMProvider.provider(id: providerId).defaultBaseURL } /// API key lives in the Keychain (cross-process, encrypted at rest). /// Returns "" when nothing is stored so the LLMClient can surface a /// `noAPIKey` error rather than firing off an obviously-bad request. public var apiKey: String { Keychain.apiKey() ?? "" } public var model: String { defaults.string(forKey: Key.model) ?? LLMProvider.provider(id: providerId).defaultModel } public var systemPrompt: String { defaults.string(forKey: Key.systemPrompt) ?? Self.defaultSystemPrompt(for: providerId) } public var modeId: String { defaults.string(forKey: Key.modeId) ?? "polish" } public var localeId: String { defaults.string(forKey: Key.localeId) ?? "auto" } /// "local" → on-device ASR only (raw transcript delivery). /// "cloud" → ASR + LLM polish (default behaviour). public var engineMode: String { defaults.string(forKey: Key.engineMode) ?? "cloud" } /// Which on-device ASR engine backs the "local" engine mode. Falls /// back to the iOS SpeechAnalyzer path so legacy installs (which /// never wrote this key) keep working. public var localASRBackend: LocalASRBackend { let raw = defaults.string(forKey: Key.localASRBackend) ?? LocalASRBackend.speechAnalyzer.rawValue return LocalASRBackend(rawValue: raw) ?? .speechAnalyzer } /// v0.2.0: whether the local engine should route its transcript /// through the configured cloud LLM (DeepSeek by default) before /// insertion. Defaults to `false`; the keyboard extension reads /// this so Flow sessions honour the toggle. public var localModeCloudPolishEnabled: Bool { guard defaults.object(forKey: Key.localModeCloudPolishEnabled) != nil else { return false } return defaults.bool(forKey: Key.localModeCloudPolishEnabled) } /// Host-app UI language override (`auto` / `en` / `zh-Hans`). public var uiLanguage: AppUILanguage { AppUILanguage.fromStored(defaults.string(forKey: Key.uiLanguage)) } /// v0.2.1 follow-up: derived — translation is on iff a target locale /// has been selected. The `translationTargetLocaleId` getter below /// is the source of truth; this property exists for backwards /// compatibility with call sites that read `store.translationEnabled`. public var translationEnabled: Bool { translationTargetLocaleId != TranslationLanguageCatalog.offLocaleId } /// v0.2.1: target locale id the translate-and-polish prompt should /// produce (e.g. `"en"`, `"ja"`). Defaults to `offLocaleId` ("off") /// when nothing is stored, matching the picker / chip UX where the /// user has to actively pick a language to turn translation on. public var translationTargetLocaleId: String { defaults.string(forKey: Key.translationTargetLocaleId) ?? TranslationLanguageCatalog.offLocaleId } public var polishScenarioId: String { let stored = defaults.string(forKey: Key.polishScenarioId) return PolishScenarioCatalog.resolve(stored ?? PolishScenarioCatalog.defaultId).id } /// Bottom-row key order on the keyboard extension. public var handednessPreference: HandednessPreference { HandednessPreference.fromStored(defaults.string(forKey: Key.handednessPreference)) } // MARK: - Writes public func setModeId(_ id: String) { defaults.set(id, forKey: Key.modeId) } public func setLocaleId(_ id: String) { defaults.set(id, forKey: Key.localeId) } public func setEngineMode(_ mode: String) { defaults.set(mode, forKey: Key.engineMode) } public func setLocalASRBackend(_ backend: LocalASRBackend) { defaults.set(backend.rawValue, forKey: Key.localASRBackend) } public func setUILanguage(_ language: AppUILanguage) { defaults.set(language.rawValue, forKey: Key.uiLanguage) } /// v0.2.1 follow-up: kept for source compatibility with callers that /// still pass a Bool (e.g. older tests, any leftover bridge code). /// `enabled == true` selects `defaultLocaleId` ("en") as a sensible /// on-ramp target; `enabled == false` resets to `offLocaleId`. /// The keyboard chip / pipeline now write the locale id directly /// via `setTranslationTargetLocaleId`, which is the preferred path. public func setTranslationEnabled(_ enabled: Bool) { defaults.set( enabled ? TranslationLanguageCatalog.defaultLocaleId : TranslationLanguageCatalog.offLocaleId, forKey: Key.translationTargetLocaleId ) } /// v0.2.1: persist target locale id (e.g. `"en"`, `"ja"`, or /// `TranslationLanguageCatalog.offLocaleId`). The keyboard /// extension reads this on every `load()` and `refreshRuntimeFlags()` /// so the chip reflects the latest value without a host-app /// round-trip. public func setTranslationTargetLocaleId(_ id: String) { defaults.set(id, forKey: Key.translationTargetLocaleId) AppGroupConfigDarwin.postConfigChanged() } public func setPolishScenarioId(_ id: String) { let resolved = PolishScenarioCatalog.resolve(id).id defaults.set(resolved, forKey: Key.polishScenarioId) AppGroupConfigDarwin.postConfigChanged() } public func setHandednessPreference(_ preference: HandednessPreference) { defaults.set(preference.rawValue, forKey: Key.handednessPreference) AppGroupConfigDarwin.postConfigChanged() } /// Whether ASR output should be sent through the cloud LLM step. /// Cloud engine: always. Local engine: only when cloud polish is /// enabled (translation is a sub-option of that step). public var shouldRunCloudLLMStep: Bool { if engineMode == "cloud" { return true } return localModeCloudPolishEnabled } /// Whether translate-and-polish should run (vs polish-only). public var isTranslationEffective: Bool { guard translationEnabled else { return false } if engineMode == "local" { return localModeCloudPolishEnabled } return true } /// Whether the keyboard top-bar translation chip should render. /// Cloud engine: always. Local engine: when cloud polish is enabled /// (translation is a sub-option of that LLM step). Independent of /// whether a target locale is currently selected — the chip stays /// visible so the user can pick "不翻译" or a language in-place. public var isTranslationChipVisible: Bool { if engineMode == "cloud" { return true } return localModeCloudPolishEnabled } /// Whether the keyboard top-bar scenario chip should render. public var isPolishScenarioChipVisible: Bool { if engineMode == "cloud" { return true } return localModeCloudPolishEnabled } /// System prompt for the polish pipeline honoring scenario selection. public func resolvedPolishSystemPrompt(providerId: String? = nil) -> String { if PolishScenarioCatalog.isCustom(polishScenarioId) { return systemPrompt } let pid = providerId ?? self.providerId return ScenarioPrompt.make( scenarioId: polishScenarioId, providerId: pid, uiLanguage: uiLanguage ) } /// Polish vs translate-and-polish for the active pipeline. public var polishModeForPipeline: PolishingService.PolishMode { isTranslationEffective ? .translate(targetLocaleId: translationTargetLocaleId) : .polish } /// Local engine pins the LLM step to DeepSeek; cloud uses the /// user's configured provider. public var polishProviderIdOverride: String? { engineMode == "local" ? "deepseek" : nil } // MARK: - Polish settings (v0.3.0+) /// How aggressively the LLM should rewrite the ASR transcript. /// Defaults to `medium` for new installs. public var polishIntensity: PolishIntensity { guard let raw = defaults.string(forKey: Key.polishIntensity), let value = PolishIntensity(rawValue: raw) else { return .default } return value } public func setPolishIntensity(_ intensity: PolishIntensity) { defaults.set(intensity.rawValue, forKey: Key.polishIntensity) } // MARK: - Onboarding (v0.3.0+) // // Mirrored from `ProviderConfig` so the keyboard extension's // overlay can read / write the same source of truth without // instantiating the main-app config (which would drag in // SwiftUI / Combine and fight the keyboard's main-thread budget). public var hasCompletedOnboarding: Bool { get { defaults.bool(forKey: "config.hasCompletedOnboarding") } set { defaults.set(newValue, forKey: "config.hasCompletedOnboarding") } } public var onboardingPage: Int { get { defaults.integer(forKey: "config.onboardingPage") } set { defaults.set(newValue, forKey: "config.onboardingPage") } } public func setHasCompletedOnboarding(_ completed: Bool) { defaults.set(completed, forKey: "config.hasCompletedOnboarding") } public func setOnboardingPage(_ page: Int) { defaults.set(page, forKey: "config.onboardingPage") } // MARK: - Detected app context (v0.3.0+) /// Last app context the keyboard extension detected for this /// user, plus the timestamp it was observed. Callers should /// treat values older than 30 minutes as stale. public var detectedAppContext: (context: AppContext, observedAt: Date)? { guard let raw = defaults.string(forKey: Key.detectedAppContext), let value = AppContext(rawValue: raw) else { return nil } let timestamp = defaults.object(forKey: Key.detectedAppContextAt) as? Date ?? .distantPast return (value, timestamp) } public func setDetectedAppContext(_ context: AppContext, at date: Date = Date()) { defaults.set(context.rawValue, forKey: Key.detectedAppContext) defaults.set(date, forKey: Key.detectedAppContextAt) } // MARK: - Personal dictionary (v0.3.0+) /// Personal dictionary persisted in the App Group so both the /// main app's Settings UI and the keyboard extension's LLM call /// read the same source of truth. Returns an empty dictionary /// when nothing is stored (and when the stored JSON is corrupt — /// failing closed is safer than crashing the keyboard). public var personalDictionary: PersonalDictionary { get { guard let data = defaults.data(forKey: Key.personalDictionary) else { return .empty } do { return try JSONDecoder().decode(PersonalDictionary.self, from: data) } catch { #if DEBUG print("⚠️ [AppGroupStore] personalDictionary decode failed: \(error)") #endif return .empty } } set { setPersonalDictionary(newValue) } } public func setPersonalDictionary(_ dictionary: PersonalDictionary) { do { let data = try JSONEncoder().encode(dictionary) defaults.set(data, forKey: Key.personalDictionary) } catch { #if DEBUG print("⚠️ [AppGroupStore] personalDictionary encode failed: \(error)") #endif } } // MARK: - Client public func makeClient() -> LLMClient { OpenAICompatibleClient( baseURL: baseURL, apiKey: apiKey, model: model ) } // MARK: - Defaults /// Per-provider default system prompt. We bias the prompt by the /// provider's *primary* language so Chinese LLMs naturally return /// Chinese for Chinese input, and English LLMs stay terse. public static func defaultSystemPrompt(for providerId: String) -> String { switch providerId { case "zhipu", "moonshot", "qwen", "deepseek": return """ 你是一位语音输入润色助手。请将用户的口述改写为干净的中文(或英文)书面文字: 1) 保留原意,不编造事实;保持输入语言。 2) 添加恰当的标点、大小写、段落。 3) 当用户枚举"第一…第二…第三…"时,使用 markdown 列表。 4) 简洁,不超出原长 1.5 倍;可去掉无意义的口头禅(嗯、啊、那个)。 5) 只输出润色后的正文,不要解释、不要加引号。 """ default: return """ You are a voice-input polishing assistant. The user has spoken informally; rewrite their dictation as clean written text: 1) Preserve the user's original intent and meaning; do not invent facts. 2) Add proper punctuation, capitalization, and paragraph breaks. 3) When the user enumerates items ("first ... second ... third"), output a markdown list. 4) Keep the output concise — do not exceed 1.5x the spoken length. Drop filler words (um, uh, like). 5) Output in the same language as the input. No quotes, no explanation, no preamble. """ } } }