3c10d73d7f
- Merge invitation, task, blessing, clarification, and empathy actions into a single Reply flow, with three fixed, clearly labeled stance choices whenever user intent must not be guessed. - Refine clipboard semantic routing with bilingual schedule, confirmation, and follow-up models, conservative language thresholds, and explicit-assignment guard for complaint-only text. - Persist Apple account refresh state, harden session recovery, and surface durable account diagnostics across keyboard and app. - Derive personal-style prompts through two-stage corpus evidence and apply real low-confidence ASR tendencies instead of neutral templates. - Localize the new reply center, clipboard semantics, and personal-style surfaces in both English and Simplified Chinese.
1293 lines
53 KiB
Swift
1293 lines
53 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()
|
||
|
||
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>?
|
||
/// One random ID per keyboard presentation. The repository splits this ID
|
||
/// into independent UTC-day fragments when a presentation crosses midnight.
|
||
private var keyboardUsageSessionID = UUID()
|
||
/// 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()
|
||
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()
|
||
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
|
||
typingDocumentPresentationID = nil
|
||
typingSessionStorage?.endDocumentPresentation()
|
||
AnalyticsExtensionService.shared.keyboardWillDisappear()
|
||
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
|
||
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()
|
||
}
|
||
if state.surface != .typing {
|
||
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")
|
||
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)
|
||
keyboardUsageSessionID = UUID()
|
||
AnalyticsExtensionService.shared.recordPresentation(
|
||
hasFullAccess: hasFullAccess
|
||
)
|
||
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
|
||
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"
|
||
)
|
||
flowCoordinator.cancelPipelineUnlessAwaitingResult()
|
||
if state.surface == .typing {
|
||
applySurface(.voice)
|
||
} else {
|
||
deactivateTypingSurface()
|
||
}
|
||
}
|
||
|
||
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.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
|
||
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 }
|
||
textDocumentProxy.insertText(text)
|
||
if source.contributesToKeyboardUsage {
|
||
let counts = KeyboardUsageCharacterClassifier.classify(text)
|
||
AnalyticsExtensionService.shared.keyboardUsageRecorder
|
||
.recordManualKeyboardCounts(
|
||
counts,
|
||
sessionID: keyboardUsageSessionID
|
||
)
|
||
}
|
||
// A non-empty insertion makes content actions available immediately.
|
||
// The next host callback remains the authoritative correction.
|
||
refreshAssistantFieldAction(hasTextOverride: true)
|
||
}
|
||
|
||
private func deleteBackwardFromDocument() {
|
||
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
|
||
}
|
||
}
|
||
}
|
||
}
|