05e005e9ce
Batch of in-progress app work from the working tree. - feat(keyboard): CursorNavigation + CursorDragPad for caret movement; KeyboardSoundFeedback for system key click sounds - feat(dictionary): DictionaryAliasGenerator + PersonalDictionaryEntrySheet; TranscriptPostProcessor quality gate; retire DictionaryLearner - feat(ui): TabBarVisibility handling; drop PageHeaderRow / PageHeaderConfirmButton; refresh views and localizable strings - fix(security): move the hardcoded DeepSeek key out of PreconfiguredKeys.swift into a gitignored PreconfiguredKeys.local.swift (seeded from .example by generate-xcodeproj.sh) - docs(agents): add Conventional Commits versioning + bilingual changelog rules - chore(gitignore): ignore PreconfiguredKeys.local.swift, .cache/, pycache Custom language model / lexicon work stays on feature/custom-language-model-asr. Changelog bullets added under [Unreleased]; no version bump.
152 lines
6.5 KiB
Swift
152 lines
6.5 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
|
|
state.localASRBackend = store.localASRBackend
|
|
// 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.localModeCloudPolishEnabled = store.localModeCloudPolishEnabled
|
|
state.micDisabled = store.isCloudAPIKeyMissingForVoiceInput
|
|
state.micDisabledHint = store.isCloudAPIKeyMissingForVoiceInput
|
|
? ExtL10n.string("keyboard.mic.disabled.missingApiKey")
|
|
: ""
|
|
// v0.2.0: iOS `SpeechAnalyzer` is always ready; mirror that
|
|
// into the State flags so downstream consumers see the same
|
|
// shape they did when the previous Qwen3 stack reported "ready".
|
|
state.localModelsReady = true
|
|
state.localModelsLoaded = false
|
|
|
|
#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)
|
|
localASRBackend = \(store.localASRBackend.rawValue)
|
|
""")
|
|
#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
|
|
state.localASRBackend = store.localASRBackend
|
|
state.localModeCloudPolishEnabled = store.localModeCloudPolishEnabled
|
|
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")
|
|
: ""
|
|
// v0.2.0: iOS `SpeechAnalyzer` is always ready. Keep these
|
|
// toggles here so the keyboard UI doesn't flicker if the host
|
|
// app briefly clears them while refactoring.
|
|
state.localModelsReady = true
|
|
state.localModelsLoaded = false
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
|
|
/// Persist `localASRBackend` to the App Group store.
|
|
public func persist(localASRBackend: LocalASRBackend) {
|
|
guard AppGroup.isAvailable else { return }
|
|
AppGroupStore().setLocalASRBackend(localASRBackend)
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
} |