// KeyboardConfigSync.swift // OSGKeyboard · Keyboard Extension // // App Group config hydration, Darwin observers, and onboarding-complete // mirroring (mic gate only — full setup lives in the host app). import Foundation import OSGKeyboardShared @MainActor final class KeyboardConfigSync { private let state: KeyboardState private let persistor: AppGroupPersistor private let onFlowSessionChanged: () -> Void /// Fired on every App Group config change — the host posts one after a /// successful Rime deployment, which is the keyboard's only signal that /// typing resources just became available. private let onConfigChanged: () -> Void /// Grace period after a chip-side translation write during which the /// 1 Hz App Group poll must not overwrite `translationTargetLocaleId`. var translationConfigProtectedUntil: Date? private var flowSessionDarwinObserver: FlowSessionDarwinObserver? private var transcriptionDarwinObserver: FlowSessionDarwinObserver? private var hostReadyDarwinObserver: FlowSessionDarwinObserver? private var configDarwinObserver: FlowSessionDarwinObserver? init( state: KeyboardState, persistor: AppGroupPersistor, onFlowSessionChanged: @escaping () -> Void, onConfigChanged: @escaping () -> Void = {} ) { self.state = state self.persistor = persistor self.onFlowSessionChanged = onFlowSessionChanged self.onConfigChanged = onConfigChanged } func installDarwinObservers() { flowSessionDarwinObserver = FlowSessionDarwinObserver { [weak self] in self?.onFlowSessionChanged() } transcriptionDarwinObserver = FlowSessionDarwinObserver( notificationName: FlowSessionDarwin.transcriptionNotificationName ) { [weak self] in self?.onFlowSessionChanged() } hostReadyDarwinObserver = FlowSessionDarwinObserver( notificationName: FlowSessionDarwin.hostReadyNotificationName ) { [weak self] in self?.onFlowSessionChanged() } configDarwinObserver = FlowSessionDarwinObserver( notificationName: AppGroupConfigDarwin.notificationName ) { [weak self] in guard let self else { return } self.refreshConfigFromAppGroup() self.onConfigChanged() } } func loadPersistedConfig() -> AppGroupLoadResult { switch persistor.load(into: state) { case .loaded: OSGLog.keyboardExt.info("config loaded") syncOnboardingStateFromAppGroup() refreshCredentialsFromKeychain() return .loaded case .unavailable: state.phase = .error( .appGroupUnavailable, message: ExtL10n.string("keyboard.error.appGroupUnavailable") ) return .unavailable } } func refreshConfigFromAppGroup() { persistor.refreshRuntimeFlags( into: state, protectTranslationUntil: translationConfigProtectedUntil ) // Host may complete (or reset) onboarding while the extension stays alive. syncOnboardingStateFromAppGroup() refreshCredentialsFromKeychain() } /// Resolves credential availability from the Keychain OFF the main actor, /// then applies it to `state`. The extension's main thread never performs /// a synchronous SecItemCopyMatching: one in flight while the system /// suspends the keyboard is a RunningBoard 0xdead10cc kill vector. /// Until the first refresh lands, `KeyboardState`'s optimistic defaults /// (mic enabled, AI available) stand; a mis-gated mic corrects itself /// within a beat of keyboard appear. func refreshCredentialsFromKeychain() { Task.detached { [weak self] in guard AppGroup.isAvailable else { return } let store = AppGroupStore() let polishKeyMissing = store.isPolishKeyMissing let cloudVoiceKeyMissing = store.isCloudAPIKeyMissingForVoiceInput await MainActor.run { guard let self else { return } self.persistor.applyCredentialAvailability( isPolishKeyMissing: polishKeyMissing, isCloudAPIKeyMissingForVoiceInput: cloudVoiceKeyMissing, into: self.state ) } } } /// Mirrors host-app onboarding completion for the mic gate only. /// Does not sync `onboardingPage` — page flow is host-app exclusive. func syncOnboardingStateFromAppGroup() { let store = AppGroupStore() // Synchronous reads stay defaults-only on the main thread (0xdead10cc // window). The Keychain fallback — a reboot must not resurrect the mic // gate when App Group transiently reads empty — resolves asynchronously // and only when the App Group value looks incomplete. let appGroupCompleted = store.hasCompletedOnboarding state.hasCompletedOnboarding = appGroupCompleted if !appGroupCompleted { Task.detached { [weak self] in let keychainCompleted = Keychain.hasCompletedOnboarding() await MainActor.run { guard let self, keychainCompleted else { return } self.state.hasCompletedOnboarding = true } } } let practice = KeyboardSetupBridge.activeOOBEPracticeSession state.oobePracticeSession = practice state.isOnboardingPracticeActive = practice != nil || KeyboardSetupBridge.isOnboardingPracticeActive switch practice?.expectedFeature { case .voiceInput, .askAI: state.surface = .voice case .clipboardTranslate, .clipboardReply: state.surface = .ai if !state.aiSession.isActive { state.aiSession.enter() } case nil: break } } func persistLocale(_ id: String) { state.localeId = id persistor.persist(localeId: id) } func persistEngineMode(_ mode: String) { state.engineMode = mode persistor.persist(engineMode: mode) } func persistTranslationTargetLocaleId(_ id: String) { let resolved = TranslationLanguageCatalog.resolve(id).id state.translationTargetLocaleId = resolved translationConfigProtectedUntil = KeyboardTranslationConfigProtection.protectionDeadline() persistor.persist(translationTargetLocaleId: resolved) } func persistMode(_ mode: KeyboardState.InputMode) { state.mode = mode persistor.persist(mode: mode) } }