// AppGroupPersistor.swift // OSGKeyboard ยท Keyboard Extension // // Extracted from KeyboardViewController so the view controller doesn't // have to know about App Group availability checks, AppGroupStore // reads/writes, or how to render the locale / mode into the State // view model. import Foundation import OSGKeyboardShared /// Outcome of `load()` โ€” distinguishes "everything fine" from "the /// App Group isn't configured so we can't read anything". The view /// controller flips its `phase` accordingly. public enum AppGroupLoadResult: Equatable { case loaded case unavailable } @MainActor public struct AppGroupPersistor { public init() {} /// Hydrate `state` from the App Group. Returns `loaded` on success /// or `unavailable` if the App Group suite can't be opened (which /// in DEBUG `fatalError`s inside `AppGroup.isAvailable`). public func load(into state: KeyboardViewController.State) -> AppGroupLoadResult { guard AppGroup.isAvailable else { return .unavailable } let store = AppGroupStore() state.localeId = store.localeId // Both engines always polish; ignore legacy off/transcribe modeId. state.mode = .polish state.engineMode = store.engineMode // v0.2.1 follow-up: only the target locale is persisted โ€” // `translationEnabled` is derived from it. Hydrate once at // startup; `refreshRuntimeFlags` keeps the chip in sync while // the keyboard stays open. state.translationTargetLocaleId = store.translationTargetLocaleId state.handednessPreference = store.handednessPreference state.cursorDragNavigationEnabled = store.cursorDragNavigationEnabled state.micDisabled = store.isCloudAPIKeyMissingForVoiceInput state.micDisabledHint = store.isCloudAPIKeyMissingForVoiceInput ? ExtL10n.string("keyboard.mic.disabled.missingApiKey") : "" #if DEBUG // Print a masked view of the live App Group config so we can see // from the device console exactly what the keyboard extension // actually sees (and whether it agrees with the main App). let key = store.apiKey let masked: String if key.count > 8 { masked = "\(key.prefix(4))โ€ฆ\(key.suffix(4)) (\(key.count) chars)" } else if key.isEmpty { masked = "" } else { masked = "<\(key.count) chars>" } print(""" ๐Ÿ” [AppGroupPersistor.load] providerId = \(store.providerId) baseURL = \(store.baseURL) apiKey = \(masked) model = \(store.model) modeId = \(store.modeId) localeId = \(store.localeId) """) #endif return .loaded } /// Lightweight refresh for flags the host app may update while the /// keyboard stays open (model downloads, engine switches). /// /// When `protectTranslationUntil` is in the future, the translation /// target locale is not overwritten โ€” avoids the 1 Hz poll clobbering /// a chip selection the user just wrote to the App Group. public func refreshRuntimeFlags( into state: KeyboardViewController.State, protectTranslationUntil: Date? = nil ) { guard AppGroup.isAvailable else { return } let store = AppGroupStore() state.engineMode = store.engineMode let shouldProtectTranslation = protectTranslationUntil.map { Date() < $0 } ?? false if !shouldProtectTranslation { state.translationTargetLocaleId = store.translationTargetLocaleId } state.handednessPreference = store.handednessPreference state.cursorDragNavigationEnabled = store.cursorDragNavigationEnabled state.micDisabled = store.isCloudAPIKeyMissingForVoiceInput state.micDisabledHint = store.isCloudAPIKeyMissingForVoiceInput ? ExtL10n.string("keyboard.mic.disabled.missingApiKey") : "" } /// Persist `mode` to the App Group store. public func persist(mode: KeyboardViewController.State.InputMode) { guard AppGroup.isAvailable else { return } AppGroupStore().setModeId(mode.rawValue) } /// Persist `localeId` to the App Group store. public func persist(localeId: String) { guard AppGroup.isAvailable else { return } AppGroupStore().setLocaleId(localeId) } /// Persist `engineMode` to the App Group store. public func persist(engineMode: String) { guard AppGroup.isAvailable else { return } AppGroupStore().setEngineMode(engineMode) } /// v0.2.1: persist translation target locale id (e.g. `"en"`, /// `"ja"`, or `TranslationLanguageCatalog.offLocaleId`). The /// chip / picker call this through `KeyboardState.setTranslationTargetLocaleId`. /// /// v0.2.1 follow-up: removed `persist(translationEnabled:)` โ€” the /// enabled state is derived from the locale id, so callers only /// need to write the locale. Keeping the legacy Bool overload /// around would have implied that there's a separate on/off /// switch to persist, which is no longer the model. public func persist(translationTargetLocaleId: String) { guard AppGroup.isAvailable else { return } AppGroupStore().setTranslationTargetLocaleId(translationTargetLocaleId) } }