Files
OSGKeyboard/OSGKeyboardExt/KeyboardViewController.swift
T
rocky a2abfc5e8c fix: keyboard stability, host-only analytics, and auto-reply dedupe
- record analytics/usage only in the host app; the extension no longer opens the
  SQLite queues, so hiding the keyboard cannot hold WAL locks (0xdead10cc)
- sync clipboard history writes across processes so deletes made on one side
  are not reverted by the other
- guard memory-relief reentrancy on controller and telemetry sides to break
  the recursive crash loop
- resolve credentials off the main thread and migrate legacy keys once per
  install, so Keychain reads cannot be caught in the suspend window
- gate Volcengine ASR ping completion to once (SIGTRAP on double resume)
- treat same-text captures as the same logical copy and dedupe auto actions
  by content fingerprint within 30s, stopping Universal Clipboard storms
- realign version line to 2.1.0 (build 110)
2026-09-25 12:14:29 +08:00

1387 lines
59 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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()
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
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()
}
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")
// 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
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
}
}
}
}