Hiding the keyboard on the typing surface kept librime's LevelDB userdb LOCK open in the App Group, so RunningBoard killed the suspended extension. Finalize the runtime on every hide (and when schema selection fails); viewWillAppear re-prepares Rime in about 30-50 ms.
1394 lines
59 KiB
Swift
1394 lines
59 KiB
Swift
// KeyboardViewController.swift
|
||
// OSGKeyboard · Keyboard Extension
|
||
//
|
||
// Principal class for the Custom Keyboard Extension. Hosts a single
|
||
// SwiftUI tree (`KeyboardSurfaceRoot`) and wires Flow voice / AI input:
|
||
//
|
||
// host app Flow session ──► App Group transcript ──► insertText
|
||
//
|
||
// Design notes:
|
||
// • The class is `@MainActor` — every UI mutation and `textDocumentProxy`
|
||
// call must happen on main, and Swift 6 strict concurrency forces this.
|
||
// • State is a single `State` ObservableObject; SwiftUI observes it via
|
||
// `@ObservedObject` so we never re-create the hosting root on each tick.
|
||
// • `phase` is a real stored property (no derivation) — the previous
|
||
// "derive from recordStream" shim locked out every press after the first.
|
||
|
||
import Combine
|
||
import OSGKeyboardShared
|
||
import SwiftUI
|
||
import UIKit
|
||
|
||
private final class KeyboardHostingController: UIHostingController<KeyboardSurfaceRoot> {
|
||
override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge {
|
||
[.left, .right]
|
||
}
|
||
|
||
override func viewDidAppear(_ animated: Bool) {
|
||
super.viewDidAppear(animated)
|
||
setNeedsUpdateOfScreenEdgesDeferringSystemGestures()
|
||
}
|
||
}
|
||
|
||
@objc(KeyboardViewController)
|
||
@MainActor
|
||
public final class KeyboardViewController: UIInputViewController {
|
||
public typealias State = KeyboardState
|
||
|
||
private let state = State()
|
||
/// 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()
|
||
created.systemLexicon = UIKitEnglishSystemLexicon()
|
||
typingSessionStorage = created
|
||
return created
|
||
}
|
||
private let persistor = AppGroupPersistor()
|
||
/// Uptime of the most recent key-driven document change. Memory relief uses
|
||
/// it to tell "the user is mid-sentence" from "the typing surface is merely
|
||
/// on screen" — see `isMidTypingBurst`.
|
||
private var lastTypingActivityUptime: TimeInterval = -1
|
||
/// Reentrancy guard for `shedHeavyResources` — see the comment there.
|
||
private var isSheddingHeavyResources = false
|
||
|
||
private var hosting: UIHostingController<KeyboardSurfaceRoot>?
|
||
private var keyboardHeightConstraint: NSLayoutConstraint?
|
||
/// The system owns the input view's height during the slide-in via a
|
||
/// required `UIView-Encapsulated-Layout-Height` constraint, which it walks
|
||
/// from the full screen height down to the keyboard slot. Our own
|
||
/// constraint only has to hold `target` and stay out of that transition.
|
||
private enum HeightPresentationPhase {
|
||
case idle
|
||
case presented
|
||
}
|
||
private var heightPhase: HeightPresentationPhase = .idle
|
||
/// Last logged layout snapshot, so `viewDidLayoutSubviews` only reports
|
||
/// changes instead of every pass.
|
||
private var lastLoggedLayoutSnapshot: String?
|
||
private var cancellables = Set<AnyCancellable>()
|
||
/// Coalesces host-document refreshes after mutations issued by this keyboard.
|
||
private var assistantFieldActionRefreshTask: Task<Void, Never>?
|
||
/// UIKit can reuse this controller across host apps. Keep document-scoped
|
||
/// candidate state separate from the heavy typing-engine lifetime.
|
||
private var isKeyboardPresentationActive = false
|
||
private var isTypingSurfaceActive = false
|
||
private var typingDocumentPresentationID: UInt64?
|
||
|
||
private var memoryTelemetryContext: String {
|
||
let language = typingSessionStorage?.language.rawValue ?? "-"
|
||
return "surface=\(state.surface.rawValue) language=\(language) "
|
||
+ "fullAccess=\(hasFullAccess ? 1 : 0) "
|
||
+ "clipboard=\(state.clipboardHistoryEnabled ? 1 : 0)"
|
||
}
|
||
|
||
private func recordMemory(_ stage: String, details: String? = nil) {
|
||
KeyboardExtensionMemoryTelemetry.updateContext(memoryTelemetryContext)
|
||
KeyboardExtensionMemoryTelemetry.record(stage, details: details)
|
||
}
|
||
|
||
private var editHintScheduler: EditHintScheduler!
|
||
private var textInserter: KeyboardTextInserter!
|
||
private var flowCoordinator: KeyboardFlowCoordinator!
|
||
private var lastInputEditCoordinator: LastInputEditCoordinator!
|
||
private var aiKeyboardCoordinator: AIKeyboardCoordinator!
|
||
private var clipboardCapture: ClipboardCaptureCoordinator!
|
||
private var configSync: KeyboardConfigSync!
|
||
/// iPad-scale keys require both an iPad host and regular horizontal space.
|
||
/// This keeps compact iPad multitasking on phone metrics and prevents wide
|
||
/// iPhones from being mistaken for iPads.
|
||
private var isIPadLayout: Bool {
|
||
KeyboardChromeLayout.usesIPadMetrics(
|
||
isPad: UIDevice.current.userInterfaceIdiom == .pad,
|
||
hasRegularWidth: traitCollection.horizontalSizeClass == .regular
|
||
)
|
||
}
|
||
|
||
/// Width the layout should be sized against. `view.bounds` is empty before
|
||
/// the first layout pass, so fall back to the screen the keyboard is on.
|
||
private var currentLayoutWidth: CGFloat {
|
||
let width = view.bounds.width
|
||
guard width > 0 else {
|
||
return (view.window?.windowScene?.screen ?? UIScreen.main).bounds.width
|
||
}
|
||
return width
|
||
}
|
||
|
||
private var targetKeyboardHeight: CGFloat {
|
||
KeyboardSurfaceRoot.height(
|
||
for: state.surface,
|
||
isIPad: state.usesIPadLayoutMetrics,
|
||
width: state.layoutWidth
|
||
)
|
||
}
|
||
|
||
// MARK: - Init
|
||
|
||
public override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
|
||
KeyboardExtensionMemoryTelemetry.begin(
|
||
context: "surface=uninitialized language=- fullAccess=- clipboard=-"
|
||
)
|
||
OSGDiag.log("KVC.init(nib) begin \(OSGDiag.memoryTag())", category: "boot")
|
||
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
|
||
recordMemory("KVC.init.done")
|
||
OSGDiag.log("KVC.init(nib) done \(OSGDiag.memoryTag())", category: "boot")
|
||
}
|
||
|
||
public required init?(coder: NSCoder) {
|
||
KeyboardExtensionMemoryTelemetry.begin(
|
||
context: "surface=uninitialized language=- fullAccess=- clipboard=-"
|
||
)
|
||
OSGDiag.log("KVC.init(coder) begin \(OSGDiag.memoryTag())", category: "boot")
|
||
super.init(coder: coder)
|
||
recordMemory("KVC.init.done")
|
||
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()
|
||
#if DEBUG
|
||
HostBundleProbe.installLogging()
|
||
#endif
|
||
state.showsSystemGlobeKey = UIDevice.current.userInterfaceIdiom == .pad
|
||
// Voice-first keyboard — hide the misleading "English" subtitle in Settings.
|
||
primaryLanguage = "mis"
|
||
let preferred = TypingInputConfiguration.preferredSurfaceOnOpen()
|
||
recordMemory(
|
||
"KVC.viewDidLoad.begin",
|
||
details: "preferredSurface=\(preferred.rawValue)"
|
||
)
|
||
KeyboardExtensionMemoryTelemetry.startBootSampling()
|
||
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.
|
||
refreshLayoutMode()
|
||
installKeyboardHeight()
|
||
configureDictationBehavior()
|
||
installServices()
|
||
recordMemory("KVC.viewDidLoad.afterInstallServices")
|
||
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()
|
||
recordMemory("KVC.viewDidLoad.afterPreferredSurface")
|
||
OSGDiag.log("KVC.viewDidLoad after preferredSurface surface=\(state.surface.rawValue)", category: "boot")
|
||
installTypingContextProviders()
|
||
installStateActions()
|
||
installSurfaceObservers()
|
||
installSwiftUI()
|
||
recordMemory("KVC.viewDidLoad.afterInstallSwiftUI")
|
||
OSGDiag.log("KVC.viewDidLoad after installSwiftUI \(OSGDiag.memoryTag())", category: "boot")
|
||
let configLoadResult = configSync.loadPersistedConfig()
|
||
refreshMultipleReplyVariantsSetting()
|
||
recordMemory(
|
||
"KVC.viewDidLoad.afterConfigLoad",
|
||
details: "result=\(configLoadResult)"
|
||
)
|
||
configSync.installDarwinObservers()
|
||
flowCoordinator.refreshSessionState()
|
||
// Installed last: the handler tears down surfaces, so it must not be
|
||
// reachable until the whole controller is wired up.
|
||
//
|
||
// 临时停用主动内存降级("自杀"逻辑):真机实测语音界面正常工作就要
|
||
// 68-88 MB,远超 48 MiB critical,且诊断里没有任何纯内存 jetsam 记录——
|
||
// 这套阈值一直在防一个不会发生的杀,代价是跳语音和 Rime 反复拆建。
|
||
// 内存遥测的采样/记录保留(供后续压力测试校准阈值);系统真正的内存警告
|
||
// didReceiveMemoryWarning 仍保留作兜底。量出真实 jetsam 上限后再恢复。
|
||
KeyboardExtensionMemoryTelemetry.reliefHandler = nil
|
||
recordMemory(
|
||
"KVC.viewDidLoad.done",
|
||
details: "sessionActive=\(FlowSessionBridge.isSessionActive() ? 1 : 0) "
|
||
+ "hostReady=\(FlowSessionBridge.isHostReady() ? 1 : 0)"
|
||
)
|
||
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)
|
||
isKeyboardPresentationActive = false
|
||
HostAppIdentity.invalidate()
|
||
typingDocumentPresentationID = nil
|
||
typingSessionStorage?.endDocumentPresentation()
|
||
assistantFieldActionRefreshTask?.cancel()
|
||
assistantFieldActionRefreshTask = nil
|
||
clipboardCapture?.keyboardWillDisappear()
|
||
// Presentation-scoped hints must never survive a reused extension
|
||
// controller, including an active Flow handoff.
|
||
editHintScheduler?.invalidate()
|
||
OSGDiag.log(
|
||
"KVC.viewWillDisappear surface=\(state.surface.rawValue) "
|
||
+ "preserve=\(flowCoordinator.preservesLifecycleOnDisappear) \(OSGDiag.memoryTag())",
|
||
category: "boot"
|
||
)
|
||
recordMemory(
|
||
"KVC.viewWillDisappear",
|
||
details: "preserve=\(flowCoordinator.preservesLifecycleOnDisappear ? 1 : 0)"
|
||
)
|
||
heightPhase = .idle
|
||
// A hidden keyboard cannot grow; stop waking the process to sample it.
|
||
KeyboardExtensionMemoryTelemetry.stopSampling()
|
||
flowCoordinator.stopSessionMonitor()
|
||
// Remember what the user left on, then pre-position a reused
|
||
// extension instance for the next open policy (no first-frame jump).
|
||
let preserve = flowCoordinator.preservesLifecycleOnDisappear
|
||
TypingInputConfiguration.persistLastSurface(
|
||
preserve ? .voice : state.surface
|
||
)
|
||
// Remember pinyin/English with the surface so "remember last" restores both.
|
||
if state.surface == .typing {
|
||
TypingInputConfiguration.persistLastTypingLanguage(typingSession.language)
|
||
}
|
||
if state.aiSession.isActive {
|
||
// Unified assistant conversations never survive a keyboard presentation.
|
||
aiKeyboardCoordinator.leave()
|
||
}
|
||
if !preserve {
|
||
prepareSurfaceForNextPresentation()
|
||
}
|
||
// Always finalize librime on hide, even on the typing surface: its
|
||
// LevelDB userdb holds `Rime/UserData/*.userdb/LOCK` in the App Group,
|
||
// and RunningBoard kills a suspended extension holding that lock
|
||
// (0xdead10cc). `viewWillAppear` re-prepares Rime (~30–50 ms).
|
||
deactivateTypingSurface()
|
||
if preserve {
|
||
return
|
||
}
|
||
ExtensionScreenWakeLock.releaseAll()
|
||
flowCoordinator.cancelPipelineUnlessAwaitingResult()
|
||
}
|
||
|
||
public override func viewWillAppear(_ animated: Bool) {
|
||
super.viewWillAppear(animated)
|
||
isKeyboardPresentationActive = true
|
||
OSGDiag.log(
|
||
"KVC.viewWillAppear begin surface=\(state.surface.rawValue) "
|
||
+ "fullAccess=\(hasFullAccess) \(OSGDiag.memoryTag())",
|
||
category: "boot"
|
||
)
|
||
recordMemory("KVC.viewWillAppear.begin")
|
||
setNeedsUpdateOfScreenEdgesDeferringSystemGestures()
|
||
configureDictationBehavior()
|
||
state.hasFullAccess = hasFullAccess
|
||
KeyboardSetupBridge.markExtensionAppearance(hasFullAccess: hasFullAccess)
|
||
// Refresh only Flow/config state; edit targets come from verified OSG insertions.
|
||
flowCoordinator.refreshSessionState()
|
||
flowCoordinator.startSessionMonitor()
|
||
configSync.syncOnboardingStateFromAppGroup()
|
||
configSync.refreshConfigFromAppGroup()
|
||
refreshMultipleReplyVariantsSetting()
|
||
clipboardCapture.refreshFlagsFromStore()
|
||
// Settings may have changed while the extension stayed alive.
|
||
applyPreferredSurfaceOnOpen()
|
||
// Re-warm Taptic after host app switches: SwiftUI `onAppear` often
|
||
// skips when the extension process is reused, leaving generators cold.
|
||
KeyboardHapticFeedback.prepare()
|
||
recordMemory("KVC.viewWillAppear.afterHaptics")
|
||
if state.surface == .typing {
|
||
OSGDiag.log("KVC.viewWillAppear enterTypingMode", category: "boot")
|
||
activateTypingPresentationIfNeeded()
|
||
recordMemory("KVC.viewWillAppear.afterTypingEnter")
|
||
}
|
||
clipboardCapture.keyboardDidAppear()
|
||
recordMemory("KVC.viewWillAppear.afterClipboard")
|
||
// Re-presented keyboards reuse the same process, so resume pressure
|
||
// polling here. No-ops while the first-launch boot burst is still
|
||
// running, so the startup samples are never cut short.
|
||
KeyboardExtensionMemoryTelemetry.startSustainedSamplingIfIdle()
|
||
recordMemory("KVC.viewWillAppear.done")
|
||
OSGDiag.log(
|
||
"KVC.viewWillAppear done surface=\(state.surface.rawValue) \(OSGDiag.memoryTag())",
|
||
category: "boot"
|
||
)
|
||
}
|
||
|
||
public override func viewIsAppearing(_ animated: Bool) {
|
||
super.viewIsAppearing(animated)
|
||
// One constant, every time: the previous "prime at target − system
|
||
// encapsulated height" trick read the pre-presentation full-screen
|
||
// height (874 pt on an iPhone), clamped to 0, and could not win against
|
||
// the system's required constraint anyway.
|
||
lockPresentedKeyboardHeight()
|
||
OSGDiag.log(
|
||
"KVC.viewIsAppearing phase=\(heightPhaseLog) "
|
||
+ "height=\(keyboardHeightConstraint?.constant ?? -1) "
|
||
+ "\(OSGDiag.memoryTag())",
|
||
category: "boot"
|
||
)
|
||
logHeightConstraints(tag: "viewIsAppearing")
|
||
}
|
||
|
||
public override func viewDidAppear(_ animated: Bool) {
|
||
super.viewDidAppear(animated)
|
||
OSGDiag.log(
|
||
"KVC.viewDidAppear begin surface=\(state.surface.rawValue) \(OSGDiag.memoryTag())",
|
||
category: "boot"
|
||
)
|
||
recordMemory("KVC.viewDidAppear.begin")
|
||
disableSystemGestureDelays()
|
||
heightPhase = .presented
|
||
lockPresentedKeyboardHeight()
|
||
refreshReturnKeyRole()
|
||
scheduleTypingDocumentSynchronization()
|
||
// Presentation math is locked before arming the PiP handoff.
|
||
DispatchQueue.main.async { [weak self] in
|
||
guard let self, self.heightPhase == .presented else { return }
|
||
self.flowCoordinator.ensurePiPReadyOnKeyboardOpen()
|
||
}
|
||
#if DEBUG
|
||
HostBundleProbe.recordPresentationAndPing()
|
||
WhatsNewDemoDriver.resetForNewPresentation()
|
||
WhatsNewDemoDriver.startIfNeeded(
|
||
state: state,
|
||
host: WhatsNewDemoDriver.HostHooks(
|
||
insertText: { [weak self] text in
|
||
self?.insertTextIntoDocument(text, source: .debugDemo)
|
||
},
|
||
deleteBackward: { [weak self] in
|
||
self?.deleteBackwardFromDocument()
|
||
},
|
||
contextBeforeInput: { [weak self] in
|
||
self?.textDocumentProxy.documentContextBeforeInput
|
||
},
|
||
performReturn: { [weak self] in
|
||
self?.performDocumentReturn()
|
||
}
|
||
)
|
||
)
|
||
#endif
|
||
OSGDiag.log(
|
||
"KVC.viewDidAppear done height=\(targetKeyboardHeight) \(OSGDiag.memoryTag())",
|
||
category: "boot"
|
||
)
|
||
recordMemory("KVC.viewDidAppear.done")
|
||
}
|
||
|
||
public override func textDidChange(_ textInput: UITextInput?) {
|
||
super.textDidChange(textInput)
|
||
refreshReturnKeyRole()
|
||
synchronizeTypingDocumentContext()
|
||
textInserter?.refreshEditingAvailability()
|
||
lastInputEditCoordinator?.refreshContext()
|
||
}
|
||
|
||
public override func selectionDidChange(_ textInput: UITextInput?) {
|
||
super.selectionDidChange(textInput)
|
||
refreshReturnKeyRole()
|
||
synchronizeTypingDocumentContext(caretMoved: true)
|
||
textInserter?.refreshEditingAvailability()
|
||
lastInputEditCoordinator?.refreshContext()
|
||
}
|
||
|
||
public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||
super.traitCollectionDidChange(previousTraitCollection)
|
||
guard previousTraitCollection?.horizontalSizeClass != traitCollection.horizontalSizeClass else {
|
||
return
|
||
}
|
||
refreshLayoutMode()
|
||
}
|
||
|
||
public override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge {
|
||
[.left, .right]
|
||
}
|
||
|
||
public override var childForScreenEdgesDeferringSystemGestures: UIViewController? {
|
||
hosting
|
||
}
|
||
|
||
public override func didReceiveMemoryWarning() {
|
||
super.didReceiveMemoryWarning()
|
||
recordMemory("KVC.didReceiveMemoryWarning")
|
||
OSGDiag.log(
|
||
"KVC.didReceiveMemoryWarning surface=\(state.surface.rawValue) \(OSGDiag.memoryTag())",
|
||
category: "boot"
|
||
)
|
||
// The system signal is not an advance warning — jetsam may be moments
|
||
// away — so this path sheds even mid-composition.
|
||
_ = shedHeavyResources(level: .critical, reason: "systemWarning", force: true)
|
||
}
|
||
|
||
/// Whether the user is actively composing right now. A non-empty preedit is
|
||
/// an unfinished Pinyin syllable, and a keystroke within the last window
|
||
/// means a burst is in progress even between syllables.
|
||
private var isMidTypingBurst: Bool {
|
||
if let session = typingSessionStorage, !session.composition.preedit.isEmpty {
|
||
return true
|
||
}
|
||
guard lastTypingActivityUptime >= 0 else { return false }
|
||
return ProcessInfo.processInfo.systemUptime - lastTypingActivityUptime
|
||
< Self.typingBurstWindow
|
||
}
|
||
|
||
/// How long after a keystroke the keyboard still counts as "being typed on".
|
||
private static let typingBurstWindow: TimeInterval = 1.5
|
||
|
||
/// Releases the keyboard's heavy caches (Rime engine, English lexicon,
|
||
/// in-flight pipeline work).
|
||
///
|
||
/// Two callers: the system memory warning above, and — crucially, *earlier*
|
||
/// — `KeyboardExtensionMemoryTelemetry.reliefHandler`. Jetsam kills
|
||
/// keyboards at the ~60 MiB boundary without reliably delivering a warning
|
||
/// first, so waiting for the system signal means the keyboard simply
|
||
/// vanishes mid-sentence. Our own 40 / 48 MiB thresholds fire while there
|
||
/// is still headroom to recover.
|
||
/// Returns whether relief actually happened, so a declined attempt keeps the
|
||
/// level armed for the next poll instead of counting as a completed shed.
|
||
@discardableResult
|
||
private func shedHeavyResources(
|
||
level: KeyboardExtensionMemoryBudget.Level,
|
||
reason: String,
|
||
force: Bool = false
|
||
) -> Bool {
|
||
// Relief can be requested from the boot sampler before `installServices`
|
||
// has run; nothing below is safe to touch until the view is loaded.
|
||
guard isViewLoaded, flowCoordinator != nil else { return false }
|
||
// Every step below records a memory milestone, and a milestone can ask
|
||
// for relief. Telemetry gates that loop on its side; this guard also
|
||
// covers `didReceiveMemoryWarning`, which calls in directly without
|
||
// going through the sampler. Reporting `false` keeps the level armed so
|
||
// the outer shed's outcome decides the episode.
|
||
guard !isSheddingHeavyResources else { return false }
|
||
isSheddingHeavyResources = true
|
||
defer { isSheddingHeavyResources = false }
|
||
#if DEBUG
|
||
MemoryDeviceProbe.log("shed.\(level.rawValue).reason=\(reason).surface=\(state.surface.rawValue).midBurst=\(isMidTypingBurst ? 1 : 0)")
|
||
#endif
|
||
OSGDiag.log(
|
||
"KVC.shed level=\(level.rawValue) reason=\(reason) "
|
||
+ "surface=\(state.surface.rawValue) \(OSGDiag.memoryTag())",
|
||
category: "memory"
|
||
)
|
||
flowCoordinator.cancelPipelineUnlessAwaitingResult()
|
||
guard level == .critical else {
|
||
// `.high`: release only what the user is not looking at. Yanking an
|
||
// active typing surface at 40 MiB would be more disruptive than the
|
||
// risk it avoids.
|
||
if state.surface != .typing {
|
||
deactivateTypingSurface()
|
||
}
|
||
recordMemory("KVC.shed.soft", details: "reason=\(reason)")
|
||
return true
|
||
}
|
||
guard state.surface == .typing else {
|
||
deactivateTypingSurface()
|
||
recordMemory("KVC.shed.hard", details: "reason=\(reason)")
|
||
return true
|
||
}
|
||
// Swapping the surface out from under someone mid-syllable loses the
|
||
// composition and drops them into voice mode with no explanation. A
|
||
// jetsam kill costs the same composition but reads as "the keyboard
|
||
// crashed", which is the lesser of the two for the user's trust in the
|
||
// keyboard — and the poll comes back in a second to try again.
|
||
guard force || !isMidTypingBurst else {
|
||
recordMemory("KVC.shed.deferred", details: "reason=\(reason) cause=typing")
|
||
return false
|
||
}
|
||
applySurface(.voice)
|
||
recordMemory("KVC.shed.hard", details: "reason=\(reason)")
|
||
return true
|
||
}
|
||
|
||
public override func viewDidLayoutSubviews() {
|
||
super.viewDidLayoutSubviews()
|
||
// Rotation does not change `horizontalSizeClass` on iPad (both
|
||
// orientations are regular), so this is the only callback that sees a
|
||
// portrait→landscape resize. `refreshLayoutMode` no-ops unless the
|
||
// layout bucket actually changed, so this cannot loop.
|
||
refreshLayoutMode()
|
||
enforcePresentedKeyboardHeightIfNeeded()
|
||
logLayoutSnapshotIfChanged()
|
||
}
|
||
|
||
// MARK: - Services
|
||
|
||
private func installServices() {
|
||
editHintScheduler = EditHintScheduler(state: state)
|
||
textInserter = KeyboardTextInserter(
|
||
state: state,
|
||
insertText: { [weak self] text, source in
|
||
self?.insertTextIntoDocument(text, source: source)
|
||
},
|
||
deleteBackward: { [weak self] in self?.deleteBackwardFromDocument() },
|
||
contextBeforeInput: { [weak self] in
|
||
guard let self, self.textDocumentProxy.isSecureTextEntry == false else { return nil }
|
||
return self.textDocumentProxy.documentContextBeforeInput
|
||
},
|
||
fieldContextProvider: { [weak self] in self?.captureFieldContext() },
|
||
selectedText: { [weak self] in
|
||
guard let self, self.textDocumentProxy.isSecureTextEntry == false else { return nil }
|
||
return self.textDocumentProxy.selectedText
|
||
},
|
||
scheduleAutoClearError: { [weak self] in self?.scheduleAutoClearError() },
|
||
editHintScheduler: editHintScheduler
|
||
)
|
||
|
||
configSync = KeyboardConfigSync(
|
||
state: state,
|
||
persistor: persistor,
|
||
onFlowSessionChanged: { [weak self] in
|
||
self?.flowCoordinator.refreshSessionState()
|
||
},
|
||
onConfigChanged: { [weak self] in
|
||
// Only an already-live typing session can be showing the setup
|
||
// error; never force-create one just to retry.
|
||
self?.typingSessionStorage?.reloadPersonalDictionaryTerms()
|
||
self?.typingSessionStorage?.retryPrepareAfterResourceDeployment()
|
||
self?.refreshMultipleReplyVariantsSetting()
|
||
}
|
||
)
|
||
|
||
flowCoordinator = KeyboardFlowCoordinator(
|
||
state: state,
|
||
textInserter: textInserter,
|
||
hasFullAccess: { [weak self] in self?.hasFullAccess ?? false },
|
||
wakeLockView: { [weak self] in self?.view },
|
||
openHostApp: { [weak self] path in self?.openHostApp(path: path) },
|
||
detectAndStoreAppContext: { [weak self] in self?.detectAndStoreAppContext() },
|
||
fieldContextProvider: { [weak self] in self?.captureFieldContext() },
|
||
scheduleAutoClearError: { [weak self] in self?.scheduleAutoClearError() },
|
||
refreshConfigFromAppGroup: { [weak self] in self?.configSync.refreshConfigFromAppGroup() }
|
||
)
|
||
lastInputEditCoordinator = LastInputEditCoordinator(
|
||
state: state,
|
||
textInserter: textInserter,
|
||
beginFlow: { [weak self] reference in
|
||
self?.flowCoordinator.beginEditRecording(reference: reference)
|
||
?? .rejected(.hostUnavailable)
|
||
},
|
||
stopFlow: { [weak self] in self?.flowCoordinator.stopEditRecording() },
|
||
abortFlow: { [weak self] in self?.flowCoordinator.abortEditRecording() },
|
||
acknowledge: { [weak self] outcome in
|
||
self?.flowCoordinator.acknowledgeEditResult(outcome)
|
||
},
|
||
editHintScheduler: editHintScheduler
|
||
)
|
||
flowCoordinator.onEditHostRecordingConfirmed = { [weak self] in
|
||
self?.lastInputEditCoordinator.hostRecordingConfirmed()
|
||
}
|
||
flowCoordinator.onEditResult = { [weak self] result in
|
||
self?.lastInputEditCoordinator.receive(result: result)
|
||
}
|
||
flowCoordinator.onEditFailure = { [weak self] message in
|
||
self?.lastInputEditCoordinator.fail(message)
|
||
}
|
||
aiKeyboardCoordinator = AIKeyboardCoordinator(
|
||
state: state,
|
||
flow: flowCoordinator,
|
||
insertAnswer: { [weak self] answer in
|
||
self?.textInserter.insertAIAnswer(answer) ?? false
|
||
},
|
||
performReturn: { [weak self] in
|
||
self?.performDocumentReturn()
|
||
},
|
||
captureInsertionFingerprint: { [weak self] in
|
||
self?.captureFieldContext().deliveryFingerprint
|
||
},
|
||
openWebURL: { [weak self] url in
|
||
self?.openWebURL(url)
|
||
},
|
||
callPhone: { [weak self] phoneNumber in
|
||
self?.callPhone(phoneNumber)
|
||
},
|
||
createContact: { [weak self] phoneNumber in
|
||
self?.createContact(phoneNumber)
|
||
}
|
||
)
|
||
clipboardCapture = ClipboardCaptureCoordinator(state: state)
|
||
clipboardCapture.configure(
|
||
isSecure: { [weak self] in
|
||
self?.textDocumentProxy.isSecureTextEntry ?? true
|
||
},
|
||
hasFullAccess: { [weak self] in
|
||
self?.hasFullAccess ?? false
|
||
}
|
||
)
|
||
flowCoordinator.onAIUtterancePrepared = { [weak self] utteranceID in
|
||
self?.aiKeyboardCoordinator.utterancePrepared(utteranceID)
|
||
}
|
||
flowCoordinator.onAIRecordingStarted = { [weak self] utteranceID in
|
||
self?.aiKeyboardCoordinator.recordingStarted(utteranceID)
|
||
}
|
||
flowCoordinator.onAIRecognitionStarted = { [weak self] utteranceID in
|
||
self?.aiKeyboardCoordinator.recognitionStarted(utteranceID)
|
||
}
|
||
flowCoordinator.onAIGeneratingStarted = { [weak self] utteranceID in
|
||
self?.aiKeyboardCoordinator.generatingStarted(utteranceID)
|
||
}
|
||
flowCoordinator.onAITranscript = { [weak self] transcript, utteranceID, status in
|
||
self?.aiKeyboardCoordinator.receiveTranscript(
|
||
transcript,
|
||
utteranceID: utteranceID,
|
||
status: status
|
||
)
|
||
}
|
||
flowCoordinator.onAIStreamingAnswer = { [weak self] draft, utteranceID in
|
||
self?.aiKeyboardCoordinator.receivePartialAnswer(
|
||
draft,
|
||
utteranceID: utteranceID
|
||
)
|
||
}
|
||
flowCoordinator.onAIResult = { [weak self] result in
|
||
self?.aiKeyboardCoordinator.receive(result: result)
|
||
}
|
||
flowCoordinator.onAIFailure = { [weak self] message, utteranceID in
|
||
self?.aiKeyboardCoordinator.fail(message, utteranceID: utteranceID)
|
||
}
|
||
flowCoordinator.onAICancelled = { [weak self] in
|
||
self?.state.aiSession.cancelCurrentWork()
|
||
}
|
||
_ = textInserter.recoverPendingEditTransactionIfNeeded()
|
||
}
|
||
|
||
// MARK: - Wiring
|
||
|
||
private func installStateActions() {
|
||
state.beginRecording = { [weak self] in self?.flowCoordinator.pressBegan() }
|
||
state.endRecording = { [weak self] in self?.flowCoordinator.pressEnded() }
|
||
state.tapMic = { [weak self] in self?.flowCoordinator.toggleRecording() }
|
||
state.setMicTouchActive = { [weak self] active in
|
||
self?.flowCoordinator.setMicTouchActive(active)
|
||
}
|
||
state.cancelVoiceInput = { [weak self] in
|
||
self?.flowCoordinator.cancelCurrentDictation()
|
||
}
|
||
state.beginEditLastInput = { [weak self] in
|
||
self?.lastInputEditCoordinator.begin()
|
||
}
|
||
state.stopEditListening = { [weak self] in
|
||
self?.lastInputEditCoordinator.stopListening()
|
||
}
|
||
state.confirmEditResult = { [weak self] in
|
||
self?.lastInputEditCoordinator.confirm()
|
||
}
|
||
state.closeEditMode = { [weak self] in
|
||
self?.lastInputEditCoordinator.close()
|
||
}
|
||
state.tapAIMic = { [weak self] in
|
||
self?.aiKeyboardCoordinator.toggleMicrophone()
|
||
}
|
||
state.cancelAIInput = { [weak self] in
|
||
self?.aiKeyboardCoordinator.cancel()
|
||
}
|
||
state.confirmPendingAIAnswer = { [weak self] in
|
||
self?.aiKeyboardCoordinator.confirmPendingAnswer()
|
||
}
|
||
state.selectAIReplyVariant = { [weak self] id in
|
||
self?.aiKeyboardCoordinator.selectReplyVariant(id: id)
|
||
}
|
||
state.discardPendingAIAnswer = { [weak self] in
|
||
self?.aiKeyboardCoordinator.discardPendingAnswer()
|
||
}
|
||
state.performAssistantFieldAction = { [weak self] in
|
||
self?.aiKeyboardCoordinator.performCurrentFieldAction()
|
||
}
|
||
state.submitAIHint = { [weak self] card in
|
||
self?.aiKeyboardCoordinator.submitHintCard(card)
|
||
}
|
||
state.submitAIClipboardSkill = { [weak self] skill, replyScene in
|
||
self?.aiKeyboardCoordinator.submitClipboardSkill(skill, replyScene: replyScene)
|
||
}
|
||
state.runClipboardExportSkill = { [weak self] skillID, titles in
|
||
AppGroupStore().setPendingShortcutRun(skillID: skillID, titles: titles)
|
||
AIAgentShortcutRun.trace("keyboard.openHost osgkeyboard://skill/run")
|
||
self?.openSkillShortcutRun()
|
||
}
|
||
state.openSettings = { [weak self] in self?.openHostApp() }
|
||
state.openInputMethodSetup = { [weak self] in self?.openHostApp(path: "deployrime") }
|
||
state.openClipboardSettings = { [weak self] in
|
||
SettingsDeepLink.setPending(.clipboard)
|
||
self?.openHostApp(path: "settings/clipboard")
|
||
}
|
||
state.tryAutoReplyFromGuide = { [weak self] in
|
||
self?.clipboardCapture.confirmAutoReplyGuide()
|
||
}
|
||
state.openClipboardPanel = { [weak self] in
|
||
self?.clipboardCapture.openPanelFromTopButton()
|
||
}
|
||
state.dismissClipboardOverlay = { [weak self] in
|
||
self?.clipboardCapture.dismissOverlay()
|
||
}
|
||
state.insertClipboardText = { [weak self] text in
|
||
guard let self else { return }
|
||
self.clipboardCapture.insertText(text) { insertText in
|
||
// Via the inserter so the undo key can roll a paste back.
|
||
self.textInserter.insertPasteboardText(insertText)
|
||
}
|
||
}
|
||
state.dismissClipboardSuggestion = { [weak self] in
|
||
self?.clipboardCapture.dismissSuggestion()
|
||
}
|
||
state.clearClipboardHistory = { [weak self] in
|
||
self?.clipboardCapture.clearHistory()
|
||
ClipboardReplyFeedbackStore.shared.clear()
|
||
}
|
||
state.deleteClipboardHistoryEntry = { [weak self] id in
|
||
self?.clipboardCapture.deleteEntry(id: id)
|
||
}
|
||
state.noteUserDidInputText = { [weak self] in
|
||
self?.clipboardCapture.noteUserDidInputText()
|
||
}
|
||
// The globe UIButton registers this controller's standard
|
||
// `handleInputModeList(from:with:)` action for all touch events.
|
||
state.inputModeController = self
|
||
state.startFlowSession = { [weak self] in self?.flowCoordinator.beginFlowStart() }
|
||
state.setMode = { [weak self] m in self?.configSync.persistMode(m) }
|
||
state.setLocale = { [weak self] l in self?.configSync.persistLocale(l) }
|
||
state.setEngineMode = { [weak self] m in self?.configSync.persistEngineMode(m) }
|
||
state.setTranslationTargetLocaleId = { [weak self] id in
|
||
self?.configSync.persistTranslationTargetLocaleId(id)
|
||
self?.aiKeyboardCoordinator.resetConversationForConfigurationChange()
|
||
}
|
||
state.insertNewline = { [weak self] in
|
||
self?.insertTextIntoDocument("\n", source: .manualKeyboard)
|
||
}
|
||
state.insertSpace = { [weak self] in
|
||
self?.insertTextIntoDocument(" ", source: .manualKeyboard)
|
||
}
|
||
state.deleteBackward = { [weak self] in self?.deleteBackwardFromDocument() }
|
||
state.undoLastInsertion = { [weak self] in self?.textInserter.undoLastInsertion() }
|
||
state.redoLastInsertion = { [weak self] in self?.textInserter.redoLastInsertion() }
|
||
state.copySelection = { [weak self] in self?.textInserter.copySelection() }
|
||
state.cutSelection = { [weak self] in self?.textInserter.cutSelection() }
|
||
state.setSurface = { [weak self] surface in
|
||
self?.applySurface(surface)
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
.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()
|
||
}
|
||
.store(in: &cancellables)
|
||
}
|
||
|
||
/// This App Group property is supplied by the parallel settings module.
|
||
/// Reading it here keeps the shared state independent from persistence.
|
||
private func refreshMultipleReplyVariantsSetting() {
|
||
state.multipleReplyVariantsEnabled = AppGroupStore().multipleReplyVariantsEnabled
|
||
}
|
||
|
||
private func applySurface(_ requestedSurface: State.Surface) {
|
||
// `.ai` is retained only to decode preferences written by older builds.
|
||
// The product now has one unified assistant surface.
|
||
let surface: State.Surface = requestedSurface == .ai ? .voice : requestedSurface
|
||
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 {
|
||
if surface == .typing {
|
||
activateTypingPresentationIfNeeded()
|
||
}
|
||
refreshKeyboardHeight()
|
||
return
|
||
}
|
||
OSGDiag.log(
|
||
"applySurface \(state.surface.rawValue) → \(surface.rawValue) \(OSGDiag.memoryTag())",
|
||
category: "boot"
|
||
)
|
||
state.surface = surface
|
||
#if DEBUG
|
||
MemoryDeviceProbe.log("applySurface.\(surface.rawValue)")
|
||
#endif
|
||
recordMemory("KVC.applySurface", details: "requested=\(surface.rawValue)")
|
||
if surface == .typing {
|
||
activateTypingPresentationIfNeeded()
|
||
} else {
|
||
deactivateTypingSurface()
|
||
}
|
||
refreshKeyboardHeight()
|
||
}
|
||
|
||
private func activateTypingPresentationIfNeeded() {
|
||
guard isKeyboardPresentationActive,
|
||
state.surface == .typing,
|
||
typingDocumentPresentationID == nil else {
|
||
return
|
||
}
|
||
if !isTypingSurfaceActive {
|
||
typingSession.enterTypingMode()
|
||
isTypingSurfaceActive = true
|
||
}
|
||
typingDocumentPresentationID = typingSession.beginDocumentPresentation()
|
||
refreshEnglishSupplementaryLexicon()
|
||
if heightPhase == .presented {
|
||
refreshReturnKeyRole()
|
||
scheduleTypingDocumentSynchronization()
|
||
}
|
||
}
|
||
|
||
private func deactivateTypingSurface() {
|
||
guard isTypingSurfaceActive || typingDocumentPresentationID != nil else { return }
|
||
typingDocumentPresentationID = nil
|
||
typingSessionStorage?.leaveTypingMode()
|
||
isTypingSurfaceActive = false
|
||
}
|
||
|
||
private func scheduleTypingDocumentSynchronization() {
|
||
guard let presentationID = typingDocumentPresentationID else { return }
|
||
DispatchQueue.main.async { [weak self] in
|
||
guard let self,
|
||
self.isKeyboardPresentationActive,
|
||
self.state.surface == .typing,
|
||
self.typingDocumentPresentationID == presentationID else {
|
||
return
|
||
}
|
||
self.synchronizeTypingDocumentContext(
|
||
caretMoved: true,
|
||
presentationID: presentationID
|
||
)
|
||
}
|
||
}
|
||
|
||
private func synchronizeTypingDocumentContext(
|
||
caretMoved: Bool = false,
|
||
presentationID: UInt64? = nil
|
||
) {
|
||
guard let activeID = presentationID ?? typingDocumentPresentationID,
|
||
typingDocumentPresentationID == activeID else {
|
||
return
|
||
}
|
||
typingSessionStorage?.synchronizeEnglishDocumentContext(
|
||
caretMoved: caretMoved,
|
||
presentationID: activeID
|
||
)
|
||
}
|
||
|
||
private func applyPreferredSurfaceOnOpen() {
|
||
let preference = TypingInputConfiguration.preferredOpenPreference()
|
||
let resolved = KeyboardOpenSurfacePolicy.resolve(
|
||
locksTypingSurface: state.locksTypingSurface,
|
||
preferred: preference.surface
|
||
)
|
||
OSGDiag.log(
|
||
"applyPreferredSurfaceOnOpen preferred=\(preference.surface.rawValue) "
|
||
+ "resolved=\(resolved.rawValue) "
|
||
+ "lang=\(preference.typingLanguage?.rawValue ?? "-") "
|
||
+ "locksTyping=\(state.locksTypingSurface ? 1 : 0)",
|
||
category: "boot"
|
||
)
|
||
if resolved == .typing, let language = preference.typingLanguage {
|
||
_ = typingSession.setLanguage(language)
|
||
}
|
||
applySurface(resolved)
|
||
}
|
||
|
||
/// 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 preference = TypingInputConfiguration.preferredOpenPreference()
|
||
if state.surface != preference.surface {
|
||
state.surface = preference.surface
|
||
}
|
||
if preference.surface == .typing, let language = preference.typingLanguage {
|
||
_ = typingSession.setLanguage(language)
|
||
}
|
||
}
|
||
|
||
private func refreshKeyboardHeight() {
|
||
// Avoid synchronous layout here: this is also called during
|
||
// `viewDidLoad`, where re-entrant layout can observe partially
|
||
// initialized controller dependencies.
|
||
lockPresentedKeyboardHeight()
|
||
}
|
||
|
||
private func refreshLayoutMode() {
|
||
let usesIPadMetrics = isIPadLayout
|
||
let width = currentLayoutWidth
|
||
// `state.layoutWidth` tracks the width that last changed the layout
|
||
// bucket, not every intermediate width: republishing on each frame of
|
||
// a Stage Manager drag would rebuild the SwiftUI grid continuously.
|
||
let bucketChanged = KeyboardChromeLayout.usesWideIPadMetrics(
|
||
isIPad: usesIPadMetrics,
|
||
width: width
|
||
) != KeyboardChromeLayout.usesWideIPadMetrics(
|
||
isIPad: state.usesIPadLayoutMetrics,
|
||
width: state.layoutWidth
|
||
)
|
||
guard state.usesIPadLayoutMetrics != usesIPadMetrics || bucketChanged else { return }
|
||
state.usesIPadLayoutMetrics = usesIPadMetrics
|
||
state.layoutWidth = width
|
||
refreshKeyboardHeight()
|
||
}
|
||
|
||
private var heightPhaseLog: String {
|
||
switch heightPhase {
|
||
case .idle: return "idle"
|
||
case .presented: return "presented"
|
||
}
|
||
}
|
||
|
||
private func lockPresentedKeyboardHeight() {
|
||
keyboardHeightConstraint?.constant = targetKeyboardHeight
|
||
view.setNeedsLayout()
|
||
}
|
||
|
||
/// The constraint must stay at `target` once presented, whatever the system
|
||
/// did to the input view's height during the transition.
|
||
private func enforcePresentedKeyboardHeightIfNeeded() {
|
||
guard heightPhase == .presented else { return }
|
||
let target = targetKeyboardHeight
|
||
guard let constraint = keyboardHeightConstraint else { return }
|
||
let was = constraint.constant
|
||
guard abs(was - target) > 0.5 else { return }
|
||
constraint.constant = target
|
||
OSGDiag.log(
|
||
"KVC.heightEnforce constraint \(Int(was))→\(Int(target))",
|
||
category: "boot"
|
||
)
|
||
}
|
||
|
||
/// Keeps field-action UI current even when a host app does not immediately
|
||
/// echo this keyboard's own document mutation through `textDidChange`.
|
||
private func insertTextIntoDocument(
|
||
_ text: String,
|
||
source _: KeyboardTextInsertionSource
|
||
) {
|
||
guard !text.isEmpty else { return }
|
||
lastTypingActivityUptime = ProcessInfo.processInfo.systemUptime
|
||
textDocumentProxy.insertText(text)
|
||
// Usage analytics stay in the host app. Recording SQLite from this
|
||
// process held WAL locks across keyboard hide and triggered
|
||
// RunningBoard `0xdead10cc` kills.
|
||
// A non-empty insertion makes content actions available immediately.
|
||
// The next host callback remains the authoritative correction.
|
||
refreshAssistantFieldAction(hasTextOverride: true)
|
||
}
|
||
|
||
private func deleteBackwardFromDocument() {
|
||
lastTypingActivityUptime = ProcessInfo.processInfo.systemUptime
|
||
textDocumentProxy.deleteBackward()
|
||
refreshAssistantFieldAction()
|
||
scheduleAssistantFieldActionRefresh()
|
||
}
|
||
|
||
private func performDocumentReturn() {
|
||
insertTextIntoDocument("\n", source: .assistantAction)
|
||
scheduleAssistantFieldActionRefresh()
|
||
}
|
||
|
||
private func scheduleAssistantFieldActionRefresh() {
|
||
assistantFieldActionRefreshTask?.cancel()
|
||
assistantFieldActionRefreshTask = Task { @MainActor [weak self] in
|
||
try? await Task.sleep(for: .milliseconds(100))
|
||
guard !Task.isCancelled, let self else { return }
|
||
self.refreshAssistantFieldAction()
|
||
self.assistantFieldActionRefreshTask = nil
|
||
}
|
||
}
|
||
|
||
private func refreshAssistantFieldAction(hasTextOverride: Bool? = nil) {
|
||
let role = returnKeyRole(for: textDocumentProxy.returnKeyType ?? .default)
|
||
state.returnKeyRole = role
|
||
state.assistantActionAvailable = role.assistantActionAvailable(
|
||
hasText: hasTextOverride ?? textDocumentProxy.hasText
|
||
)
|
||
}
|
||
|
||
private func refreshReturnKeyRole() {
|
||
assistantFieldActionRefreshTask?.cancel()
|
||
assistantFieldActionRefreshTask = nil
|
||
refreshAssistantFieldAction()
|
||
// An unavailable trait is not proof that the new host field is safe.
|
||
// Fail closed until UIKit supplies an explicit non-secure value.
|
||
let isSecure = textDocumentProxy.isSecureTextEntry ?? true
|
||
state.setSecureTextEntry(isSecure)
|
||
clipboardCapture?.secureEntryDidChange(isSecure: isSecure)
|
||
// Secure fields must not run English autocomplete / autocorrect / learning.
|
||
typingSessionStorage?.suggestionsEnabled = !isSecure
|
||
if !isSecure {
|
||
typingSessionStorage?.syncAutocapitalization()
|
||
}
|
||
}
|
||
|
||
private func installTypingContextProviders() {
|
||
typingSession.precedingTextProvider = { [weak self] in
|
||
guard let self, self.textDocumentProxy.isSecureTextEntry == false else { return nil }
|
||
return self.textDocumentProxy.documentContextBeforeInput
|
||
}
|
||
typingSession.followingTextProvider = { [weak self] in
|
||
guard let self, self.textDocumentProxy.isSecureTextEntry == false else { return nil }
|
||
return self.textDocumentProxy.documentContextAfterInput
|
||
}
|
||
typingSession.autocapitalizationModeProvider = { [weak self] in
|
||
guard let self, self.textDocumentProxy.isSecureTextEntry == false else { return .none }
|
||
return Self.typingAutocapitalizationMode(
|
||
for: self.textDocumentProxy.autocapitalizationType ?? .sentences
|
||
)
|
||
}
|
||
}
|
||
|
||
/// Contacts and user text replacements, without a Contacts permission.
|
||
///
|
||
/// `requestSupplementaryLexicon` replies on `com.apple.TextInput.lexicon-request`,
|
||
/// not the main actor. Touching `TypingSessionController` there traps in Swift 6
|
||
/// (`_dispatch_assert_queue_fail`) and the extension is killed on appear.
|
||
private func refreshEnglishSupplementaryLexicon() {
|
||
requestSupplementaryLexicon { @Sendable lexicon in
|
||
Task { @MainActor [weak self] in
|
||
self?.applySupplementaryLexicon(lexicon)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func applySupplementaryLexicon(_ lexicon: UILexicon) {
|
||
typingSessionStorage?.supplementaryWords = lexicon.entries.compactMap { entry -> String? in
|
||
let text = entry.documentText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !text.isEmpty else { return nil }
|
||
let parts = text.split { $0.isWhitespace || $0 == "," }
|
||
guard parts.count == 1 else { return nil }
|
||
let token = String(parts[0])
|
||
guard token.allSatisfy({ $0.isLetter || $0 == "'" || $0 == "’" || $0 == "-" }) else {
|
||
return nil
|
||
}
|
||
return token
|
||
}
|
||
}
|
||
|
||
private static func typingAutocapitalizationMode(
|
||
for type: UITextAutocapitalizationType
|
||
) -> TypingAutocapitalizationMode {
|
||
switch type {
|
||
case .none:
|
||
return .none
|
||
case .words:
|
||
return .words
|
||
case .sentences:
|
||
return .sentences
|
||
case .allCharacters:
|
||
return .allCharacters
|
||
@unknown default:
|
||
return .sentences
|
||
}
|
||
}
|
||
|
||
private func returnKeyRole(for returnKeyType: UIReturnKeyType) -> State.ReturnKeyRole {
|
||
switch returnKeyType {
|
||
case .default:
|
||
return .newline
|
||
case .go:
|
||
return .go
|
||
case .google:
|
||
return .google
|
||
case .join:
|
||
return .join
|
||
case .next:
|
||
return .next
|
||
case .route:
|
||
return .route
|
||
case .search:
|
||
return .search
|
||
case .send:
|
||
return .send
|
||
case .yahoo:
|
||
return .yahoo
|
||
case .done:
|
||
return .done
|
||
case .emergencyCall:
|
||
return .emergencyCall
|
||
case .continue:
|
||
return .continue
|
||
@unknown default:
|
||
return .newline
|
||
}
|
||
}
|
||
|
||
// MARK: - System keyboard chrome
|
||
|
||
private func configureDictationBehavior() {
|
||
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)
|
||
}
|
||
|
||
private func disableGestureDelays(in targetView: UIView) {
|
||
targetView.gestureRecognizers?.forEach { recognizer in
|
||
recognizer.delaysTouchesBegan = false
|
||
recognizer.delaysTouchesEnded = false
|
||
recognizer.cancelsTouchesInView = false
|
||
if recognizer is UIScreenEdgePanGestureRecognizer {
|
||
recognizer.isEnabled = false
|
||
}
|
||
}
|
||
targetView.subviews.forEach(disableGestureDelays)
|
||
}
|
||
|
||
// MARK: - Layout
|
||
|
||
private func installKeyboardHeight() {
|
||
let constraint = view.heightAnchor.constraint(equalToConstant: targetKeyboardHeight)
|
||
constraint.priority = UILayoutPriority(999)
|
||
constraint.isActive = true
|
||
keyboardHeightConstraint = constraint
|
||
}
|
||
|
||
/// Every height constraint the system and we put on the input view. The
|
||
/// system's own constant walks from the full screen height down to the
|
||
/// keyboard slot during the slide-in, so this is what to check whenever the
|
||
/// surface appears mis-sized or off-slot.
|
||
private func logHeightConstraints(tag: String) {
|
||
let heights = view.constraints.filter { constraint in
|
||
constraint.firstItem as? UIView === view && constraint.firstAttribute == .height
|
||
}
|
||
let described = heights.map { constraint in
|
||
let name = constraint === keyboardHeightConstraint
|
||
? "ours"
|
||
: (constraint.identifier ?? "system")
|
||
return "\(name)=\(Int(constraint.constant))@\(Int(constraint.priority.rawValue))"
|
||
+ (constraint.isActive ? "" : "(inactive)")
|
||
}
|
||
OSGDiag.log(
|
||
"KVC.heightConstraints[\(tag)] \(described.joined(separator: " "))",
|
||
category: "boot"
|
||
)
|
||
}
|
||
|
||
private func logLayoutSnapshotIfChanged() {
|
||
let snapshot = "phase=\(heightPhaseLog) "
|
||
+ "view=\(Int(view.bounds.height)) "
|
||
+ "host=\(Int(hosting?.view.bounds.height ?? -1)) "
|
||
+ "constraint=\(Int(keyboardHeightConstraint?.constant ?? -1)) "
|
||
+ "target=\(Int(targetKeyboardHeight))"
|
||
guard snapshot != lastLoggedLayoutSnapshot else { return }
|
||
lastLoggedLayoutSnapshot = snapshot
|
||
OSGDiag.log("KVC.layout \(snapshot)", category: "boot")
|
||
}
|
||
|
||
private func installSwiftUI() {
|
||
let root = KeyboardSurfaceRoot(
|
||
state: state,
|
||
typing: typingSession,
|
||
onInsert: { [weak self] text in
|
||
self?.insertTextIntoDocument(text, source: .manualKeyboard)
|
||
},
|
||
onDeleteBackward: { [weak self] in
|
||
self?.deleteBackwardFromDocument()
|
||
}
|
||
)
|
||
let host = KeyboardHostingController(rootView: root)
|
||
host.view.backgroundColor = .clear
|
||
host.view.translatesAutoresizingMaskIntoConstraints = false
|
||
host.view.clipsToBounds = false
|
||
host.view.insetsLayoutMarginsFromSafeArea = false
|
||
host.safeAreaRegions = []
|
||
addChild(host)
|
||
view.addSubview(host.view)
|
||
NSLayoutConstraint.activate([
|
||
host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||
host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||
host.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||
host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
||
])
|
||
host.didMove(toParent: self)
|
||
hosting = host
|
||
}
|
||
|
||
// MARK: - App context
|
||
|
||
private func detectAndStoreAppContext() {
|
||
guard textDocumentProxy.isSecureTextEntry == false else { return }
|
||
let preceding = textDocumentProxy.documentContextBeforeInput
|
||
let store = AppGroupStore()
|
||
let detector = AppContextDetector()
|
||
let context = detector.detect(
|
||
precedingText: preceding,
|
||
storedCache: store.detectedAppContext
|
||
)
|
||
store.setDetectedAppContext(context)
|
||
}
|
||
|
||
private func captureFieldContext() -> FlowFieldContext {
|
||
let isSecure = textDocumentProxy.isSecureTextEntry ?? true
|
||
let preceding = isSecure ? nil : textDocumentProxy.documentContextBeforeInput
|
||
let following = isSecure ? nil : textDocumentProxy.documentContextAfterInput
|
||
let isAvailable = preceding != nil || following != nil
|
||
let isEmpty = isAvailable && (preceding ?? "").isEmpty && (following ?? "").isEmpty
|
||
|
||
return FlowFieldContext(
|
||
precedingText: preceding.map { String($0.suffix(600)) },
|
||
followingText: following.map { String($0.prefix(200)) },
|
||
keyboardType: keyboardTypeName(textDocumentProxy.keyboardType ?? .default),
|
||
returnKeyType: returnKeyTypeName(textDocumentProxy.returnKeyType ?? .default),
|
||
isSecureEntry: isSecure,
|
||
isEmptyField: isEmpty,
|
||
isContextAvailable: isAvailable
|
||
)
|
||
}
|
||
|
||
private func keyboardTypeName(_ type: UIKeyboardType) -> String {
|
||
switch type {
|
||
case .asciiCapable: return "asciiCapable"
|
||
case .numbersAndPunctuation: return "numbersAndPunctuation"
|
||
case .URL: return "url"
|
||
case .numberPad: return "numberPad"
|
||
case .phonePad: return "phonePad"
|
||
case .namePhonePad: return "namePhonePad"
|
||
case .emailAddress: return "emailAddress"
|
||
case .decimalPad: return "decimalPad"
|
||
case .twitter: return "twitter"
|
||
case .webSearch: return "webSearch"
|
||
case .asciiCapableNumberPad: return "asciiCapableNumberPad"
|
||
case .default: return "default"
|
||
@unknown default: return "default"
|
||
}
|
||
}
|
||
|
||
private func returnKeyTypeName(_ type: UIReturnKeyType) -> String {
|
||
switch type {
|
||
case .go: return "go"
|
||
case .google: return "google"
|
||
case .join: return "join"
|
||
case .next: return "next"
|
||
case .route: return "route"
|
||
case .search: return "search"
|
||
case .send: return "send"
|
||
case .yahoo: return "yahoo"
|
||
case .done: return "done"
|
||
case .emergencyCall: return "emergencyCall"
|
||
case .continue: return "continue"
|
||
case .default: return "default"
|
||
@unknown default: return "default"
|
||
}
|
||
}
|
||
|
||
// MARK: - Open host app
|
||
|
||
private func openWebURL(_ url: URL) {
|
||
guard hasFullAccess else {
|
||
state.skillTipText = ExtL10n.string("keyboard.error.fullAccessForJump")
|
||
return
|
||
}
|
||
HostAppLauncher.open(url: url, from: self) { [weak self] success in
|
||
guard !success else { return }
|
||
self?.state.skillTipText = ExtL10n.string("keyboard.ai.skill.openLinkFailed")
|
||
}
|
||
}
|
||
|
||
private func callPhone(_ phoneNumber: String) {
|
||
guard hasFullAccess else {
|
||
state.skillTipText = ExtL10n.string("keyboard.error.fullAccessForJump")
|
||
return
|
||
}
|
||
guard let url = AIPhoneNumberResolver.telephoneURL(for: phoneNumber) else {
|
||
state.skillTipText = ExtL10n.string("keyboard.ai.skill.callPhoneFailed")
|
||
return
|
||
}
|
||
HostAppLauncher.open(url: url, from: self) { [weak self] success in
|
||
guard !success else { return }
|
||
self?.state.skillTipText = ExtL10n.string("keyboard.ai.skill.callPhoneFailed")
|
||
}
|
||
}
|
||
|
||
private func createContact(_ phoneNumber: String) {
|
||
guard hasFullAccess else {
|
||
state.skillTipText = ExtL10n.string("keyboard.error.fullAccessForJump")
|
||
return
|
||
}
|
||
AppGroupStore().setPendingContactCreation(phoneNumber: phoneNumber)
|
||
guard let url = URL(string: "osgkeyboard://contact/new") else { return }
|
||
HostAppLauncher.open(url: url, from: self) { [weak self] success in
|
||
guard !success else { return }
|
||
self?.state.skillTipText = ExtL10n.string(
|
||
"keyboard.ai.skill.createContactFailed"
|
||
)
|
||
}
|
||
}
|
||
|
||
private func openSkillShortcutRun() {
|
||
guard hasFullAccess else {
|
||
state.skillTipText = ExtL10n.string("keyboard.error.fullAccessForJump")
|
||
return
|
||
}
|
||
guard let url = URL(string: "osgkeyboard://skill/run") else { return }
|
||
HostAppLauncher.open(url: url, from: self) { [weak self] success in
|
||
AIAgentShortcutRun.trace("keyboard.openHost result success=\(success)")
|
||
if success { return }
|
||
self?.state.skillTipText = ExtL10n.string("keyboard.ai.skill.handoffFailed")
|
||
}
|
||
}
|
||
|
||
private func openHostApp(path: String = "settings") {
|
||
guard hasFullAccess else {
|
||
let msg = ExtL10n.string("keyboard.error.fullAccessForJump")
|
||
state.phase = .error(.manualOpenRequired, message: msg)
|
||
scheduleAutoClearError()
|
||
return
|
||
}
|
||
guard let url = URL(string: "osgkeyboard://\(path)") else {
|
||
flowCoordinator.handleHostAppOpenResult(path: path, success: false)
|
||
return
|
||
}
|
||
HostAppLauncher.open(url: url, from: self) { [weak self] success in
|
||
self?.flowCoordinator.handleHostAppOpenResult(path: path, success: success)
|
||
}
|
||
}
|
||
|
||
private func scheduleAutoClearError() {
|
||
Task { @MainActor [weak self] in
|
||
try? await Task.sleep(nanoseconds: 2_400_000_000)
|
||
guard let self else { return }
|
||
switch self.state.phase {
|
||
case .error:
|
||
self.state.phase = .idle
|
||
// Re-derive mic availability right away so a now-ready host
|
||
// turns the mic green immediately instead of lingering orange
|
||
// until the next monitor tick.
|
||
self.flowCoordinator.refreshSessionState()
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|