// AppGroupStore.swift // OSGKeyboard ยท Shared // // Thin read/write facade over `AppGroupConfiguration` for the keyboard // extension (no SwiftUI) and other non-ObservableObject call sites. // // `apiKey` is NOT stored in UserDefaults โ€” see `Keychain.swift`. import Foundation /// Sendable facade over thread-safe UserDefaults, hence `@unchecked`; callers /// must still serialize compound read-modify-write mutations. iOS requires the /// App Group (except unsigned tests), while macOS may use `.standard`. API keys /// are resolved from Keychain and never saved here. public struct AppGroupStore: @unchecked Sendable { public let defaults: UserDefaults public init(defaults: UserDefaults? = nil) { if let defaults { self.defaults = defaults return } if let available = AppGroup.defaultsIfAvailable { self.defaults = available return } #if os(iOS) // iOS app + keyboard extension MUST share the App Group suite; a // silent `.standard` fallback would desync them. Keep this a hard // failure so a provisioning mistake is impossible to miss. // // Exception: unsigned XCTest hosts (`CODE_SIGNING_ALLOWED=NO`) often // lack the App Group container. `fatalError` here aborts the whole // test process (SIGTRAP) and masks real assertion failures โ€” use an // isolated suite only while XCTest is loaded. #if DEBUG if NSClassFromString("XCTestCase") != nil { let suiteName = "\(AppGroup.identifier).xctest-fallback" self.defaults = UserDefaults(suiteName: suiteName) ?? .standard return } fatalError("App Group unavailable โ€” inject UserDefaults in tests or fix entitlements.") #else fatalError("App Group unavailable.") #endif #else // macOS is a standalone menu-bar app with no keyboard extension to // stay in sync with, so a missing App Group container is expected; // fall back to the app's standard defaults. self.defaults = .standard #endif } private var configuration: AppGroupConfiguration { AppGroupConfiguration.load(fromAvailable: defaults) } private func mutateConfiguration(_ transform: (inout AppGroupConfiguration) -> Void) { let baseline = AppGroupConfiguration.load(fromAvailable: defaults) var config = baseline transform(&config) config.saveChanges(since: baseline, to: defaults) } // MARK: - Reads public var providerId: String { configuration.providerId } public var baseURL: String { configuration.baseURL } public var apiKey: String { configuration.apiKey } public var model: String { configuration.model } public var asrProviderId: String { configuration.asrProviderId } public var asrBaseURL: String { configuration.resolvedASRBaseURL } public var asrApiKey: String { configuration.asrApiKey } public var asrModel: String { configuration.resolvedASRModel } public var modeId: String { configuration.modeId } public var localeId: String { configuration.localeId } public var engineMode: String { configuration.engineMode } public var credentialSource: CredentialSource { configuration.credentialSource } /// Non-secret host-authentication marker used to gate account-funded grants. public var isManagedGatewayAccountSessionAvailable: Bool { defaults.bool( forKey: AppGroupConfiguration.Keys.managedGatewayAccountSessionAvailable ) } public var uiLanguage: AppUILanguage { configuration.uiLanguage } public var translationEnabled: Bool { configuration.translationEnabled } public var translationTargetLocaleId: String { configuration.translationTargetLocaleId } public var handednessPreference: HandednessPreference { configuration.handednessPreference } public var cursorDragNavigationEnabled: Bool { configuration.cursorDragNavigationEnabled } public var keyboardHapticIntensity: KeyboardHapticIntensity { configuration.keyboardHapticIntensity } public var polishIntensity: PolishIntensity { configuration.polishIntensity } public var aiResponseLength: AIResponseLength { configuration.aiResponseLength } public var polishStyleCatalog: PolishStyleCatalog { configuration.polishStyleCatalog } public var activePolishStyleId: String { configuration.activePolishStyleId } public var activePolishStyle: PolishStylePack { PolishStylePackCatalog.resolve(id: activePolishStyleId, userCatalog: polishStyleCatalog) } public var llmThinkingEnabled: Bool { configuration.llmThinkingEnabled } public var clipboardHistoryEnabled: Bool { configuration.clipboardHistoryEnabled } public var clipboardCandidateBarEnabled: Bool { configuration.clipboardCandidateBarEnabled } public var isPolishKeyMissing: Bool { configuration.isPolishKeyMissing } public var isTranslationEffective: Bool { configuration.isTranslationEffective } public var isLocalEngine: Bool { configuration.isLocalEngine } public var polishModeForPipeline: PolishingService.PolishMode { configuration.polishModeForPipeline } public var polishProviderIdOverride: String? { configuration.polishProviderIdOverride } public var isCloudAPIKeyMissingForVoiceInput: Bool { configuration.isCloudAPIKeyMissingForVoiceInput } public var localASRCustomLanguageModelEnabled: Bool { configuration.localASRCustomLanguageModelEnabled } /// Kept off `AppGroupConfiguration.save()` so other settings writes cannot clobber it. public var agentSkillLayout: AIAgentSkillLayout { Self.decodeAgentSkillLayout( from: defaults, userCatalog: agentUserSkillCatalog, officialCatalog: officialSkillCatalog, uiLanguage: uiLanguage ) } public var agentUserSkillCatalog: AIUserSkillCatalog { Self.decodeUserSkillCatalog(from: defaults) } /// Last-known-good host-fetched catalog. The extension only reads this snapshot. public var officialSkillCatalog: OfficialSkillCatalog { Self.decodeOfficialSkillCatalog(from: defaults) } public var resolvedAgentSkillCatalog: [AIClipboardSkill] { AIClipboardSkillCatalog.all( officialCatalog: officialSkillCatalog, userCatalog: agentUserSkillCatalog, uiLanguage: uiLanguage ) } // MARK: - Writes public func setModeId(_ id: String) { mutateConfiguration { $0.modeId = id } } public func setLocaleId(_ id: String) { mutateConfiguration { $0.localeId = id } } public func setEngineMode(_ mode: String) { mutateConfiguration { $0.engineMode = mode } AppGroupConfigDarwin.postConfigChanged() } public func setCredentialSource(_ source: CredentialSource) { mutateConfiguration { $0.credentialSource = source } AppGroupConfigDarwin.postConfigChanged() } public func setManagedGatewayAccountSessionAvailable(_ available: Bool) { defaults.set( available, forKey: AppGroupConfiguration.Keys.managedGatewayAccountSessionAvailable ) AppGroupConfigDarwin.postConfigChanged() } public func setUILanguage(_ language: AppUILanguage) { mutateConfiguration { $0.uiLanguage = language } } public func setTranslationEnabled(_ enabled: Bool) { setTranslationTargetLocaleId( enabled ? TranslationLanguageCatalog.defaultLocaleId : TranslationLanguageCatalog.offLocaleId ) } public func setTranslationTargetLocaleId(_ id: String) { mutateConfiguration { $0.translationTargetLocaleId = id } AppGroupConfigDarwin.postConfigChanged() } public func setHandednessPreference(_ preference: HandednessPreference) { mutateConfiguration { $0.handednessPreference = preference } AppGroupConfigDarwin.postConfigChanged() } public func setCursorDragNavigationEnabled(_ enabled: Bool) { mutateConfiguration { $0.cursorDragNavigationEnabled = enabled } AppGroupConfigDarwin.postConfigChanged() } public func setKeyboardHapticIntensity(_ intensity: KeyboardHapticIntensity) { mutateConfiguration { $0.keyboardHapticIntensity = intensity } AppGroupConfigDarwin.postConfigChanged() } public func setPolishIntensity(_ intensity: PolishIntensity) { mutateConfiguration { $0.polishIntensity = intensity } AppGroupConfigDarwin.postConfigChanged() } public func setAIResponseLength(_ length: AIResponseLength) { mutateConfiguration { $0.aiResponseLength = length } AppGroupConfigDarwin.postConfigChanged() } // MARK: - Polish styles public func setPolishStyleCatalog(_ catalog: PolishStyleCatalog) { mutateConfiguration { $0.polishStyleCatalog = catalog } AppGroupConfigDarwin.postConfigChanged() } public func setActivePolishStyleId(_ id: String) { mutateConfiguration { config in config.activePolishStyleId = PolishStylePackCatalog.isValidActiveID( id, userCatalog: config.polishStyleCatalog ) ? id : PolishStylePackCatalog.defaultID } AppGroupConfigDarwin.postConfigChanged() } public func deletePolishStylePack(id: String, at date: Date = Date()) { mutateConfiguration { config in config.polishStyleCatalog.recordDeletion(of: id, at: date) if config.activePolishStyleId == id { config.activePolishStyleId = PolishStylePackCatalog.defaultID } } AppGroupConfigDarwin.postConfigChanged() } public func setLLMThinkingEnabled(_ enabled: Bool) { mutateConfiguration { $0.llmThinkingEnabled = enabled } AppGroupConfigDarwin.postConfigChanged() } public func setClipboardHistoryEnabled(_ enabled: Bool) { mutateConfiguration { $0.clipboardHistoryEnabled = enabled } AppGroupConfigDarwin.postConfigChanged() } public func setClipboardCandidateBarEnabled(_ enabled: Bool) { mutateConfiguration { $0.clipboardCandidateBarEnabled = enabled } AppGroupConfigDarwin.postConfigChanged() } public func setLocalASRCustomLanguageModelEnabled(_ enabled: Bool) { mutateConfiguration { $0.localASRCustomLanguageModelEnabled = enabled } } public func setAgentSkillLayout(_ layout: AIAgentSkillLayout) { do { let data = try JSONEncoder().encode( layout.sanitized(catalog: resolvedAgentSkillCatalog) ) defaults.set(data, forKey: AppGroupConfiguration.Keys.agentSkillLayout) defaults.set( Self.currentAgentSkillDefaultsMigrationVersion, forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion ) } catch { OSGLog.config.warning("agentSkillLayout encode failed: \(error.localizedDescription, privacy: .public)") } AppGroupConfigDarwin.postConfigChanged() } /// Stores one encoded catalog snapshot. A successful 200 refresh can also /// append newly published text skills without restoring previously disabled ones. public func setOfficialSkillCatalog( _ catalog: OfficialSkillCatalog, installingNewDefaultSkills: Bool = false ) throws { let validated = try catalog.validated() let encoder = JSONEncoder() let catalogData = try encoder.encode(validated) var layoutData: Data? if installingNewDefaultSkills { let cachedIDs = Set(officialSkillCatalog.skills.map(\.id)) let addedIDs = validated.skills .filter { $0.kind == .transform && !cachedIDs.contains($0.id) } .map(\.id) if !addedIDs.isEmpty { let current = agentSkillLayout let resolvedCatalog = AIClipboardSkillCatalog.all( officialCatalog: validated, userCatalog: agentUserSkillCatalog, uiLanguage: uiLanguage ) let updated = AIAgentSkillLayout( enabledIDs: current.enabledIDs + addedIDs.filter { !current.enabledIDs.contains($0) }, confirmedShortcutIDs: current.confirmedShortcutIDs ).sanitized(catalog: resolvedCatalog) layoutData = try encoder.encode(updated) } } defaults.set(catalogData, forKey: AppGroupConfiguration.Keys.officialSkillCatalog) if let layoutData { defaults.set(layoutData, forKey: AppGroupConfiguration.Keys.agentSkillLayout) defaults.set( Self.currentAgentSkillDefaultsMigrationVersion, forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion ) } defaults.synchronize() AppGroupConfigDarwin.postConfigChanged() } public func setAgentUserSkillCatalog(_ catalog: AIUserSkillCatalog) { do { defaults.set( try JSONEncoder().encode(catalog), forKey: AppGroupConfiguration.Keys.agentUserSkillCatalog ) } catch { OSGLog.config.warning( "agentUserSkillCatalog encode failed: \(error.localizedDescription, privacy: .public)" ) } AppGroupConfigDarwin.postConfigChanged() } public func setPendingShortcutRun(skillID: String, titles: [String]) { let payload = AIAgentShortcutRunPayload(skillID: skillID, titles: titles) if let data = AIAgentShortcutRun.encode(payload) { defaults.set(data, forKey: AIAgentShortcutRun.pendingKey) AIAgentShortcutRun.trace( "appGroup.writePending skill=\(skillID) items=\(titles.count) bytes=\(data.count)" ) } else { AIAgentShortcutRun.trace("appGroup.writePending FAILED encode skill=\(skillID)") } } public func consumePendingShortcutRun(now: Date = Date()) -> AIAgentShortcutRunPayload? { let data = defaults.data(forKey: AIAgentShortcutRun.pendingKey) defaults.removeObject(forKey: AIAgentShortcutRun.pendingKey) guard let data else { AIAgentShortcutRun.trace("appGroup.consumePending missing") return nil } guard let payload = AIAgentShortcutRun.decode(data, now: now) else { AIAgentShortcutRun.trace( "appGroup.consumePending dropped bytes=\(data.count) (expired or empty titles)" ) return nil } AIAgentShortcutRun.trace( "appGroup.consumePending ok skill=\(payload.skillID) items=\(payload.titles.count)" ) return payload } public func setPendingContactCreation(phoneNumber: String) { guard let normalized = AIPhoneNumberResolver.normalized(phoneNumber), let data = AIContactCreationHandoff.encode( AIContactCreationPayload(phoneNumber: normalized) ) else { defaults.removeObject(forKey: AIContactCreationHandoff.pendingKey) return } defaults.set(data, forKey: AIContactCreationHandoff.pendingKey) } public func consumePendingContactCreation( now: Date = Date() ) -> AIContactCreationPayload? { let data = defaults.data(forKey: AIContactCreationHandoff.pendingKey) defaults.removeObject(forKey: AIContactCreationHandoff.pendingKey) guard let data else { return nil } return AIContactCreationHandoff.decode(data, now: now) } private static func decodeAgentSkillLayout( from defaults: UserDefaults, userCatalog: AIUserSkillCatalog, officialCatalog: OfficialSkillCatalog, uiLanguage: AppUILanguage ) -> AIAgentSkillLayout { let catalog = AIClipboardSkillCatalog.all( officialCatalog: officialCatalog, userCatalog: userCatalog, uiLanguage: uiLanguage ) guard let data = defaults.data(forKey: AppGroupConfiguration.Keys.agentSkillLayout) else { defaults.set( currentAgentSkillDefaultsMigrationVersion, forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion ) return AIAgentSkillLayout( enabledIDs: catalog.filter(\.isDefault).map(\.id), confirmedShortcutIDs: [] ) } do { let decoded = try JSONDecoder().decode(AIAgentSkillLayout.self, from: data) .sanitized(catalog: catalog) let storedMigrationVersion = defaults.integer( forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion ) guard storedMigrationVersion < currentAgentSkillDefaultsMigrationVersion else { return decoded } let legacyDefaults = Set([ AIClipboardSkillCatalog.replyID, AIClipboardSkillCatalog.summarizeID, AIClipboardSkillCatalog.translateID ]) var additionIDs = Set() if storedMigrationVersion < 1 { // Preserve any legacy default the user explicitly turned off. // Export and semantic skills first became defaults in v1. additionIDs.formUnion( AIAgentSkillLayout.defaultEnabledIDs.filter { !legacyDefaults.contains($0) } ) } if storedMigrationVersion < 2 { additionIDs.insert(AIClipboardSkillCatalog.playfulReplyID) } if storedMigrationVersion < 3 { additionIDs.formUnion( officialCatalog.skills .filter { $0.kind == .transform } .map(\.id) ) } if storedMigrationVersion < 4 { additionIDs.insert(AIClipboardSkillCatalog.openLinkID) additionIDs.insert(AIClipboardSkillCatalog.summarizeWebPageID) } if storedMigrationVersion < 5 { additionIDs.insert(AIClipboardSkillCatalog.callPhoneID) additionIDs.insert(AIClipboardSkillCatalog.createContactID) } // v6 persists canonical IDs for the consolidated reply, summary, // and clarification skills. `sanitized` performs the mapping. let additions = catalog.map(\.id).filter { additionIDs.contains($0) && !decoded.enabledIDs.contains($0) } let migrated = AIAgentSkillLayout( enabledIDs: decoded.enabledIDs + additions, confirmedShortcutIDs: decoded.confirmedShortcutIDs ).sanitized(catalog: catalog) if let migratedData = try? JSONEncoder().encode(migrated) { defaults.set(migratedData, forKey: AppGroupConfiguration.Keys.agentSkillLayout) } defaults.set( currentAgentSkillDefaultsMigrationVersion, forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion ) return migrated } catch { OSGLog.config.warning("agentSkillLayout decode failed: \(error.localizedDescription, privacy: .public)") defaults.set( currentAgentSkillDefaultsMigrationVersion, forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion ) return .default } } private static let currentAgentSkillDefaultsMigrationVersion = 6 private static func decodeUserSkillCatalog(from defaults: UserDefaults) -> AIUserSkillCatalog { guard let data = defaults.data(forKey: AppGroupConfiguration.Keys.agentUserSkillCatalog) else { return .empty } do { return try JSONDecoder().decode(AIUserSkillCatalog.self, from: data) } catch { OSGLog.config.warning( "agentUserSkillCatalog decode failed: \(error.localizedDescription, privacy: .public)" ) return .empty } } private static func decodeOfficialSkillCatalog(from defaults: UserDefaults) -> OfficialSkillCatalog { guard let data = defaults.data(forKey: AppGroupConfiguration.Keys.officialSkillCatalog) else { return .empty } do { return try JSONDecoder().decode(OfficialSkillCatalog.self, from: data).validated() } catch { OSGLog.config.warning( "officialSkillCatalog decode failed: \(error.localizedDescription, privacy: .public)" ) return .empty } } public var hasCompletedOnboarding: Bool { get { configuration.hasCompletedOnboarding } set { setHasCompletedOnboarding(newValue) } } public var onboardingPage: Int { get { configuration.onboardingPage } set { setOnboardingPage(newValue) } } /// Commits onboarding to both the App Group and the reboot-durable /// Keychain marker; callers must preserve this dual-write invariant. public func setHasCompletedOnboarding(_ completed: Bool) { mutateConfiguration { config in config.hasCompletedOnboarding = completed if completed { config.onboardingPage = 0 } } // Mirror to the reboot-durable Keychain marker (keyboard-side completion). Keychain.setOnboardingCompleted(completed) } public func setOnboardingPage(_ page: Int) { mutateConfiguration { $0.onboardingPage = page } } // MARK: - Detected app context public var detectedAppContext: (context: AppContext, observedAt: Date)? { configuration.detectedAppContext(from: defaults) } public func setDetectedAppContext(_ context: AppContext, at date: Date = Date()) { var config = configuration config.setDetectedAppContext(context, at: date, to: defaults) } // MARK: - Personal dictionary public var personalDictionary: PersonalDictionary { get { configuration.personalDictionary } set { setPersonalDictionary(newValue) } } public func setPersonalDictionary(_ dictionary: PersonalDictionary) { mutateConfiguration { $0.personalDictionary = dictionary } AppGroupConfigDarwin.postConfigChanged() #if os(iOS) // Host redeploys Rime sidecar; extension picks it up next typing open. PersonalDictionaryRimeSync.scheduleAfterDictionaryChange() #endif } public func deletePersonalDictionaryEntry(id: UUID, at date: Date = Date()) { mutateConfiguration { config in config.personalDictionary.entries.removeAll { $0.id == id } config.personalDictionary.deletedEntryIDs[id] = date } AppGroupConfigDarwin.postConfigChanged() #if os(iOS) PersonalDictionaryRimeSync.scheduleAfterDictionaryChange() #endif } public var personalDictionaryICloudSyncEnabled: Bool { get { configuration.personalDictionaryICloudSyncEnabled } set { setPersonalDictionaryICloudSyncEnabled(newValue) } } public func setPersonalDictionaryICloudSyncEnabled(_ enabled: Bool) { mutateConfiguration { $0.personalDictionaryICloudSyncEnabled = enabled } } public var settingsICloudSyncEnabled: Bool { get { configuration.settingsICloudSyncEnabled } set { setSettingsICloudSyncEnabled(newValue) } } public func setSettingsICloudSyncEnabled(_ enabled: Bool) { mutateConfiguration { $0.settingsICloudSyncEnabled = enabled } } /// Timestamp of the last settings blob applied from iCloud KVS. public var settingsCloudUpdatedAt: Date? { let raw = defaults.double(forKey: AppGroupConfiguration.Keys.settingsCloudUpdatedAt) guard raw > 0 else { return nil } return Date(timeIntervalSince1970: raw) } // MARK: - Client public func makeClient( taskKind: ManagedGatewayTaskKind?, requestPurpose: ManagedGatewayRequestPurpose?, oobeFeature: ManagedGatewayOOBEFeature? ) -> LLMClient { configuration.makeClient( taskKind: taskKind, requestPurpose: requestPurpose, oobeFeature: oobeFeature ) } }