Files
OSGKeyboard/OSGKeyboardShared/Services/AppGroupStore.swift
T
rockyandClaude Opus 5 0023741455 feat(app): add crash visibility, memory relief, and clipboard reply work
Squashes the working tree that had accumulated on cloud-agent. Two
strands of work, kept in one commit at the maintainer's request.

Crash and stability (this session):

- Subscribe to MetricKit so crash, hang, CPU, and disk-write diagnostics
  are recorded on device. Keyboard-extension memory kills are the most
  likely way a user loses the keyboard mid-sentence and iOS does not
  classify them as crashes, so they never reach App Store Connect.
- Upload those reports to POST /v1/analytics/diagnostics from internal
  builds only (local Debug and TestFlight), reusing the analytics
  installation identifier and skipping entirely when analytics are off.
  App Store builds keep everything on device.
- Shed the Chinese engine, English lexicon, and in-flight pipeline work
  at the 40 MiB and 48 MiB budget thresholds instead of waiting for a
  system memory warning that jetsam often preempts.
- Install audio taps with the input node's live format through one
  shared adaptive resampler, so an audio-route change mid-recording no
  longer raises an uncatchable format-mismatch exception. Covers the
  dictation preview and the Mac recorder, which still used the pattern
  the Flow capture path had already moved away from.
- Harden the librime bridge: never pass a nil string across a nonnull
  boundary when a user dictionary holds malformed UTF-8, null-check the
  engine API vector, and distrust a candidate count without its array.
- Guard the expanded candidate grid against a list that shrinks while
  the collection view is still laying out against the previous count.
- Resolve localized text without the App Group so the screen shown when
  that container is missing renders its message instead of crashing on
  launch, which is the one situation it exists to handle.
- Gate CI on the App Group and Keychain wiring that the keyboard
  extension hard-depends on, with a self-test proving the check fails.

Clipboard, reply, and semantics (pre-existing working tree):

- Unify the reply center, refresh keyboard AI features, and add native
  event and reminder exporters.
- Expand clipboard semantic routing with authored corpora and a
  product-blind holdout, plus the scripts that build and validate them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:31:29 -07:00

688 lines
28 KiB
Swift

// 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 keyboardHapticIntensity: KeyboardHapticIntensity { configuration.keyboardHapticIntensity }
public var polishIntensity: PolishIntensity { configuration.polishIntensity }
public var aiResponseLength: AIResponseLength { configuration.aiResponseLength }
public var multipleReplyVariantsEnabled: Bool { configuration.multipleReplyVariantsEnabled }
public var polishStyleCatalog: PolishStyleCatalog { configuration.polishStyleCatalog }
public var activePolishStyleId: String { configuration.activePolishStyleId }
public var personalReplyStyleId: String { configuration.personalReplyStyleId }
/// Distilled personal style for AI replies, or nil when disabled or when
/// the pack no longer exists. Never falls back to the voice-polish style.
public var personalReplyStyle: PolishStylePack? {
PolishStylePackCatalog.resolvePersonalReplyStyle(
id: personalReplyStyleId,
userCatalog: polishStyleCatalog
)
}
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 clipboardAutoModeEnabled: Bool { configuration.clipboardAutoModeEnabled }
public var clipboardAutoTranslateEnabled: Bool { configuration.clipboardAutoTranslateEnabled }
public var clipboardAutoEmailReplyEnabled: Bool { configuration.clipboardAutoEmailReplyEnabled }
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 }
AppGroupConfigDarwin.postConfigChanged()
}
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 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()
}
public func setMultipleReplyVariantsEnabled(_ enabled: Bool) {
mutateConfiguration { $0.multipleReplyVariantsEnabled = enabled }
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()
}
/// Empty string disables personalized AI replies. A hand-written pack or a
/// missing ID is rejected the same way, so the field can only ever hold a
/// distilled personal style.
public func setPersonalReplyStyleId(_ id: String) {
mutateConfiguration { config in
config.personalReplyStyleId = PolishStylePackCatalog.resolvePersonalReplyStyle(
id: id,
userCatalog: config.polishStyleCatalog
)?.id ?? ""
}
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
}
if config.personalReplyStyleId == id {
config.personalReplyStyleId = ""
}
}
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 setClipboardAutoModeEnabled(_ enabled: Bool) {
mutateConfiguration { $0.clipboardAutoModeEnabled = enabled }
AppGroupConfigDarwin.postConfigChanged()
}
public func setClipboardAutoTranslateEnabled(_ enabled: Bool) {
mutateConfiguration { $0.clipboardAutoTranslateEnabled = enabled }
AppGroupConfigDarwin.postConfigChanged()
}
public func setClipboardAutoEmailReplyEnabled(_ enabled: Bool) {
mutateConfiguration { $0.clipboardAutoEmailReplyEnabled = 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<String>()
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.
if storedMigrationVersion < 7 {
// Product baseline: install these text skills once for existing
// layouts. Skill-management visibility is a separate policy.
additionIDs.formUnion([
AIClipboardSkillCatalog.replyID,
AIClipboardSkillCatalog.translateID,
AIClipboardSkillCatalog.summarizeID,
AIClipboardSkillCatalog.declineInvitationID,
AIClipboardSkillCatalog.clarifyRequestID,
AIClipboardSkillCatalog.empathyReplyID,
AIClipboardSkillCatalog.organizeListID
])
}
if storedMigrationVersion < 8 {
additionIDs.insert(AIClipboardSkillCatalog.blessingReplyID)
}
// v9 consolidates playful and business reply into Reply. Do not
// add Reply here: `sanitized` preserves it only when any reply ID
// was enabled, so a user's explicit disabled state stays disabled.
// v10 applies the same canonical migration to Empathetic Reply.
// v11 folds invitation, task, blessing, and clarification replies
// into Reply. `sanitized` again preserves explicit disabled state.
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 = 11
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
}
/// Applies a read-modify-write operation to the latest persisted dictionary.
///
/// Returning `nil` cancels the write. Callers must not mutate the dictionary
/// before returning `nil`.
@discardableResult
public func updatePersonalDictionary<Result>(
_ transform: (inout PersonalDictionary) -> Result?
) -> (dictionary: PersonalDictionary, result: Result)? {
var output: (dictionary: PersonalDictionary, result: Result)?
mutateConfiguration { config in
guard let result = transform(&config.personalDictionary) else { return }
output = (config.personalDictionary, result)
}
guard let output else { return nil }
AppGroupConfigDarwin.postConfigChanged()
#if os(iOS)
PersonalDictionaryRimeSync.scheduleAfterDictionaryChange()
#endif
return output
}
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
)
}
}