537a68552a
Reduce perceived latency from key release to final text: - Adaptive chunking: 2.5s first chunk + 5s follow-ups so short utterances start on-device recognition while still recording. - Session-level ASR warmup and audio-format cache reuse to remove per-utterance cold-start of SpeechAnalyzer. - Mirror live pipelined partials to the keyboard transcript line via a new flow.transcriptionPartial App Group key + Darwin ping. Also commits the accumulated custom language model, Flow session, keyboard extension restructure, and Xiaomi MiMo provider work in progress on this branch.
132 lines
5.4 KiB
Swift
132 lines
5.4 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.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 = 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")
|
|
: ""
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
}
|