feat(keyboard): improve typing, voice flow, and polish reliability

Reduce extension memory pressure and delivery races while adding richer candidates, tactile feedback, and safer two-level creative polishing.
This commit is contained in:
Rocky
2026-08-05 21:39:31 +08:00
parent 38e5ad570d
commit 31f5937a7f
177 changed files with 8343 additions and 3904 deletions
+127 -42
View File
@@ -36,7 +36,15 @@ public final class KeyboardViewController: UIInputViewController {
public typealias State = KeyboardState
private let state = State()
private let typingSession = TypingSessionController()
/// Created on first typing use so the default voice surface never pays
/// for `TypingSessionController` / engine factories at KVC init.
private var typingSessionStorage: TypingSessionController?
private var typingSession: TypingSessionController {
if let typingSessionStorage { return typingSessionStorage }
let created = TypingSessionController()
typingSessionStorage = created
return created
}
private let persistor = AppGroupPersistor()
private var hosting: UIHostingController<KeyboardSurfaceRoot>?
@@ -47,40 +55,89 @@ public final class KeyboardViewController: UIInputViewController {
private var textInserter: KeyboardTextInserter!
private var flowCoordinator: KeyboardFlowCoordinator!
private var configSync: KeyboardConfigSync!
private var cursorDrag: CursorDragController!
/// UIKit may synchronously lay out the view during `viewDidLoad`.
/// Keep this optional so an early layout pass is harmless.
private var cursorDrag: CursorDragController?
private var targetKeyboardHeight: CGFloat {
KeyboardSurfaceRoot.height(for: state.surface)
}
// MARK: - Init
public override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
OSGDiag.log("KVC.init(nib) begin \(OSGDiag.memoryTag())", category: "boot")
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
OSGDiag.log("KVC.init(nib) done \(OSGDiag.memoryTag())", category: "boot")
}
public required init?(coder: NSCoder) {
OSGDiag.log("KVC.init(coder) begin \(OSGDiag.memoryTag())", category: "boot")
super.init(coder: coder)
OSGDiag.log("KVC.init(coder) done \(OSGDiag.memoryTag())", category: "boot")
}
deinit {
// Intentionally NSLog-only: deinit is nonisolated.
NSLog("%@", "[OSGDiag/boot] KVC.deinit")
}
// MARK: - Lifecycle
public override func viewDidLoad() {
super.viewDidLoad()
// Voice-first keyboard hide the misleading "English" subtitle in Settings.
primaryLanguage = "mis"
OSGLog.keyboardExt.info("viewDidLoad — extension booted")
let preferred = TypingInputConfiguration.preferredSurfaceOnOpen()
OSGDiag.log(
"KVC.viewDidLoad begin preferredSurface=\(preferred.rawValue) "
+ "fullAccess=\(hasFullAccess) \(OSGDiag.memoryTag())",
category: "boot"
)
// Deliberately NO CustomLanguageModelManager prewarm here: the
// extension never runs ASR (the host app owns the microphone and
// the SpeechAnalyzer pipeline), and compiling/caching an LM inside
// the keyboard's ~60 MB jetsam budget risks the system killing the
// keyboard outright. The host app prewarms it on session start.
setNeedsUpdateOfScreenEdgesDeferringSystemGestures()
// Establish layout dependencies before applying the preferred surface.
// `applySurface` updates height and UIKit may lay out synchronously.
installKeyboardHeight()
configureDictationBehavior()
installServices()
OSGDiag.log("KVC.viewDidLoad after installServices \(OSGDiag.memoryTag())", category: "boot")
// Apply open preference before mounting SwiftUI so the first frame is
// already voice or typing avoids a visible surface flash.
applyPreferredSurfaceOnOpen()
OSGDiag.log("KVC.viewDidLoad after preferredSurface surface=\(state.surface.rawValue)", category: "boot")
installTypingContextProviders()
installStateActions()
installSurfaceObservers()
installSwiftUI()
OSGDiag.log("KVC.viewDidLoad after installSwiftUI \(OSGDiag.memoryTag())", category: "boot")
_ = configSync.loadPersistedConfig()
configSync.installDarwinObservers()
flowCoordinator.refreshSessionState()
OSGDiag.log(
"KVC.viewDidLoad done surface=\(state.surface.rawValue) "
+ "sessionActive=\(FlowSessionBridge.isSessionActive()) "
+ "hostReady=\(FlowSessionBridge.isHostReady()) \(OSGDiag.memoryTag())",
category: "boot"
)
}
public override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
OSGDiag.log(
"KVC.viewWillDisappear surface=\(state.surface.rawValue) "
+ "preserve=\(flowCoordinator.preservesLifecycleOnDisappear) \(OSGDiag.memoryTag())",
category: "boot"
)
flowCoordinator.stopSessionMonitor()
// Remember what the user left on, then pre-position a reused
// extension instance for the next open policy (no first-frame jump).
TypingInputConfiguration.persistLastSurface(state.surface)
prepareSurfaceForNextPresentation()
if state.surface == .typing {
typingSession.leaveTypingMode()
}
@@ -93,6 +150,11 @@ public final class KeyboardViewController: UIInputViewController {
public override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
OSGDiag.log(
"KVC.viewWillAppear begin surface=\(state.surface.rawValue) "
+ "fullAccess=\(hasFullAccess) \(OSGDiag.memoryTag())",
category: "boot"
)
setNeedsUpdateOfScreenEdgesDeferringSystemGestures()
configureDictationBehavior()
KeyboardSetupBridge.markExtensionAppearance(hasFullAccess: hasFullAccess)
@@ -101,23 +163,41 @@ public final class KeyboardViewController: UIInputViewController {
flowCoordinator.startSessionMonitor()
configSync.syncOnboardingStateFromAppGroup()
configSync.refreshConfigFromAppGroup()
// Settings may have changed while the extension stayed alive.
applyPreferredSurfaceOnOpen()
if state.surface == .typing {
OSGDiag.log("KVC.viewWillAppear enterTypingMode", category: "boot")
typingSession.enterTypingMode()
}
configSync.autoAdvancePastKeyboardSetupStepIfNeeded()
OSGDiag.log(
"KVC.viewWillAppear done surface=\(state.surface.rawValue) \(OSGDiag.memoryTag())",
category: "boot"
)
}
public override func viewIsAppearing(_ animated: Bool) {
super.viewIsAppearing(animated)
applyPresentationHeightOffset()
OSGDiag.log(
"KVC.viewIsAppearing height=\(keyboardHeightConstraint?.constant ?? -1) "
+ "\(OSGDiag.memoryTag())",
category: "boot"
)
}
public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
OSGDiag.log(
"KVC.viewDidAppear begin surface=\(state.surface.rawValue) \(OSGDiag.memoryTag())",
category: "boot"
)
disableSystemGestureDelays()
keyboardHeightConstraint?.constant = targetKeyboardHeight
refreshReturnKeyRole()
OSGDiag.log(
"KVC.viewDidAppear done height=\(targetKeyboardHeight) \(OSGDiag.memoryTag())",
category: "boot"
)
}
public override func textDidChange(_ textInput: UITextInput?) {
@@ -135,6 +215,10 @@ public final class KeyboardViewController: UIInputViewController {
public override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
OSGDiag.log(
"KVC.didReceiveMemoryWarning surface=\(state.surface.rawValue) \(OSGDiag.memoryTag())",
category: "boot"
)
flowCoordinator.cancelPipelineUnlessAwaitingResult()
if state.surface == .typing {
typingSession.leaveTypingMode()
@@ -146,7 +230,7 @@ public final class KeyboardViewController: UIInputViewController {
public override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
cursorDrag.layoutChrome()
cursorDrag?.layoutChrome()
}
// MARK: - Services
@@ -201,22 +285,17 @@ public final class KeyboardViewController: UIInputViewController {
state.setTranslationTargetLocaleId = { [weak self] id in
self?.configSync.persistTranslationTargetLocaleId(id)
}
state.advanceOnboarding = { [weak self] in self?.configSync.advanceOnboarding() }
state.completeOnboarding = { [weak self] in self?.configSync.completeOnboarding() }
state.requestMicPermission = { [weak self] in self?.requestMicPermissionFromExtension() }
state.requestSpeechPermission = { [weak self] in self?.requestSpeechPermissionFromExtension() }
state.openSystemSettings = { [weak self] in self?.openSystemSettingsFromExtension() }
state.insertNewline = { [weak self] in self?.textDocumentProxy.insertText("\n") }
state.insertSpace = { [weak self] in self?.textDocumentProxy.insertText(" ") }
state.deleteBackward = { [weak self] in self?.textDocumentProxy.deleteBackward() }
state.moveCursorHorizontal = { [weak self] steps in
self?.cursorDrag.moveCursorHorizontally(by: steps)
self?.cursorDrag?.moveCursorHorizontally(by: steps)
}
state.moveCursorVertical = { [weak self] steps in
self?.cursorDrag.moveCursorVertically(by: steps)
self?.cursorDrag?.moveCursorVertically(by: steps)
}
state.setCursorDragActive = { [weak self] active in
self?.cursorDrag.setCursorDragActive(active)
self?.cursorDrag?.setCursorDragActive(active)
}
state.setSurface = { [weak self] surface in
self?.applySurface(surface)
@@ -244,24 +323,48 @@ public final class KeyboardViewController: UIInputViewController {
private func applySurface(_ surface: State.Surface) {
if surface == .typing, state.locksTypingSurface {
OSGDiag.log("applySurface blocked typing (locksTypingSurface)", category: "boot")
return
}
if surface == .typing, FlowSessionBridge.isHostHeavy() {
OSGDiag.log("applySurface stay voice hostHeavy=1", category: "boot")
return
}
guard state.surface != surface else {
refreshKeyboardHeight()
return
}
OSGDiag.log(
"applySurface \(state.surface.rawValue)\(surface.rawValue) \(OSGDiag.memoryTag())",
category: "boot"
)
state.surface = surface
if surface == .voice {
typingSession.leaveTypingMode()
} else {
typingSession.enterTypingMode()
}
refreshKeyboardHeight()
}
private func applyPreferredSurfaceOnOpen() {
let preferredSurface: State.Surface = TypingInputConfiguration.prefersTypingOnOpen()
? .typing
: .voice
applySurface(preferredSurface)
let preferred = TypingInputConfiguration.preferredSurfaceOnOpen()
OSGDiag.log(
"applyPreferredSurfaceOnOpen preferred=\(preferred.rawValue) "
+ "remember=\(TypingInputConfiguration.remembersLastSurface()) "
+ "defaultTyping=\(TypingInputConfiguration.prefersTypingOnOpen())",
category: "boot"
)
applySurface(preferred)
}
/// When not remembering, snap to the static open preference while hidden
/// so a reused keyboard instance does not animate voice typing on show.
private func prepareSurfaceForNextPresentation() {
guard !TypingInputConfiguration.remembersLastSurface() else { return }
let preferred = TypingInputConfiguration.preferredSurfaceOnOpen()
guard state.surface != preferred else { return }
state.surface = preferred
}
private func refreshKeyboardHeight() {
@@ -270,10 +373,11 @@ public final class KeyboardViewController: UIInputViewController {
// switch subtracts the system's ~228 pt encapsulated height from the
// requested typing height and collapses the keyboard to a thin strip.
// Once presented, update our height constraint directly, matching the
// final assignment in `viewDidAppear`.
// final assignment in `viewDidAppear`. Avoid synchronous layout here:
// this is also called during `viewDidLoad`, where re-entrant layout can
// observe partially initialized controller dependencies.
keyboardHeightConstraint?.constant = targetKeyboardHeight
view.setNeedsLayout()
view.layoutIfNeeded()
}
private func refreshReturnKeyRole() {
@@ -328,19 +432,11 @@ public final class KeyboardViewController: UIInputViewController {
hasDictationKey = true
}
/// Only walk our own input-view subtree. Recursing into the host window /
/// root VC previously risked "System gesture gate timed out" and the
/// system killing the keyboard plugin.
private func disableSystemGestureDelays() {
disableGestureDelays(in: view)
var parent = view.superview
while let current = parent {
disableGestureDelays(in: current)
parent = current.superview
}
if let window = view.window {
disableGestureDelays(in: window)
if let rootView = window.rootViewController?.view {
disableGestureDelays(in: rootView)
}
}
}
private func disableGestureDelays(in targetView: UIView) {
@@ -355,17 +451,6 @@ public final class KeyboardViewController: UIInputViewController {
targetView.subviews.forEach(disableGestureDelays)
}
// MARK: - Permission / settings stubs (v0.3.0)
private func requestMicPermissionFromExtension() {}
private func requestSpeechPermissionFromExtension() {}
private func openSystemSettingsFromExtension() {
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
HostAppLauncher.open(url: url, from: self) { _ in }
}
// MARK: - Layout
private func installKeyboardHeight() {
@@ -414,7 +499,7 @@ public final class KeyboardViewController: UIInputViewController {
])
host.didMove(toParent: self)
hosting = host
cursorDrag.install(on: view)
cursorDrag?.install(on: view)
}
// MARK: - App context