diff --git a/CHANGELOG.md b/CHANGELOG.md index e3c10fd..7acade0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Messaging shift to AI voice keyboard / voice double / agentic skills**: README and the product page now lead with “Say it. It’s typed. Speaks like you. Acts for you.” — the personalization story (learned style, three-tone replies) moves ahead of typing, clipboard skills are framed as agentic actions, and typing becomes the fallback capability. / **宣传定位转向 AI 语音键盘 / 嘴替 / Agentic 技能**:README 与产品页改用「开口即文字。会说你的话,会办你的事。」定位;个人化卖点(专属风格、三语气回复)提前,剪贴板技能表述升级为 Agentic 行动,打字退为兜底能力。 - **Product page redesign**: rebuild the GitHub Pages home as a dark product stage with the new Icon Composer app icon, the three-device hero visual, and real on-device videos and UI cards replacing hand-drawn mockups; add 2.1 selling points (personal style, three-tone replies, monthly usage calendar, EventKit skills export, guided trials and credit rewards), a TestFlight link, and self-hosted inline SVG icons; bump page references to 2.1.0. / **产品页重设计**:GitHub Pages 主页改为深色产品舞台,启用 Icon Composer 新图标与三端同框主视觉,以实机视频与真实界面卡片取代手绘示意;补齐 2.1 卖点(专属风格、三语气个性回复、月度用量日历、EventKit 技能写入、引导体验与积分奖励),新增 TestFlight 入口,图标改为自托管内联 SVG;页面版本号更新至 2.1.0。 - **Host-only analytics recording**: the keyboard extension no longer opens the analytics or keyboard-usage SQLite queues. The host app is the only process that records and uploads events, so hiding the keyboard cannot hold WAL locks and get killed as `0xdead10cc`. Keyboard-activated and per-keystroke usage events are no longer collected. / **仅主 App 记埋点**:键盘扩展不再打开统计与键盘用量 SQLite 队列;开库、记账与上传只在主 App 进行,收起键盘时不会因 WAL 锁被系统以 `0xdead10cc` 终止。不再采集「键盘激活」和逐次按键用量。 +- **Lighter keyboard redraws while dictating**: the keyboard now redraws only the parts whose data changed. The microphone level (5 times a second) updates just the waveform bars, and live recognized text updates just the status line, instead of rebuilding the whole voice panel each time. / **听写时键盘重绘更轻**:键盘现在只重绘数据有变化的部分。麦克风音量(每秒 5 次)只刷新波形条,实时识别文字只刷新状态行,不再每次都重建整个语音面板。 ### Fixed - **Keyboard killed after hiding on pinyin**: hiding the keyboard while the pinyin/English typing surface was showing kept librime's user dictionary open, and its LevelDB `LOCK` file in the shared App Group got the suspended extension killed as `0xdead10cc` (about 30 kills in 3 days on one test device). The typing engine is now closed on every hide and reopened on the next show, which adds about 30–50 ms only when a still-running keyboard is reopened. / **拼音界面收起后键盘被系统终止**:在拼音/英文打字界面收起键盘时,librime 用户词库仍保持打开,其位于共享 App Group 的 LevelDB `LOCK` 文件导致挂起的扩展被系统以 `0xdead10cc` 终止(一台测试机 3 天约 30 次)。现在每次收起都会关闭打字引擎、下次弹出时重新打开,仅在复用仍存活的键盘进程时多约 30–50 毫秒。 diff --git a/OSGKeyboard/Views/AIClipboardSkillLayoutDemoView.swift b/OSGKeyboard/Views/AIClipboardSkillLayoutDemoView.swift index fb4850c..4894b71 100644 --- a/OSGKeyboard/Views/AIClipboardSkillLayoutDemoView.swift +++ b/OSGKeyboard/Views/AIClipboardSkillLayoutDemoView.swift @@ -12,7 +12,7 @@ import SwiftUI struct AIClipboardSkillLayoutDemoView: View { @StateObject private var config = ProviderConfig.shared - @StateObject private var state = KeyboardState() + @State private var state = KeyboardState() @StateObject private var typing = TypingSessionController() @State private var count: Int = Self.initialCount diff --git a/OSGKeyboard/Views/AIKeyboardDemoView.swift b/OSGKeyboard/Views/AIKeyboardDemoView.swift index 6a2f8cc..52206cd 100644 --- a/OSGKeyboard/Views/AIKeyboardDemoView.swift +++ b/OSGKeyboard/Views/AIKeyboardDemoView.swift @@ -17,7 +17,7 @@ struct AIKeyboardDemoView: View { } @StateObject private var config = ProviderConfig.shared - @StateObject private var state = KeyboardState() + @State private var state = KeyboardState() @StateObject private var typing = TypingSessionController() @State private var scene: Scene = .settings diff --git a/OSGKeyboard/Views/AssistantKeyboardUITestHarness.swift b/OSGKeyboard/Views/AssistantKeyboardUITestHarness.swift index 93c1a45..d3559d3 100644 --- a/OSGKeyboard/Views/AssistantKeyboardUITestHarness.swift +++ b/OSGKeyboard/Views/AssistantKeyboardUITestHarness.swift @@ -20,7 +20,7 @@ struct AssistantKeyboardUITestHarness: View { case yesNoReply } - @StateObject private var state = KeyboardState() + @State private var state = KeyboardState() @StateObject private var typing = TypingSessionController() @State private var configured = false @Environment(\.colorScheme) private var colorScheme diff --git a/OSGKeyboard/Views/ClipboardHistoryDemoView.swift b/OSGKeyboard/Views/ClipboardHistoryDemoView.swift index 7c8734c..6fbf03d 100644 --- a/OSGKeyboard/Views/ClipboardHistoryDemoView.swift +++ b/OSGKeyboard/Views/ClipboardHistoryDemoView.swift @@ -21,7 +21,7 @@ struct ClipboardHistoryDemoView: View { (actionClusterTopGap - micToButtonGap) / 2 } - @StateObject private var state = KeyboardState() + @State private var state = KeyboardState() @StateObject private var typing = TypingSessionController() // `AIKeyboardView` ranks its skill row off `ClipboardHistoryStore.shared` // and `ClipboardSemanticRankingStore.shared`. Driving a private store here diff --git a/OSGKeyboard/Views/EditDemoView.swift b/OSGKeyboard/Views/EditDemoView.swift index 450ea94..113ab8e 100644 --- a/OSGKeyboard/Views/EditDemoView.swift +++ b/OSGKeyboard/Views/EditDemoView.swift @@ -31,7 +31,7 @@ struct EditDemoView: View { case editing } - @StateObject private var state = KeyboardState() + @State private var state = KeyboardState() @State private var scene: Scene = .hint /// Mirrors the host document for `--preview-fullscreen` recordings. @State private var hostText: String = EditDemoView.originalText diff --git a/OSGKeyboard/Views/SmartReplyDemoView.swift b/OSGKeyboard/Views/SmartReplyDemoView.swift index f6cce94..c22023c 100644 --- a/OSGKeyboard/Views/SmartReplyDemoView.swift +++ b/OSGKeyboard/Views/SmartReplyDemoView.swift @@ -14,7 +14,7 @@ import SwiftUI struct SmartReplyDemoView: View { @StateObject private var config = ProviderConfig.shared - @StateObject private var state = KeyboardState() + @State private var state = KeyboardState() @StateObject private var typing = TypingSessionController() var body: some View { diff --git a/OSGKeyboardExt/KeyboardViewController.swift b/OSGKeyboardExt/KeyboardViewController.swift index 29c3e53..9f93fe5 100644 --- a/OSGKeyboardExt/KeyboardViewController.swift +++ b/OSGKeyboardExt/KeyboardViewController.swift @@ -778,28 +778,38 @@ public final class KeyboardViewController: UIInputViewController { } private func installSurfaceObservers() { - state.$phase - .receive(on: RunLoop.main) - .sink { [weak self] _ in - guard let self else { return } - if self.state.locksTypingSurface, self.state.surface == .typing { - self.applySurface(.voice) - } + let state = state + // `Phase` is not Sendable; reading it only registers the dependency. + observe(Observations { _ = state.phase }) { [weak self] in + guard let self else { return } + if self.state.locksTypingSurface, self.state.surface == .typing { + self.applySurface(.voice) } - .store(in: &cancellables) + } - state.$surface - .receive(on: RunLoop.main) - .sink { [weak self] surface in - guard let self else { return } - if surface == .typing { - self.activateTypingPresentationIfNeeded() - } else { - self.deactivateTypingSurface() - } - self.refreshKeyboardHeight() + observe(Observations { state.surface }) { [weak self] surface in + guard let self else { return } + if surface == .typing { + self.activateTypingPresentationIfNeeded() + } else { + self.deactivateTypingSurface() } - .store(in: &cancellables) + self.refreshKeyboardHeight() + } + } + + /// Runs `handler` for the current value and every later change. The loop + /// is cancelled together with `cancellables` when the controller goes away. + private func observe( + _ values: Observations, + handler: @escaping @MainActor (Element) -> Void + ) { + let task = Task { @MainActor in + for await value in values { + handler(value) + } + } + cancellables.insert(AnyCancellable { task.cancel() }) } /// This App Group property is supplied by the parallel settings module. diff --git a/OSGKeyboardExt/Typing/KeyboardSurfaceRoot.swift b/OSGKeyboardExt/Typing/KeyboardSurfaceRoot.swift index 048b471..083ed1b 100644 --- a/OSGKeyboardExt/Typing/KeyboardSurfaceRoot.swift +++ b/OSGKeyboardExt/Typing/KeyboardSurfaceRoot.swift @@ -10,7 +10,7 @@ struct KeyboardSurfaceRoot: View { @Environment(\.colorScheme) private var colorScheme @Namespace private var keyboardTabSelectionNamespace - @ObservedObject var state: KeyboardState + var state: KeyboardState @ObservedObject var typing: TypingSessionController var onInsert: (String) -> Void diff --git a/OSGKeyboardExt/Typing/TypingRootView.swift b/OSGKeyboardExt/Typing/TypingRootView.swift index 55cb34c..5197baa 100644 --- a/OSGKeyboardExt/Typing/TypingRootView.swift +++ b/OSGKeyboardExt/Typing/TypingRootView.swift @@ -38,7 +38,7 @@ enum TypingLayoutMetrics { struct TypingRootView: View { @Environment(\.colorScheme) private var colorScheme - @ObservedObject var state: KeyboardState + var state: KeyboardState @ObservedObject var typing: TypingSessionController var onInsert: (String) -> Void diff --git a/OSGKeyboardExt/Views/AIKeyboardView.swift b/OSGKeyboardExt/Views/AIKeyboardView.swift index 13f598a..6fe0387 100644 --- a/OSGKeyboardExt/Views/AIKeyboardView.swift +++ b/OSGKeyboardExt/Views/AIKeyboardView.swift @@ -64,7 +64,7 @@ struct AIKeyboardView: View { @Environment(\.colorScheme) private var colorScheme @Environment(\.accessibilityReduceMotion) private var reduceMotion - @ObservedObject var state: KeyboardState + var state: KeyboardState @ObservedObject var typing: TypingSessionController @ObservedObject private var clipboardHistory = ClipboardHistoryStore.shared @ObservedObject private var semanticRanking = ClipboardSemanticRankingStore.shared @@ -604,9 +604,9 @@ struct AIKeyboardView: View { @ViewBuilder private var contextArea: some View { ZStack { - if showsOOBEClipboardSkill, activeStatus == nil { + if showsOOBEClipboardSkill, !hasActiveStatus { clipboardSkillPager - } else if let onboardingPracticeHint, activeStatus == nil { + } else if let onboardingPracticeHint, !hasActiveStatus { Text(onboardingPracticeHint) .font(TypeStyle.footnote.weight(.semibold)) .foregroundStyle(palette.textPrimary) @@ -636,8 +636,12 @@ struct AIKeyboardView: View { } } else if let content = semanticBadgeContent { semanticBadge(content) - } else if let status = activeStatus { - statusText(status.text, color: status.color) + } else if hasActiveStatus { + LiveTranscriptReader(state: state) { transcript in + if let status = activeStatus(lastTranscript: transcript) { + statusText(status.text, color: status.color) + } + } } else if showsLongPressCoach { Text(ExtL10n.string("keyboard.assistant.longPressCoach")) .font(TypeStyle.footnote.weight(.medium)) @@ -720,7 +724,14 @@ struct AIKeyboardView: View { .frame(maxWidth: .infinity) } - private var activeStatus: (text: String, color: Color)? { + /// Whether a status line replaces the idle content. Never depends on the + /// transcript text, so this surface does not re-render per ASR partial. + private var hasActiveStatus: Bool { + activeStatus(lastTranscript: "") != nil + } + + /// `lastTranscript` is passed in so only `LiveTranscriptReader` tracks it. + private func activeStatus(lastTranscript: String) -> (text: String, color: Color)? { if let error = state.aiSession.errorMessage, !error.isEmpty, !ordinaryPipelineIsActive { @@ -757,18 +768,18 @@ struct AIKeyboardView: View { return nil case .requestingPermissions: return ( - state.lastTranscript.isEmpty + lastTranscript.isEmpty ? ExtL10n.string("keyboard.placeholder.preparing") - : state.lastTranscript, + : lastTranscript, palette.textSecondary ) case .recording: - return (state.lastTranscript.isEmpty ? " " : state.lastTranscript, palette.textPrimary) + return (lastTranscript.isEmpty ? " " : lastTranscript, palette.textPrimary) case .processing: return ( - state.lastTranscript.isEmpty + lastTranscript.isEmpty ? ExtL10n.string("keyboard.placeholder.processing") - : state.lastTranscript, + : lastTranscript, palette.textSecondary ) case .error(_, let message): @@ -1149,14 +1160,7 @@ struct AIKeyboardView: View { } private func waveform(color: Color) -> some View { - WaveformView( - level: state.level, - barCount: 7, - color: color, - active: true - ) - .frame(width: 38, height: 26) - .clipped() + AssistantWaveform(state: state, color: color) } private var microphoneTint: Color { @@ -1562,6 +1566,35 @@ struct AIKeyboardView: View { /// Uses a subview's intrinsic width until it reaches a visual cap. Unlike /// `frame(maxWidth:)`, this layout does not expand short labels to the cap, /// so each capsule and its complete hit target follow the actual content. +/// Reads `state.level` in its own body so the 200 ms mic level tick re-renders +/// only these bars instead of the whole assistant surface. +private struct AssistantWaveform: View { + let state: KeyboardState + let color: Color + + var body: some View { + WaveformView( + level: state.level, + barCount: 7, + color: color, + active: true + ) + .frame(width: 38, height: 26) + .clipped() + } +} + +/// Reads `state.lastTranscript` in its own body so per-word ASR partials +/// re-render only the status line. +private struct LiveTranscriptReader: View { + let state: KeyboardState + @ViewBuilder let content: (String) -> Content + + var body: some View { + content(state.lastTranscript) + } +} + private struct IntrinsicWidthCap: Layout { let maxWidth: CGFloat diff --git a/OSGKeyboardExt/Views/GlobeInputModeButton.swift b/OSGKeyboardExt/Views/GlobeInputModeButton.swift index 7916b1a..ae30730 100644 --- a/OSGKeyboardExt/Views/GlobeInputModeButton.swift +++ b/OSGKeyboardExt/Views/GlobeInputModeButton.swift @@ -12,7 +12,7 @@ import SwiftUI import UIKit struct GlobeInputModeButton: UIViewRepresentable { - @ObservedObject var state: KeyboardState + var state: KeyboardState @Binding var isPressed: Bool func makeUIView(context: Context) -> GlobeInputModeButtonView { @@ -41,7 +41,7 @@ struct GlobeInputModeButton: UIViewRepresentable { struct SystemGlobeKey: View { @Environment(\.colorScheme) private var colorScheme @Environment(\.themePalette) private var palette - @ObservedObject var state: KeyboardState + var state: KeyboardState @State private var isPressed = false var width: CGFloat = 44 diff --git a/OSGKeyboardExt/Views/KeyboardTopControls.swift b/OSGKeyboardExt/Views/KeyboardTopControls.swift index e8ce454..230d90d 100644 --- a/OSGKeyboardExt/Views/KeyboardTopControls.swift +++ b/OSGKeyboardExt/Views/KeyboardTopControls.swift @@ -112,7 +112,7 @@ struct KeyboardTopControls: View { @Environment(\.keyboardTabSelectionNamespace) private var sharedSelectionNamespace @Namespace private var fallbackSelectionNamespace - @ObservedObject var state: KeyboardState + var state: KeyboardState @ObservedObject var typing: TypingSessionController let palette: ThemePalette diff --git a/OSGKeyboardExt/Views/LastInputEditView.swift b/OSGKeyboardExt/Views/LastInputEditView.swift index 26137c2..ff2d626 100644 --- a/OSGKeyboardExt/Views/LastInputEditView.swift +++ b/OSGKeyboardExt/Views/LastInputEditView.swift @@ -12,7 +12,7 @@ struct LastInputEditView: View { @Environment(\.colorScheme) private var colorScheme @Environment(\.accessibilityReduceMotion) private var reduceMotion - @ObservedObject var state: KeyboardState + var state: KeyboardState @State private var selectedPage: Int? = 0 private var palette: ThemePalette { diff --git a/OSGKeyboardExtTests/ClipboardSuggestionLifecycleTests.swift b/OSGKeyboardExtTests/ClipboardSuggestionLifecycleTests.swift index 31b1c2a..e18072c 100644 --- a/OSGKeyboardExtTests/ClipboardSuggestionLifecycleTests.swift +++ b/OSGKeyboardExtTests/ClipboardSuggestionLifecycleTests.swift @@ -4,7 +4,7 @@ // Verifies that the transient suggestion belongs to one pasteboard generation // and one keyboard presentation; clipboard history remains independently usable. -import Combine +import Observation @testable import OSGKeyboardShared import XCTest @@ -136,17 +136,19 @@ final class ClipboardSuggestionLifecycleTests: XCTestCase { func testUnchangedPollingDoesNotRepublishSuggestion() { copy("stable clipboard") coordinator.captureIfNeeded() - var publications = 0 - let cancellable = state.$clipboardSuggestionText - .dropFirst() - .sink { _ in publications += 1 } - defer { cancellable.cancel() } + let republished = expectation(description: "suggestion republished") + republished.isInverted = true + withObservationTracking { + _ = state.clipboardSuggestionText + } onChange: { + republished.fulfill() + } coordinator.captureIfNeeded() coordinator.captureIfNeeded() coordinator.captureIfNeeded() - XCTAssertEqual(publications, 0) + wait(for: [republished], timeout: 0.1) XCTAssertEqual(pasteboard.stringReadCount, 1) } diff --git a/OSGKeyboardExtTests/KeyboardStateTests.swift b/OSGKeyboardExtTests/KeyboardStateTests.swift index 63ec86d..f3cb248 100644 --- a/OSGKeyboardExtTests/KeyboardStateTests.swift +++ b/OSGKeyboardExtTests/KeyboardStateTests.swift @@ -13,7 +13,7 @@ // *don't* attempt that here — the State class is what we care about // for correctness. -import Combine +import Observation @testable import OSGKeyboardShared import XCTest @@ -189,19 +189,17 @@ final class KeyboardStateTests: XCTestCase { ) state.enabledClipboardSkills = [first] let published = expectation(description: "resolved skill snapshot published") - var cancellable: AnyCancellable? - cancellable = state.$enabledClipboardSkills - .dropFirst() - .sink { skills in - XCTAssertEqual(skills.first?.customPrompt, "Second prompt") - published.fulfill() - } + withObservationTracking { + _ = state.enabledClipboardSkills + } onChange: { + published.fulfill() + } state.enabledClipboardSkills = [second] wait(for: [published], timeout: 1) + XCTAssertEqual(state.enabledClipboardSkills.first?.customPrompt, "Second prompt") XCTAssertEqual(state.enabledClipboardSkillIDs, ["official.rewrite"]) - withExtendedLifetime(cancellable) {} } func testReturnKeyRoleActionFillAndTitles() { diff --git a/OSGKeyboardShared/Services/KeyboardState.swift b/OSGKeyboardShared/Services/KeyboardState.swift index de5277a..fc515ff 100644 --- a/OSGKeyboardShared/Services/KeyboardState.swift +++ b/OSGKeyboardShared/Services/KeyboardState.swift @@ -7,13 +7,16 @@ // (`KeyboardViewController`) re-exports the same type as a typealias so // existing call sites (`KeyboardViewController.State`) keep compiling. -import Combine 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 -public final class KeyboardState: ObservableObject { +@Observable +public final class KeyboardState { public init() {} /// Full-height clipboard UI layered over the active keyboard surface. @@ -89,7 +92,7 @@ public final class KeyboardState: ObservableObject { public var id: String { rawValue } } - @Published public var phase: Phase = .idle { + public var phase: Phase = .idle { didSet { // A timeline only describes the current processing wait. if phase != .processing, dictationProgress != nil { @@ -99,47 +102,47 @@ public final class KeyboardState: ObservableObject { } /// Dictation wait shown as the microphone capsule's progress fill. /// Nil outside dictation processing (AI, edit, and cancellation waits). - @Published public var dictationProgress: DictationProgressTimeline? + public var dictationProgress: DictationProgressTimeline? /// Active chrome. Forced to `.voice` while recording / processing. - @Published public var surface: Surface = .voice + 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. - public var autoReplyReturnSurface: Surface? - @Published public var level: Double = 0 - @Published public var mode: InputMode = .polish - @Published public var localeId: String = "auto" - @Published public var lastTranscript: String = "" + @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. - @Published public var onDeviceSupported: Bool = false + public var onDeviceSupported: Bool = false /// Seconds remaining in the current utterance (Flow tap-to-talk). - @Published public var utteranceRemainingSeconds: Int = Int(FlowSessionKeys.maxUtteranceDuration) + 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 + public var flowSessionActive: Bool = false /// Unified mic color / tap / hint source for the keyboard extension. - @Published public var micVoiceAvailability: MicVoiceAvailability = .unavailable(.hostNotReady) + 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 + 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? + 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 + 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 + public var aiServiceAvailable: Bool = true /// One-line helper shown above the mic while `micDisabled == true`. - @Published public var micDisabledHint: String = "" + 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" + 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). @@ -150,122 +153,122 @@ public final class KeyboardState: ObservableObject { /// 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 + public var translationTargetLocaleId: String = TranslationLanguageCatalog.offLocaleId /// Mirrored from App Group — swaps delete / space on the bottom row. - @Published public var handednessPreference: HandednessPreference = .left + 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 + public var returnKeyRole: ReturnKeyRole = .newline /// Opt-in clipboard history capture (mirrored from App Group). - @Published public var clipboardHistoryEnabled: Bool = false + public var clipboardHistoryEnabled: Bool = false /// Current extension permission state. Clipboard history remains readable /// without Full Access, but new system pasteboard entries cannot be captured. - @Published public var hasFullAccess: Bool = false + public var hasFullAccess: Bool = false public var needsClipboardFullAccessHint: Bool { clipboardHistoryEnabled && !hasFullAccess } /// Opt-in clipboard suggestion strip (requires history enabled). - @Published public var clipboardCandidateBarEnabled: Bool = false + 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). - @Published public var clipboardAutoModeEnabled: Bool = false + public var clipboardAutoModeEnabled: Bool = false /// Opt-in: auto-translate a freshly copied non-system-language paste on open. - @Published public var clipboardAutoTranslateEnabled: Bool = false + public var clipboardAutoTranslateEnabled: Bool = false /// Opt-in: auto-draft a reply when a freshly copied paste is an email. - @Published public var clipboardAutoEmailReplyEnabled: Bool = false + 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. - @Published public var autoResultReadOnly: Bool = false + 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"). - @Published public var autoResultInsertLabelKey: String = "keyboard.assistant.insertTranslation" + 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. - @Published public var activeClipboardSkillID: String? + public var activeClipboardSkillID: String? /// Skills-tab order for clipboard chips. Empty → hint carousel. - @Published public var enabledClipboardSkillIDs: [String] = AIAgentSkillLayout.defaultEnabledIDs + 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] = + 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] = + public var clipboardSkillCatalog: [AIClipboardSkill] = AIClipboardSkillCatalog.catalog /// User-owned speaking style injected only into conversational reply skills. /// Built-in polish personalities intentionally leave this nil. - @Published public var clipboardReplyStyle: AIClipboardReplyStyleContext? + public var clipboardReplyStyle: AIClipboardReplyStyleContext? /// App Group setting: one Reply request should return three selectable tones. - @Published public var multipleReplyVariantsEnabled: Bool = false + public var multipleReplyVariantsEnabled: Bool = false /// Export skills whose companion Shortcut setup the user confirmed. - @Published public var confirmedClipboardShortcutIDs: [String] = [] + public var confirmedClipboardShortcutIDs: [String] = [] /// App language captured with the same App Group snapshot as skill copy. - @Published public var uiLanguage: AppUILanguage = .auto + public var uiLanguage: AppUILanguage = .auto /// Export skill currently waiting on the LLM. Nil for transform skills. - @Published public var pendingClipboardSkillID: String? + 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? + @ObservationIgnored public var pendingClipboardSkillSource: String? /// In-keyboard toast (e.g. no todos). Does not leave the host app. - @Published public var skillTipText: String? + public var skillTipText: String? /// Host field is a password / secure entry — never read pasteboard. - @Published public var isSecureTextEntry: Bool = false + 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 + public var clipboardOverlay: ClipboardKeyboardOverlay = .none /// Suggestion strip above keys (newest clipboard item). - @Published public var clipboardSuggestionText: String? + public var clipboardSuggestionText: String? /// Pasteboard changeCount associated with the current suggestion (for dismiss). - @Published public var clipboardSuggestionChangeCount: Int? + 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. - @Published public var dismissedClipboardSkillEntryID: UUID? + public var dismissedClipboardSkillEntryID: UUID? /// Typing-grid haptic strength (off / light / strong). - @Published public var keyboardHapticIntensity: KeyboardHapticIntensity = .default + 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 + 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 + 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 + 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 + public var undoAvailable: Bool = false /// A verified OSG-generated insertion can be edited by voice. - @Published public var editAvailable: Bool = false + public var editAvailable: Bool = false /// `true` while an undone voice insertion can be re-applied (redo buffer). - @Published public var redoAvailable: Bool = false + public var redoAvailable: Bool = false /// `true` when the host field has a non-empty selection (copy enabled). - @Published public var copyAvailable: Bool = false + public var copyAvailable: Bool = false /// `true` when the host field has a non-empty selection (cut enabled). - @Published public var cutAvailable: Bool = false + public var cutAvailable: Bool = false /// Closed state machine for long-press editing of the last insertion. - @Published public var editSession: EditSessionState = .inactive + 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 + 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 + 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 + public var assistantInsertionSucceeded: Bool = false + public var editCanReplaceOriginal: Bool = false /// Short idle feedback (availability, expiry, missing LLM). - @Published public var editHint: String? + public var editHint: String? /// Availability hints use the green accent; failures keep warning styling. - @Published public var editHintIsPositive: Bool = false + public var editHintIsPositive: Bool = false /// Whether translate-and-polish is armed for the current engine. public var isTranslationEffective: Bool { translationEnabled @@ -288,7 +291,7 @@ public final class KeyboardState: ObservableObject { /// 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 var hasCompletedOnboarding: Bool = false public enum ReturnKeyRole: Equatable { case newline @@ -379,76 +382,76 @@ public final class KeyboardState: ObservableObject { } // Action hooks — injected by the view controller at install time. - public var beginRecording: () -> Void = {} - public var endRecording: () -> Void = {} - public var tapMic: () -> Void = {} + @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. - public var setMicTouchActive: (Bool) -> Void = { _ in } + @ObservationIgnored 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 = {} + @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. - public var confirmPendingAIAnswer: () -> Void = {} + @ObservationIgnored public var confirmPendingAIAnswer: () -> Void = {} /// Inserts the complete tapped reply card. The UUID remains available on /// `aiSession.selectedReplyVariant` for a future feedback store. - public var selectAIReplyVariant: (UUID) -> Void = { _ in } - public var discardPendingAIAnswer: () -> Void = {} + @ObservationIgnored public var selectAIReplyVariant: (UUID) -> Void = { _ in } + @ObservationIgnored public var discardPendingAIAnswer: () -> Void = {} /// Performs the focused field's semantic Return action. - public var performAssistantFieldAction: () -> Void = {} + @ObservationIgnored public var performAssistantFieldAction: () -> Void = {} /// Sends a tapped idle hint card as the AI question (skip microphone). - public var submitAIHint: (AIHintCard) -> Void = { _ in } + @ObservationIgnored public var submitAIHint: (AIHintCard) -> Void = { _ in } /// Sends a clipboard skill plus any source-bound Reply scene modifier. - public var submitAIClipboardSkill: (AIClipboardSkill, AIClipboardReplyScene?) -> Void = { _, _ in } + @ObservationIgnored public var submitAIClipboardSkill: (AIClipboardSkill, AIClipboardReplyScene?) -> 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 = {} + @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. - public var openInputMethodSetup: () -> Void = {} + @ObservationIgnored public var openInputMethodSetup: () -> Void = {} /// Opens the host app Settings → Clipboard page (enable history toggle). - public var openClipboardSettings: () -> Void = {} + @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. - public var tryAutoReplyFromGuide: () -> Void = {} + @ObservationIgnored public var tryAutoReplyFromGuide: () -> 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 } + @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). - public var noteUserDidInputText: () -> Void = {} + @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. - 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 } + @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`. - public var setTranslationTargetLocaleId: (String) -> Void = { _ in } - public var insertNewline: () -> Void = {} - public var insertSpace: () -> Void = {} - public var deleteBackward: () -> Void = {} + @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. - public var undoLastInsertion: () -> Void = {} + @ObservationIgnored public var undoLastInsertion: () -> Void = {} /// Redo the last undone voice insertion when `redoAvailable` is true. - public var redoLastInsertion: () -> Void = {} + @ObservationIgnored public var redoLastInsertion: () -> Void = {} /// Copy the current text selection to the pasteboard. - public var copySelection: () -> Void = {} + @ObservationIgnored public var copySelection: () -> Void = {} /// Cut the current text selection (copy + delete). - public var cutSelection: () -> Void = {} + @ObservationIgnored public var cutSelection: () -> Void = {} /// Switch voice ↔ typing. No-ops when voice pipeline is active. - public var setSurface: (Surface) -> Void = { _ in } + @ObservationIgnored public var setSurface: (Surface) -> Void = { _ in } /// Recording / processing must stay on the voice surface. public var locksTypingSurface: Bool {