// 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 Combine import Foundation import SwiftUI import UIKit @MainActor public final class KeyboardState: ObservableObject { public init() {} /// Full-height clipboard UI layered over the active keyboard surface. public enum ClipboardKeyboardOverlay: Equatable { case none case enableGuide case historyPanel } /// 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 } } @Published public var phase: Phase = .idle /// Active chrome. Forced to `.voice` while recording / processing. @Published public var surface: Surface = .voice @Published public var level: Double = 0 @Published public var mode: InputMode = .polish @Published public var localeId: String = "auto" @Published 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. @Published public var onDeviceSupported: Bool = false /// Seconds remaining in the current utterance (Flow tap-to-talk). @Published 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. @Published public var flowSessionActive: Bool = false /// Unified mic color / tap / hint source for the keyboard extension. @Published 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. @Published 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. @Published public var oobePracticeSession: OOBEPracticeSession? /// When true, the mic is intentionally disabled (e.g. cloud engine /// selected but the provider-specific API key is missing). @Published public var micDisabled: Bool = false /// AI mode always needs an LLM even when local ASR keeps voice dictation usable. @Published public var aiServiceAvailable: Bool = true /// One-line helper shown above the mic while `micDisabled == true`. @Published 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. @Published 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. @Published public var translationTargetLocaleId: String = TranslationLanguageCatalog.offLocaleId /// Mirrored from App Group — swaps delete / space on the bottom row. @Published 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. @Published public var returnKeyRole: ReturnKeyRole = .newline /// Opt-in clipboard history capture (mirrored from App Group). @Published public var clipboardHistoryEnabled: Bool = false /// Opt-in clipboard suggestion strip (requires history enabled). @Published public var clipboardCandidateBarEnabled: Bool = false /// Skills-tab order for clipboard chips. Empty → hint carousel. @Published 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. @Published public var enabledClipboardSkills: [AIClipboardSkill] = AIClipboardSkillCatalog.visible() /// Complete resolved catalog used for semantic recommendations. Keyboard /// visibility is independent from the Skills page installation state. @Published public var clipboardSkillCatalog: [AIClipboardSkill] = AIClipboardSkillCatalog.catalog /// Export skills whose companion Shortcut setup the user confirmed. @Published public var confirmedClipboardShortcutIDs: [String] = [] /// App language captured with the same App Group snapshot as skill copy. @Published public var uiLanguage: AppUILanguage = .auto /// Export skill currently waiting on the LLM. Nil for transform skills. @Published public var pendingClipboardSkillID: String? /// Clipboard captured when that export skill was tapped, so the body /// still exists after the 30-second hint window closes. public var pendingClipboardSkillSource: String? /// In-keyboard toast (e.g. no todos). Does not leave the host app. @Published public var skillTipText: String? /// Host field is a password / secure entry — never read pasteboard. @Published 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). @Published public var clipboardOverlay: ClipboardKeyboardOverlay = .none /// Suggestion strip above keys (newest clipboard item). @Published public var clipboardSuggestionText: String? /// Pasteboard changeCount associated with the current suggestion (for dismiss). @Published public var clipboardSuggestionChangeCount: Int? /// Typing-grid haptic strength (off / light / strong). @Published 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. @Published public var usesIPadLayoutMetrics: Bool = false /// The custom system-keyboard switch is iPad-only. iPhone relies on the /// system-provided switch below the keyboard instead of showing a duplicate. @Published 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. @Published public var layoutWidth: CGFloat = 0 /// `true` when the last voice insertion is still at the caret and can /// be undone (suffix-checked against `documentContextBeforeInput`). @Published public var undoAvailable: Bool = false /// A verified OSG-generated insertion can be edited by voice. @Published public var editAvailable: Bool = false /// `true` while an undone voice insertion can be re-applied (redo buffer). @Published public var redoAvailable: Bool = false /// `true` when the host field has a non-empty selection (copy enabled). @Published public var copyAvailable: Bool = false /// `true` when the host field has a non-empty selection (cut enabled). @Published public var cutAvailable: Bool = false /// Closed state machine for long-press editing of the last insertion. @Published public var editSession: EditSessionState = .inactive /// AI conversation UI state for the keyboard surface. The host owns the actual messages. @Published 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`. @Published public var assistantActionAvailable: Bool = false /// Brief success pulse rendered on the unified assistant microphone. @Published public var assistantInsertionSucceeded: Bool = false @Published public var editCanReplaceOriginal: Bool = false /// Short idle feedback (availability, expiry, missing LLM). @Published public var editHint: String? /// Availability hints use the green accent; failures keep warning styling. @Published 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. @Published 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. public var beginRecording: () -> Void = {} public var endRecording: () -> Void = {} public var tapMic: () -> Void = {} /// Starts/cancels a bounded host-audio prime from the user's mic touch. public var setMicTouchActive: (Bool) -> Void = { _ in } /// Discards the complete normal-dictation round, including late ASR/LLM output. public var cancelVoiceInput: () -> Void = {} public var beginEditLastInput: () -> Void = {} public var stopEditListening: () -> Void = {} public var confirmEditResult: () -> Void = {} public var closeEditMode: () -> Void = {} public var tapAIMic: () -> Void = {} public var cancelAIInput: () -> Void = {} /// Explicitly inserts a retained AI result after target validation failed. public var confirmPendingAIAnswer: () -> Void = {} public var discardPendingAIAnswer: () -> Void = {} /// Performs the focused field's semantic Return action. public var performAssistantFieldAction: () -> Void = {} /// Sends a tapped idle hint card as the AI question (skip microphone). public var submitAIHint: (AIHintCard) -> Void = { _ in } /// Sends a clipboard skill (reply / summarize / translate / export). public var submitAIClipboardSkill: (AIClipboardSkill) -> Void = { _ in } /// Writes extract-todos titles and opens the host to run the Shortcut. public var runClipboardExportSkill: (String, [String]) -> Void = { _, _ in } 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. public var openInputMethodSetup: () -> Void = {} /// Opens the host app Settings → Clipboard page (enable history toggle). public var openClipboardSettings: () -> Void = {} /// Top-bar clipboard button: guide when history off, else history panel. public var openClipboardPanel: () -> Void = {} public var dismissClipboardOverlay: () -> Void = {} public var insertClipboardText: (String) -> Void = { _ in } public var dismissClipboardSuggestion: () -> Void = {} public var clearClipboardHistory: () -> Void = {} public var deleteClipboardHistoryEntry: (UUID) -> Void = { _ in } /// Notify that the user inserted text (hides suggestion strip). 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. public weak var inputModeController: UIInputViewController? public var startFlowSession: () -> Void = {} public var setMode: (InputMode) -> Void = { _ in } public var setLocale: (String) -> Void = { _ in } 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`. public var setTranslationTargetLocaleId: (String) -> Void = { _ in } public var insertNewline: () -> Void = {} public var insertSpace: () -> Void = {} public var deleteBackward: () -> Void = {} /// Undo the last voice insertion when `undoAvailable` is true. public var undoLastInsertion: () -> Void = {} /// Redo the last undone voice insertion when `redoAvailable` is true. public var redoLastInsertion: () -> Void = {} /// Copy the current text selection to the pasteboard. public var copySelection: () -> Void = {} /// Cut the current text selection (copy + delete). public var cutSelection: () -> Void = {} /// Switch voice ↔ typing. No-ops when voice pipeline is active. 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 } } }