Files
OSGKeyboard/OSGKeyboardExt/Services/AppGroupPersistor.swift
T
Rocky 31f5937a7f feat(keyboard): improve typing, voice flow, and polish reliability
Reduce extension memory pressure and delivery races while adding richer candidates, tactile feedback, and safer two-level creative polishing.
2026-08-05 21:39:31 +08:00

136 lines
5.6 KiB
Swift

// 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.keyboardHapticIntensity = store.keyboardHapticIntensity
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 = "<empty>"
} 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 = KeyboardTranslationConfigProtection.shouldProtect(
until: protectTranslationUntil
)
if !shouldProtectTranslation {
state.translationTargetLocaleId = store.translationTargetLocaleId
}
state.handednessPreference = store.handednessPreference
state.cursorDragNavigationEnabled = store.cursorDragNavigationEnabled
state.keyboardHapticIntensity = store.keyboardHapticIntensity
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)
}
}