// KeyboardState.swift // OSGKeyboard · Shared // // View-model for the keyboard extension. Lives in Shared (not the // extension target) so unit tests can import it directly without the // `app-extension` linking headaches. The keyboard view controller // (`KeyboardViewController`) re-exports the same type as a typealias so // existing call sites (`KeyboardViewController.State`) keep compiling. import Foundation import Observation import SwiftUI import UIKit /// `@Observable` so each view re-renders only for the properties it reads — /// the 200 ms mic `level` tick no longer invalidates the whole keyboard. @MainActor @Observable public final class KeyboardState { public init() {} /// Full-height clipboard UI layered over the active keyboard surface. public enum ClipboardKeyboardOverlay: Equatable { case none case enableGuide case historyPanel /// One-time "try AI auto-reply" nudge, shown as a clean full-keyboard /// layer for users who never turned auto mode on. case autoReplyGuide } /// Pipeline phase. Errors are structured so the UI layer can choose /// the right icon / copy for each failure mode without /// reverse-parsing a free-form string. public enum Phase: Equatable { case idle case requestingPermissions case recording case processing case error(ErrorKind, message: String? = nil) case denied(Reason) /// Why the pipeline failed. `message` is a short, user-facing /// hint (e.g. "请检查主 App 设置"); the structured kind is what /// drives icon / colour. public enum ErrorKind: Equatable { case micDenied case speechDenied case asr(String) case llm(LLMError) case appGroupUnavailable /// Keyboard extension lacks Full Access for host-app jumps. case fullAccessRequired /// Auto-jump to the host app failed; user must open it manually. case manualOpenRequired /// Host delivered raw transcript; polish step failed or was skipped. case polishDegraded(String) /// Host ASR finished with no usable speech. case noSpeechDetected /// Host ASR was interrupted before a final transcript arrived. case recognitionInterrupted /// Host could not start background audio capture. case hostAudioUnavailable /// Host ASR or pipeline failed with a user-facing message. case hostTranscriptionFailed(String) /// Flow result did not arrive before the keyboard watchdog expired. case flowResultTimeout /// Host Flow session ended while the keyboard was idle. case flowSessionExpired case unknown(String) } public enum Reason: Equatable { case mic, speech } } /// Voice input always runs through polish; legacy off/transcribe modes removed. public enum InputMode: String, CaseIterable, Identifiable { case polish public var id: String { rawValue } public var labelKey: String { "mode.polish" } } /// Keyboard chrome surface. Voice is the default product mode; typing is /// a secondary QWERTY / pinyin surface for quick corrections. public enum Surface: String, CaseIterable, Identifiable, Sendable { case voice case typing case ai public var id: String { rawValue } } public var phase: Phase = .idle { didSet { // A timeline only describes the current processing wait. if phase != .processing, dictationProgress != nil { dictationProgress = nil } } } /// Dictation wait shown as the microphone capsule's progress fill. /// Nil outside dictation processing (AI, edit, and cancellation waits). public var dictationProgress: DictationProgressTimeline? /// Active chrome. Forced to `.voice` while recording / processing. public var surface: Surface = .voice /// Surface the user was on when auto mode switched them to `.voice` to show /// an auto-reply. Restored when the reply is closed; nil when not auto-routed. @ObservationIgnored public var autoReplyReturnSurface: Surface? public var level: Double = 0 public var mode: InputMode = .polish public var localeId: String = "auto" public var lastTranscript: String = "" /// `true` if the active ASR session is running on-device for the /// current locale. With iOS 26's `SpeechAnalyzer` this is always /// `true` — kept on the state object because the UI's status /// badge still wants a single source of truth to read from. public var onDeviceSupported: Bool = false /// Seconds remaining in the current utterance (Flow tap-to-talk). public var utteranceRemainingSeconds: Int = Int(FlowSessionKeys.maxUtteranceDuration) /// Whether the host app's Flow voice session is live and reachable (fresh /// heartbeat). Do not use the App Group session flag alone for UI gating. /// Prefer `micVoiceAvailability` for mic color and tap behavior. public var flowSessionActive: Bool = false /// Unified mic color / tap / hint source for the keyboard extension. public var micVoiceAvailability: MicVoiceAvailability = .unavailable(.hostNotReady) /// Short-lived host-owned practice mode. It unlocks real dictation before /// onboarding completion, but only while the onboarding text field is live. public var isOnboardingPracticeActive: Bool = false /// Current strict OOBE contract used to render feature-specific extension /// state and bind completion to the host-owned session ID. public var oobePracticeSession: OOBEPracticeSession? /// When true, the mic is intentionally disabled (e.g. cloud engine /// selected but the provider-specific API key is missing). public var micDisabled: Bool = false /// AI mode always needs an LLM even when local ASR keeps voice dictation usable. public var aiServiceAvailable: Bool = true /// One-line helper shown above the mic while `micDisabled == true`. public var micDisabledHint: String = "" /// "local" → on-device ASR only. "cloud" → cloud ASR + LLM polish. /// Boot value must match the privacy-safe app default (`local`) so the /// keyboard never assumes the audio-uploading engine before the App /// Group config has been read. public var engineMode: String = "local" /// Derived: translation is on iff a target /// locale has been selected (mirrors `ProviderConfig.translationEnabled` /// so the chip / pipeline read the same source of truth). public var translationEnabled: Bool { translationTargetLocaleId != TranslationLanguageCatalog.offLocaleId } /// Target locale id the translate-and-polish prompt should /// produce (e.g. `"en"`, `"ja"`). Mirrored from `ProviderConfig`. /// Defaults to `offLocaleId` so the keyboard boots in the "off" /// state on first install. public var translationTargetLocaleId: String = TranslationLanguageCatalog.offLocaleId /// Mirrored from App Group — swaps delete / space on the bottom row. public var handednessPreference: HandednessPreference = .left /// Mirrors the host field's return-key intent. The action stays a newline /// insert; host apps decide whether that submits or creates a line break. public var returnKeyRole: ReturnKeyRole = .newline /// Opt-in clipboard history capture (mirrored from App Group). public var clipboardHistoryEnabled: Bool = false /// Current extension permission state. Clipboard history remains readable /// without Full Access, but new system pasteboard entries cannot be captured. public var hasFullAccess: Bool = false public var needsClipboardFullAccessHint: Bool { clipboardHistoryEnabled && !hasFullAccess } /// Opt-in clipboard suggestion strip (requires history enabled). public var clipboardCandidateBarEnabled: Bool = false /// Opt-in auto mode: a freshly copied replyable message is auto-routed into /// the Reply flow when the keyboard opens (requires history enabled). public var clipboardAutoModeEnabled: Bool = false /// Opt-in: auto-translate a freshly copied non-system-language paste on open. public var clipboardAutoTranslateEnabled: Bool = false /// Opt-in: auto-draft a reply when a freshly copied paste is an email. public var clipboardAutoEmailReplyEnabled: Bool = false /// When true, the current AI result is a *staged* auto result (auto-translate /// or auto-email-reply): shown on the keyboard and inserted only when the /// user taps the glass Insert button — never dropped straight into the field. public var autoResultReadOnly: Bool = false /// Localization key for the staged auto result's glass Insert button, so the /// label matches the action (e.g. "Insert translation" vs "Insert reply"). public var autoResultInsertLabelKey: String = "keyboard.assistant.insertTranslation" /// Clipboard skill that produced the currently shown result (reply / translate /// / …). The result panel's top bar offers the *other* applicable skills, so /// this one is excluded from that row. Nil when no clipboard result is active. public var activeClipboardSkillID: String? /// Skills-tab order for clipboard chips. Empty → hint carousel. public var enabledClipboardSkillIDs: [String] = AIAgentSkillLayout.defaultEnabledIDs /// Fully resolved enabled skills. Mirroring value-semantic content here /// ensures Darwin updates publish prompt/name changes even when IDs stay unchanged. public var enabledClipboardSkills: [AIClipboardSkill] = AIClipboardSkillCatalog.visible() /// Complete resolved catalog used for semantic recommendations. Keyboard /// visibility is independent from the Skills page installation state. public var clipboardSkillCatalog: [AIClipboardSkill] = AIClipboardSkillCatalog.catalog /// User-owned speaking style injected only into conversational reply skills. /// Built-in polish personalities intentionally leave this nil. public var clipboardReplyStyle: AIClipboardReplyStyleContext? /// App Group setting: one Reply request should return three selectable tones. public var multipleReplyVariantsEnabled: Bool = false /// Export skills whose companion Shortcut setup the user confirmed. public var confirmedClipboardShortcutIDs: [String] = [] /// App language captured with the same App Group snapshot as skill copy. public var uiLanguage: AppUILanguage = .auto /// Export skill currently waiting on the LLM. Nil for transform skills. public var pendingClipboardSkillID: String? /// Clipboard captured when that export skill was tapped, so the body /// still exists after the 30-second hint window closes. @ObservationIgnored public var pendingClipboardSkillSource: String? /// In-keyboard toast (e.g. no todos). Does not leave the host app. public var skillTipText: String? /// Host field is a password / secure entry — never read pasteboard. public var isSecureTextEntry: Bool = false /// Secure fields hide every clipboard-history entry point. public var canShowClipboardEntry: Bool { !isSecureTextEntry } /// Full-keyboard clipboard overlay (enable guide or history list). public var clipboardOverlay: ClipboardKeyboardOverlay = .none /// Suggestion strip above keys (newest clipboard item). public var clipboardSuggestionText: String? /// Pasteboard changeCount associated with the current suggestion (for dismiss). public var clipboardSuggestionChangeCount: Int? /// Newest clipboard entry the user dismissed from the AI skill top bar. /// Shared (not view-local) so the dismissal survives surface switches — /// otherwise recreating `AIKeyboardView` would resurrect the skill bar /// while the paste suggestion stays suppressed. Reset when a new entry /// arrives so a fresh copy re-shows the skills. public var dismissedClipboardSkillEntryID: UUID? /// Typing-grid haptic strength (off / light / strong). public var keyboardHapticIntensity: KeyboardHapticIntensity = .default /// Single source of truth for selecting iPad-scale keyboard metrics. /// The view controller resolves this from device idiom + horizontal size /// class so SwiftUI and the UIKit height constraint cannot disagree. public var usesIPadLayoutMetrics: Bool = false /// Unfolded iPhone Duo: phone key layout on rows sized to the system /// keyboard of the inner display. Resolved alongside /// `usesIPadLayoutMetrics`; never true at the same time. public var usesExpandedPhoneLayoutMetrics: Bool = false /// Any iPhone in landscape (vertically compact): a shorter phone panel. /// Never true together with the iPad or expanded-phone metrics. public var usesCompactHeightPhoneLayoutMetrics: Bool = false /// Whether the keyboard draws its own globe key: always on iPad, and /// elsewhere only when UIKit reports no system switch below the keyboard. public var showsSystemGlobeKey: Bool = false /// Width the controller sized the keyboard to. Both the UIKit height /// constraint and the SwiftUI key grid pick their metrics from this one /// value so they can never disagree and clip the bottom row. public var layoutWidth: CGFloat = 0 /// Wide hosts swap the voice surface's circle cluster for a keyboard-style /// bottom row aligned with the typing keys (see `VoiceKeyRowLayout`). public var usesVoiceKeyRow: Bool { VoiceKeyRowLayout.isAvailable( usesIPadMetrics: usesIPadLayoutMetrics, usesExpandedPhoneMetrics: usesExpandedPhoneLayoutMetrics, usesCompactHeightPhoneMetrics: usesCompactHeightPhoneLayoutMetrics, hostWidth: layoutWidth ) } /// `true` when the last voice insertion is still at the caret and can /// be undone (suffix-checked against `documentContextBeforeInput`). public var undoAvailable: Bool = false /// A verified OSG-generated insertion can be edited by voice. public var editAvailable: Bool = false /// `true` while an undone voice insertion can be re-applied (redo buffer). public var redoAvailable: Bool = false /// `true` when the host field has a non-empty selection (copy enabled). public var copyAvailable: Bool = false /// `true` when the host field has a non-empty selection (cut enabled). public var cutAvailable: Bool = false /// Closed state machine for long-press editing of the last insertion. public var editSession: EditSessionState = .inactive /// AI conversation UI state for the keyboard surface. The host owns the actual messages. public var aiSession: AISessionState = .inactive /// Whether the assistant's field action can invoke the host Return key. /// Derived from the focused field's role and `UITextDocumentProxy.hasText`. public var assistantActionAvailable: Bool = false /// Brief success pulse rendered on the unified assistant microphone. public var assistantInsertionSucceeded: Bool = false public var editCanReplaceOriginal: Bool = false /// Short idle feedback (availability, expiry, missing LLM). public var editHint: String? /// Availability hints use the green accent; failures keep warning styling. public var editHintIsPositive: Bool = false /// Whether translate-and-polish is armed for the current engine. public var isTranslationEffective: Bool { translationEnabled } /// Convenience shorthand used by the pipeline and views. public var isLocalEngine: Bool { engineMode == "local" } /// Applies the non-persistent secure-field UI policy immediately. public func setSecureTextEntry(_ isSecure: Bool) { isSecureTextEntry = isSecure guard isSecure else { return } clipboardSuggestionText = nil clipboardSuggestionChangeCount = nil clipboardOverlay = .none } // MARK: - Host-app onboarding gate /// Mirrored from App Group / Keychain. Setup UI lives only in the host /// app; the keyboard uses this flag to gate voice (mic / Flow cold-start) /// and prompt a jump back to the app when incomplete. public var hasCompletedOnboarding: Bool = false public enum ReturnKeyRole: Equatable { case newline case send case go case search case join case done case next case `continue` case route case google case yahoo case emergencyCall public var titleKey: String { switch self { case .newline: return "common.newline" case .send: return "common.send" case .go: return "keyboard.return.go" case .search: return "keyboard.return.search" case .join: return "keyboard.return.join" case .done: return "common.done" case .next: return "keyboard.return.next" case .continue: return "common.continue" case .route: return "keyboard.return.route" case .google: return "keyboard.return.google" case .yahoo: return "keyboard.return.yahoo" case .emergencyCall: return "keyboard.return.emergencyCall" } } /// Green action chrome (system uses blue for Go / Search / Send / Done). public var usesActionFill: Bool { self != .newline } /// SF Symbol matching the semantic Return action exposed by the host. public var assistantActionSystemImage: String { switch self { case .newline: return "arrow.turn.down.left" case .send: return "paperplane.fill" case .search, .google, .yahoo: return "magnifyingglass" case .go, .route: return "arrow.right.circle.fill" case .join: return "person.badge.plus" case .done: return "checkmark" case .next, .continue: return "arrow.right" case .emergencyCall: return "phone.fill" } } /// Stable suffix for UI automation and diagnostics. public var assistantActionIdentifier: String { switch self { case .newline: return "newline" case .send: return "send" case .go: return "go" case .search: return "search" case .join: return "join" case .done: return "done" case .next: return "next" case .continue: return "continue" case .route: return "route" case .google: return "google" case .yahoo: return "yahoo" case .emergencyCall: return "emergencyCall" } } /// Text-producing actions need content; navigation actions and newline /// remain useful in an empty field. public func assistantActionAvailable(hasText: Bool) -> Bool { switch self { case .send, .go, .search, .join, .route, .google, .yahoo: return hasText case .newline, .done, .next, .continue, .emergencyCall: return true } } } // Action hooks — injected by the view controller at install time. @ObservationIgnored public var beginRecording: () -> Void = {} @ObservationIgnored public var endRecording: () -> Void = {} @ObservationIgnored public var tapMic: () -> Void = {} /// Starts/cancels a bounded host-audio prime from the user's mic touch. @ObservationIgnored public var setMicTouchActive: (Bool) -> Void = { _ in } /// Discards the complete normal-dictation round, including late ASR/LLM output. @ObservationIgnored public var cancelVoiceInput: () -> Void = {} @ObservationIgnored public var beginEditLastInput: () -> Void = {} @ObservationIgnored public var stopEditListening: () -> Void = {} @ObservationIgnored public var confirmEditResult: () -> Void = {} @ObservationIgnored public var closeEditMode: () -> Void = {} @ObservationIgnored public var tapAIMic: () -> Void = {} @ObservationIgnored public var cancelAIInput: () -> Void = {} /// Explicitly inserts a retained AI result after target validation failed. @ObservationIgnored public var confirmPendingAIAnswer: () -> Void = {} /// Inserts the complete tapped reply card. The UUID remains available on /// `aiSession.selectedReplyVariant` for a future feedback store. @ObservationIgnored public var selectAIReplyVariant: (UUID) -> Void = { _ in } @ObservationIgnored public var discardPendingAIAnswer: () -> Void = {} /// Performs the focused field's semantic Return action. @ObservationIgnored public var performAssistantFieldAction: () -> Void = {} /// Sends a tapped idle hint card as the AI question (skip microphone). @ObservationIgnored public var submitAIHint: (AIHintCard) -> Void = { _ in } /// Sends a clipboard skill plus any source-bound Reply scene modifier. @ObservationIgnored public var submitAIClipboardSkill: (AIClipboardSkill, AIClipboardReplyScene?) -> Void = { _, _ in } /// Writes extract-todos titles and opens the host to run the Shortcut. @ObservationIgnored public var runClipboardExportSkill: (String, [String]) -> Void = { _, _ in } @ObservationIgnored public var openSettings: () -> Void = {} /// Opens the host app straight to input-resource deployment. Used by the /// typing surface when Rime resources have not been deployed yet. @ObservationIgnored public var openInputMethodSetup: () -> Void = {} /// Opens the host app Settings → Clipboard page (enable history toggle). @ObservationIgnored public var openClipboardSettings: () -> Void = {} /// Confirms the full-keyboard "try AI auto-reply" guide: turns auto mode on /// (persisted), dismisses the layer, and drafts a reply for the current copy. @ObservationIgnored public var tryAutoReplyFromGuide: () -> Void = {} /// Top-bar clipboard button: guide when history off, else history panel. @ObservationIgnored public var openClipboardPanel: () -> Void = {} @ObservationIgnored public var dismissClipboardOverlay: () -> Void = {} @ObservationIgnored public var insertClipboardText: (String) -> Void = { _ in } @ObservationIgnored public var dismissClipboardSuggestion: () -> Void = {} @ObservationIgnored public var clearClipboardHistory: () -> Void = {} @ObservationIgnored public var deleteClipboardHistoryEntry: (UUID) -> Void = { _ in } /// Notify that the user inserted text (hides suggestion strip). @ObservationIgnored public var noteUserDidInputText: () -> Void = {} /// System globe (🌐) key target. Kept weak to avoid a state → controller /// ownership cycle; UIKit's standard all-touch-events action provides both /// tap-to-advance and long-press input-mode selection. @ObservationIgnored public weak var inputModeController: UIInputViewController? @ObservationIgnored public var startFlowSession: () -> Void = {} @ObservationIgnored public var setMode: (InputMode) -> Void = { _ in } @ObservationIgnored public var setLocale: (String) -> Void = { _ in } @ObservationIgnored public var setEngineMode: (String) -> Void = { _ in } /// Only the locale picker remains; `enabled` /// is derived from the locale id, so there's no separate toggle to /// persist. Wired in `KeyboardViewController.installStateActions`. @ObservationIgnored public var setTranslationTargetLocaleId: (String) -> Void = { _ in } @ObservationIgnored public var insertNewline: () -> Void = {} @ObservationIgnored public var insertSpace: () -> Void = {} @ObservationIgnored public var deleteBackward: () -> Void = {} /// Undo the last voice insertion when `undoAvailable` is true. @ObservationIgnored public var undoLastInsertion: () -> Void = {} /// Redo the last undone voice insertion when `redoAvailable` is true. @ObservationIgnored public var redoLastInsertion: () -> Void = {} /// Copy the current text selection to the pasteboard. @ObservationIgnored public var copySelection: () -> Void = {} /// Cut the current text selection (copy + delete). @ObservationIgnored public var cutSelection: () -> Void = {} /// Switch voice ↔ typing. No-ops when voice pipeline is active. @ObservationIgnored public var setSurface: (Surface) -> Void = { _ in } /// Recording / processing must stay on the voice surface. public var locksTypingSurface: Bool { if editSession.isActive { return true } if aiSession.isBusy { return true } switch phase { case .requestingPermissions, .recording, .processing: return true default: return false } } public var canEnterTypingSurface: Bool { !locksTypingSurface } public var canCancelAIInput: Bool { aiSession.isBusy } /// Normal dictation can be discarded from microphone startup through /// ASR / polish, including the abort-wait after Cancel until the host /// acks (coordinator keeps `phase == .processing` for that window). public var canCancelVoiceInput: Bool { guard !editSession.isActive else { return false } switch phase { case .requestingPermissions, .recording, .processing: return true case .idle, .error, .denied: return false } } // MARK: - Preview helpers (DEBUG only) #if DEBUG public static var previewIdle: KeyboardState { let s = KeyboardState() s.phase = .idle s.level = 0 s.mode = .polish s.localeId = "zh-Hans" s.lastTranscript = "" return s } public static var previewRecording: KeyboardState { let s = KeyboardState() s.phase = .recording s.level = 0.65 s.mode = .polish s.localeId = "zh-Hans" s.lastTranscript = "你好,我想说一段测试" return s } public static var previewProcessing: KeyboardState { let s = KeyboardState() s.phase = .processing s.level = 0 s.mode = .polish s.localeId = "zh-Hans" s.lastTranscript = "" return s } #endif } extension KeyboardState.Phase.ErrorKind { /// Maps a host-app Flow transcription failure into a keyboard error kind. public static func fromFlowTranscription(_ error: FlowTranscriptionError) -> Self { switch error.kind { case .noSpeech: return .noSpeechDetected case .recognitionInterrupted: return .recognitionInterrupted case .audioUnavailable: return .hostAudioUnavailable case .asrFailed, .generic: return .hostTranscriptionFailed(error.message) case .discardedEmpty: return .noSpeechDetected } } }