- record analytics/usage only in the host app; the extension no longer opens the SQLite queues, so hiding the keyboard cannot hold WAL locks (0xdead10cc) - sync clipboard history writes across processes so deletes made on one side are not reverted by the other - guard memory-relief reentrancy on controller and telemetry sides to break the recursive crash loop - resolve credentials off the main thread and migrate legacy keys once per install, so Keychain reads cannot be caught in the suspend window - gate Volcengine ASR ping completion to once (SIGTRAP on double resume) - treat same-text captures as the same logical copy and dedupe auto actions by content fingerprint within 30s, stopping Universal Clipboard storms - realign version line to 2.1.0 (build 110)
217 lines
9.7 KiB
Swift
217 lines
9.7 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
|
|
// 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.clipboardHistoryEnabled = store.clipboardHistoryEnabled
|
|
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
|
|
state.clipboardAutoModeEnabled = store.clipboardAutoModeEnabled
|
|
state.clipboardAutoTranslateEnabled = store.clipboardAutoTranslateEnabled
|
|
state.clipboardAutoEmailReplyEnabled = store.clipboardAutoEmailReplyEnabled
|
|
applySkillSnapshot(store: store, into: state)
|
|
state.keyboardHapticIntensity = store.keyboardHapticIntensity
|
|
// Credential availability is intentionally NOT resolved here: it needs
|
|
// synchronous Keychain reads, which are a RunningBoard 0xdead10cc kill
|
|
// vector on the extension's main thread inside the appear/suspend
|
|
// window. KeyboardState's optimistic defaults stand until
|
|
// `KeyboardConfigSync.refreshCredentialsFromKeychain()` lands.
|
|
|
|
#if DEBUG
|
|
// Log only credential availability and the base URL origin. Never put
|
|
// credential fragments or URL path/query/userinfo into device logs.
|
|
let credentialStatus: String
|
|
switch Keychain.apiKeyOutcome(for: store.providerId, preferICloudSync: true) {
|
|
case .found(let value):
|
|
credentialStatus = value.isEmpty ? "empty" : "configured"
|
|
case .notFound:
|
|
credentialStatus = store.apiKey.isEmpty ? "empty" : "configured"
|
|
case .unavailable:
|
|
credentialStatus = "keychainUnavailable"
|
|
}
|
|
let components = URLComponents(string: store.baseURL)
|
|
let baseURLOrigin = components?.scheme.flatMap { scheme in
|
|
components?.host.map { host in "\(scheme)://\(host)" }
|
|
} ?? "<invalid>"
|
|
print("""
|
|
🔍 [AppGroupPersistor.load]
|
|
providerId = \(store.providerId)
|
|
baseURLOrigin = \(baseURLOrigin)
|
|
credential = \(credentialStatus)
|
|
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
|
|
state.localeId = store.localeId
|
|
let shouldProtectTranslation = KeyboardTranslationConfigProtection.shouldProtect(
|
|
until: protectTranslationUntil
|
|
)
|
|
if !shouldProtectTranslation {
|
|
state.translationTargetLocaleId = store.translationTargetLocaleId
|
|
}
|
|
state.handednessPreference = store.handednessPreference
|
|
state.clipboardHistoryEnabled = store.clipboardHistoryEnabled
|
|
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
|
|
state.clipboardAutoModeEnabled = store.clipboardAutoModeEnabled
|
|
state.clipboardAutoTranslateEnabled = store.clipboardAutoTranslateEnabled
|
|
state.clipboardAutoEmailReplyEnabled = store.clipboardAutoEmailReplyEnabled
|
|
applySkillSnapshot(store: store, into: state)
|
|
state.keyboardHapticIntensity = store.keyboardHapticIntensity
|
|
// Credential availability is refreshed asynchronously — see load(into:).
|
|
}
|
|
|
|
/// Resolve once outside SwiftUI body evaluation. Assigning the complete
|
|
/// value snapshot publishes remote copy changes even when slot IDs are stable.
|
|
private func applySkillSnapshot(
|
|
store: AppGroupStore,
|
|
into state: KeyboardViewController.State
|
|
) {
|
|
let layout = store.agentSkillLayout
|
|
let language = store.uiLanguage
|
|
state.uiLanguage = language
|
|
let personalReplyStyle = store.personalReplyStyle
|
|
let hasPersonalReplyStyle = personalReplyStyle != nil
|
|
let enabledIDs = AIClipboardSkillCatalog.availableEnabledIDs(
|
|
layout.enabledIDs,
|
|
layout: layout,
|
|
hasPersonalReplyStyle: hasPersonalReplyStyle
|
|
)
|
|
state.enabledClipboardSkillIDs = enabledIDs
|
|
state.confirmedClipboardShortcutIDs = layout.confirmedShortcutIDs
|
|
state.enabledClipboardSkills = AIClipboardSkillCatalog.visible(
|
|
enabledIDs: enabledIDs,
|
|
officialCatalog: store.officialSkillCatalog,
|
|
userCatalog: store.agentUserSkillCatalog,
|
|
uiLanguage: language
|
|
)
|
|
// The keyboard's chips are ranked from this full catalog, not from the
|
|
// enabled list, so an unavailable skill has to be removed here or it
|
|
// still reaches the surface (and `AIKeyboardCoordinator` could run it).
|
|
state.clipboardSkillCatalog = AIClipboardSkillCatalog.availableForKeyboard(
|
|
AIClipboardSkillCatalog.all(
|
|
officialCatalog: store.officialSkillCatalog,
|
|
userCatalog: store.agentUserSkillCatalog,
|
|
uiLanguage: language
|
|
),
|
|
layout: layout,
|
|
hasPersonalReplyStyle: hasPersonalReplyStyle
|
|
)
|
|
// Independent of the voice-polish selection: the Styles page shapes
|
|
// dictation, this shapes AI replies. Sharing one selector previously
|
|
// made "clean dictation" and "replies that sound like me" mutually
|
|
// exclusive.
|
|
state.clipboardReplyStyle = AIClipboardReplyStyleContext.resolve(
|
|
personalReplyStyle: personalReplyStyle
|
|
)
|
|
}
|
|
|
|
/// Applies a credential snapshot that was resolved OFF the main actor
|
|
/// (see `KeyboardConfigSync.refreshCredentialsFromKeychain`). This method
|
|
/// itself performs no Keychain access.
|
|
///
|
|
/// Cloud without ASR/LLM keys blocks the mic. Local ASR still works when
|
|
/// the polish key is missing — show a soft tip above the mic instead.
|
|
public func applyCredentialAvailability(
|
|
isPolishKeyMissing: Bool,
|
|
isCloudAPIKeyMissingForVoiceInput: Bool,
|
|
into state: KeyboardViewController.State
|
|
) {
|
|
state.aiServiceAvailable = !isPolishKeyMissing
|
|
if isCloudAPIKeyMissingForVoiceInput {
|
|
state.micDisabled = true
|
|
state.micDisabledHint = ExtL10n.string("keyboard.mic.disabled.missingApiKey")
|
|
} else if isPolishKeyMissing {
|
|
state.micDisabled = false
|
|
state.micDisabledHint = ExtL10n.string("keyboard.mic.hint.missingPolishApiKey")
|
|
} else {
|
|
state.micDisabled = false
|
|
state.micDisabledHint = ""
|
|
}
|
|
}
|
|
|
|
/// 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 translation target locale id (e.g. `"en"`,
|
|
/// `"ja"`, or `TranslationLanguageCatalog.offLocaleId`). The
|
|
/// chip / picker call this through `KeyboardState.setTranslationTargetLocaleId`.
|
|
///
|
|
/// There is no `persist(translationEnabled:)` because 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)
|
|
}
|
|
}
|