Files
OSGKeyboard/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift
T
Rocky 5d7fcdf24e feat(keyboard): harden polish/clipboard guards and ship 1.6.6 (build 59)
Keep marketing version at 1.6.6 and bump build to 59. Strengthen never-answer
polish safeguards, clipboard reply-intent continuity, voice undo UX, device
UITests harness, eval fixtures, and What's New assets.
2026-08-09 12:45:33 +08:00

2043 lines
86 KiB
Swift

// KeyboardFlowCoordinator.swift
// OSGKeyboard · Keyboard Extension
//
// Flow session start, recording, watchdogs, and result delivery handling.
import UIKit
import OSGKeyboardShared
@MainActor
final class KeyboardFlowCoordinator {
private enum FlowWatchdog {
static let pollIntervalNs: UInt64 = 200_000_000
/// Give the user time to manually open the host app when auto-jump fails.
static let startTimeout: TimeInterval = 30
static func resultTimeout(engineMode: String) -> TimeInterval {
FlowSessionKeys.keyboardResultTimeout(engineMode: engineMode)
}
}
private let state: KeyboardState
private let textInserter: KeyboardTextInserter
private let hasFullAccess: () -> Bool
private let wakeLockView: () -> UIView?
private let openHostApp: (String) -> Void
private let detectAndStoreAppContext: () -> Void
private let fieldContextProvider: () -> FlowFieldContext?
private let scheduleAutoClearError: () -> Void
private let refreshConfigFromAppGroup: () -> Void
private var isPendingFlowStart = false
private var flowStartDeadline: TimeInterval = 0
private var isFlowRecording = false
private var flowWatchdogTask: Task<Void, Never>?
private var utteranceTimerTask: Task<Void, Never>?
private var hostReadyWaitTask: Task<Void, Never>?
private var utteranceStartedAt: TimeInterval = 0
private var wasSessionActive = false
/// Last wall-clock time the host published a fresh ready contract. Used to
/// smooth over transient cross-process heartbeat read jitter so a single
/// stale sample never flashes the mic orange while the session is healthy.
private var lastHostReadyAt: TimeInterval = 0
private static let hostReadyGrace: TimeInterval = 4
/// Once the host has published ready for this session, hold green through
/// brief inter-utterance ready flaps instead of flashing preparingSession.
private var sessionProvenReady = false
private var flowSessionMonitorTask: Task<Void, Never>?
private var isAwaitingFlowResult = false
private var activeSessionId: UUID?
private var currentUtteranceId: UUID?
/// Utterance whose final result we already inserted (or failed). Prevents
/// `adoptHostBusyStateIfNeeded` from re-entering `.processing` after a
/// stale App Group snapshot still says `reason=processing`.
private var lastConsumedUtteranceId: UUID?
/// Utterance we just asked the host to stop. Until the host publishes
/// processing/final state, stale App Group snapshots can still say
/// `reason=recording`; do not re-adopt that utterance as locally active.
private var lastStoppedUtteranceId: UUID?
private var currentCommandSeq: Int64 = 0
private var lastAvailabilityTraceSignature = ""
/// When true, `completeFlowStartHandoff` starts recording after the host
/// publishes ready — set only for an explicit mic press.
private var recordAfterHandoff = false
/// When true, `startHostReadyWaitIfNeeded` starts recording once ready
/// (mic pressed while session was still warming / mid ready-flap).
private var recordWhenHostReady = false
/// Ignores single-frame "host dead" samples before allowing a cold-start jump
/// from non-press recovery paths.
private var coldStartDebouncer = FlowColdStartDebouncer()
/// Frozen clipboard material for the in-flight clipboard-command utterance.
private var clipboardFrozenSnapshot: String?
/// True while the live utterance is a clipboard-command round.
private var isClipboardCommandUtterance = false
/// True while blocked inside `UIPasteboard.string` (system paste alert).
/// Must preserve extension lifecycle / voice surface across that alert.
private var isAcquiringClipboardPaste = false
private var clipboardAcquisitionTask: Task<Void, Never>?
/// Clipboard start sent; waiting for host `reason=recording` before confirmed capture UI.
private var clipboardAwaitingHostRecordConfirm = false
/// Wall time when host confirmed real capture for this clipboard utterance.
private var clipboardHostRecordConfirmedAt: TimeInterval?
/// User tapped stop — honor after confirm + minimum recording window.
private var clipboardStopRequested = false
private var clipboardDeferredStopTask: Task<Void, Never>?
private var clipboardFailureHintTask: Task<Void, Never>?
private var clipboardPreparingWatchdogTask: Task<Void, Never>?
/// Wall time when we entered clipboard「准备录音…」awaiting host confirm.
private var clipboardPreparingStartedAt: TimeInterval = 0
init(
state: KeyboardState,
textInserter: KeyboardTextInserter,
hasFullAccess: @escaping () -> Bool,
wakeLockView: @escaping () -> UIView?,
openHostApp: @escaping (String) -> Void,
detectAndStoreAppContext: @escaping () -> Void,
fieldContextProvider: @escaping () -> FlowFieldContext?,
scheduleAutoClearError: @escaping () -> Void,
refreshConfigFromAppGroup: @escaping () -> Void
) {
self.state = state
self.textInserter = textInserter
self.hasFullAccess = hasFullAccess
self.wakeLockView = wakeLockView
self.openHostApp = openHostApp
self.detectAndStoreAppContext = detectAndStoreAppContext
self.fieldContextProvider = fieldContextProvider
self.scheduleAutoClearError = scheduleAutoClearError
self.refreshConfigFromAppGroup = refreshConfigFromAppGroup
}
var preservesLifecycleOnDisappear: Bool {
isPendingFlowStart
|| isFlowRecording
|| isAwaitingFlowResult
|| isClipboardCommandUtterance
|| isAcquiringClipboardPaste
|| ClipboardCommandResume.shouldPreferVoice()
}
/// True while a clipboard-command round owns the mic (incl. paste-alert acquire).
var isClipboardCommandActive: Bool {
isClipboardCommandUtterance
|| isAcquiringClipboardPaste
|| ClipboardCommandResume.shouldPreferVoice()
}
/// Session/transcription changes are pushed in real time by Darwin
/// notifications (see `KeyboardConfigSync.installDarwinObservers`), so this
/// loop is only a low-frequency safety net for coalesced/dropped Darwin
/// signals — hence 3 s rather than 1 Hz to save battery while idle.
private static let sessionMonitorIntervalNs: UInt64 = 3_000_000_000
func startSessionMonitor() {
flowSessionMonitorTask?.cancel()
flowSessionMonitorTask = Task { @MainActor [weak self] in
while !Task.isCancelled {
self?.refreshSessionState()
try? await Task.sleep(nanoseconds: Self.sessionMonitorIntervalNs)
}
}
}
func stopSessionMonitor() {
flowSessionMonitorTask?.cancel()
flowSessionMonitorTask = nil
stopHostReadyWait()
}
/// Ensure the containing app has armed its low-profile PiP even when the
/// keyboard opens directly into Pinyin/English typing mode. The mic stays
/// visually ready; if the host contract is missing, one automatic handoff
/// prepares PiP so the next press does not need another app switch.
///
/// Must **not** re-jump when a session is already alive/warming (common after
/// the user returns from startflow and taps the Voice tab — height/appear
/// noise used to call this again while `ready` briefly lagged).
func ensurePiPReadyOnKeyboardOpen() {
guard FlowHandoffPolicy.allowsProactiveHostAutoLaunch,
state.hasCompletedOnboarding,
hasFullAccess(),
AppGroup.isAvailable,
!isPendingFlowStart,
!isFlowRecording,
!isAwaitingFlowResult else { return }
FlowSessionBridge.reloadFromDisk()
let withinReadyGrace = lastHostReadyAt > 0
&& (Date().timeIntervalSince1970 - lastHostReadyAt) <= Self.hostReadyGrace
let shouldArm = FlowHandoffPolicy.shouldProactivePiPArm(
hostReady: FlowSessionBridge.isHostReady(),
snapshotReason: FlowSessionBridge.readySnapshot()?.reason,
sessionActive: FlowSessionBridge.isSessionActive(),
hostReachable: FlowSessionBridge.isHostReachable(),
hostStale: FlowSessionBridge.isHostStale(),
withinReadyGrace: withinReadyGrace,
inCooldown: FlowSessionBridge.isPiPArmInCooldown(),
heartbeatStaleness: FlowSessionBridge.heartbeatStaleness()
)
guard shouldArm else {
traceState("keyboardOpen.autoArmPiP.skipped", extra: "gate=0")
return
}
FlowSessionBridge.markPiPArmAttempt()
detectAndStoreAppContext()
beginFlowStart(recordAfterHandoff: false)
traceState("keyboardOpen.autoArmPiP")
}
func refreshSessionState() {
FlowSessionBridge.reloadFromDisk()
refreshConfigFromAppGroup()
refreshFlowPartialIfNeeded()
adoptPendingResultIfNeeded()
consumePendingFlowDeliveryIfNeeded()
recoverFromDeadHostIfNeeded()
if FlowSessionBridge.clearIfHostStale() {
debug("cleared zombie Flow session from App Group")
}
// A stale "session ended" hint may linger from an earlier drop. If the
// host is provably ready again, recover to idle now so the mic can go
// green immediately instead of waiting out the auto-clear timer.
if case .error(.flowSessionExpired, _) = state.phase,
FlowSessionBridge.isHostReady() {
state.phase = .idle
state.lastTranscript = ""
}
recomputeMicVoiceAvailability()
refreshClipboardEligibility()
promoteClipboardRecordingFromSnapshotIfNeeded()
recoverClipboardPreparingIfHostMovedOn()
startHostReadyWaitIfNeeded()
// Proactive host auto-launch is disabled (FlowHandoffPolicy): a single
// stale ready snapshot after finalize must never open startflow.
// Only surface "session ended" when the session contract *genuinely*
// dropped (expired / cleared). A transient host-ready flap — engine
// hiccup or a stale cross-process read while the session is still
// valid — must never nuke a healthy ready state into a sticky error,
// otherwise the error phase forces the mic orange and defeats the
// ready-wait poll until the auto-clear fires.
let sessionActive = FlowSessionBridge.isSessionActive()
if wasSessionActive && !sessionActive && !isFlowRecording && !isPendingFlowStart {
switch state.phase {
case .recording, .processing:
break
default:
showFlowSessionExpiredHint()
}
}
wasSessionActive = sessionActive
}
private func recomputeMicVoiceAvailability() {
FlowSessionBridge.reloadFromDisk()
let readySnapshot = FlowSessionBridge.readySnapshot()
activeSessionId = readySnapshot?.sessionId ?? activeSessionId
// If the host is mid-utterance but this extension process lost local
// ownership (jetsam / recreate after app switch), re-adopt it so we
// show red/white instead of a fake orange "starting" state.
adoptHostBusyStateIfNeeded(snapshot: readySnapshot)
let hostReadyRaw = readySnapshot?.ready == true && FlowSessionBridge.isHostReady()
let sessionActive = FlowSessionBridge.isSessionActive()
let now = Date().timeIntervalSince1970
if hostReadyRaw {
lastHostReadyAt = now
sessionProvenReady = true
}
if !sessionActive {
sessionProvenReady = false
lastHostReadyAt = 0
}
// Grace window: the host was ready very recently, so treat a momentary
// stale heartbeat read as "still warming" rather than an outright
// failure. `isSessionActive` is heartbeat-independent, so it stays true
// across cross-process read jitter and anchors this smoothing.
let withinReadyGrace = lastHostReadyAt > 0
&& (now - lastHostReadyAt) <= Self.hostReadyGrace
// Host busy (recording/processing) is NOT "still starting". Treating
// it as preparingSession was the orange-stuck bug after cold start:
// host utt.rec=1 → ready=false → keyboard forever "正在启动…".
let hostBusy = FlowKeyboardHostWarming.isHostBusy(reason: readySnapshot?.reason)
// Hold green after the session already proved ready — PiP mic release /
// ack lag must not flash yellow「正在启动…」.
let holdReady = FlowKeyboardHostWarming.shouldHoldReady(
hostReady: hostReadyRaw,
hostBusy: hostBusy,
sessionActive: sessionActive,
sessionProvenReady: sessionProvenReady,
isPendingFlowStart: isPendingFlowStart,
snapshotReason: readySnapshot?.reason
)
let hostReady = hostReadyRaw || holdReady
// PiP sessions publish `reason=.starting` while the small window is
// coming up — treat that as warming so the mic stays orange (wait)
// instead of jumping into another cold start.
let hostWarming = FlowKeyboardHostWarming.isHostWarming(
hostReady: hostReady,
hostBusy: hostBusy,
sessionActive: sessionActive,
hostReachable: FlowSessionBridge.isHostReachable(),
isPendingFlowStart: isPendingFlowStart,
withinReadyGrace: withinReadyGrace,
snapshotReason: readySnapshot?.reason
)
state.flowSessionActive = sessionActive
state.debugPendingFlowStart = isPendingFlowStart
state.debugFlowRecording = isFlowRecording
state.debugAwaitingFlowResult = isAwaitingFlowResult
state.debugHasFullAccess = hasFullAccess()
state.micVoiceAvailability = MicVoiceAvailabilityResolver.resolve(
phase: state.phase,
micDisabled: state.micDisabled,
hasFullAccess: hasFullAccess(),
appGroupAvailable: AppGroup.isAvailable,
hostReady: hostReady,
isPreparingSession: isPendingFlowStart || hostWarming,
hasCompletedOnboarding: state.hasCompletedOnboarding
)
let signature = [
"phase=\(String(describing: state.phase))",
"availability=\(String(describing: state.micVoiceAvailability))",
hostReady ? "hostReady=1" : "hostReady=0",
state.flowSessionActive ? "sessionActive=1" : "sessionActive=0",
isPendingFlowStart ? "pending=1" : "pending=0",
isFlowRecording ? "recording=1" : "recording=0",
isAwaitingFlowResult ? "awaiting=1" : "awaiting=0",
readySnapshot?.reason.rawValue ?? "snapshot=nil"
].joined(separator: "|")
if signature != lastAvailabilityTraceSignature {
lastAvailabilityTraceSignature = signature
traceState("availability.update", extra: signature)
}
}
/// Re-attach to a host utterance this keyboard process no longer owns.
private func adoptHostBusyStateIfNeeded(snapshot: FlowReadySnapshot?) {
let action = FlowKeyboardAdoptBusyPolicy.decide(
snapshot: snapshot,
currentHostGeneration: FlowSessionBridge.currentHostGeneration(),
isFlowRecording: isFlowRecording,
isAwaitingFlowResult: isAwaitingFlowResult,
lastConsumedUtteranceId: lastConsumedUtteranceId,
lastStoppedUtteranceId: lastStoppedUtteranceId
)
switch action {
case .none:
return
case .clearStickyProcessing:
clearStickyProcessingIfNeeded(hostReady: snapshot?.ready ?? false)
case .adoptRecording(let sessionId, let busyId):
// Require the host's utterance id — inventing one makes matchingResult
// forever miss the real delivery and leaves the mic white forever.
activeSessionId = sessionId
currentUtteranceId = busyId
isPendingFlowStart = false
flowStartDeadline = 0
stopHostReadyWait()
isFlowRecording = true
// Paste-alert may have destroyed in-memory clipboard flags; sticky
// snapshot means this busy utterance is still a clipboard round.
if !isClipboardCommandUtterance,
ClipboardCommandResume.shouldPreferVoice() {
if let snap = ClipboardCommandResume.pendingSnapshot(), !snap.isEmpty {
clipboardFrozenSnapshot = snap
}
isClipboardCommandUtterance = true
}
if isClipboardCommandUtterance {
noteClipboardHostRecordingConfirmed()
} else {
state.phase = .recording
if state.lastTranscript.isEmpty {
state.lastTranscript = ""
}
}
publishClipboardUIState()
if let view = wakeLockView() {
ExtensionScreenWakeLock.acquire(from: view)
}
startUtteranceCountdown()
startFlowLevelWatchdog()
traceState("adoptHostBusy.recording", extra: "session=\(sessionId)")
case .adoptProcessing(let sessionId, let busyId):
activeSessionId = sessionId
currentUtteranceId = busyId
isPendingFlowStart = false
flowStartDeadline = 0
isFlowRecording = false
stopUtteranceCountdown()
ExtensionScreenWakeLock.release()
state.phase = .processing
if state.lastTranscript.isEmpty {
state.lastTranscript = ExtL10n.string("keyboard.flow.transcribing")
}
startFlowResultWatchdog()
traceState("adoptHostBusy.processing", extra: "session=\(sessionId)")
}
}
/// After insert, a stale `reason=processing` snapshot can bounce the mic
/// back to white loading. When the host is no longer busy, force idle.
private func clearStickyProcessingIfNeeded(hostReady: Bool) {
guard !isAwaitingFlowResult, !isFlowRecording else { return }
guard case .processing = state.phase else { return }
state.phase = .idle
state.lastTranscript = ""
stopFlowWatchdog()
currentUtteranceId = nil
lastStoppedUtteranceId = nil
traceState(
"stickyProcessing.cleared",
extra: hostReady ? "hostReady=1" : "hostReady=0"
)
}
/// Session is live but the ready contract has not landed yet — poll
/// quickly instead of sticking on "session inactive" orange.
///
/// Cold-start (`osgkeyboard://startflow`) is allowed only when the user
/// explicitly pressed the mic (`recordWhenHostReady`). An idle open must
/// never relaunch the host: Flow + ASR warmup then jetsams the keyboard.
private func startHostReadyWaitIfNeeded() {
guard !isPendingFlowStart else { return }
guard FlowSessionBridge.isSessionActive() else {
stopHostReadyWait()
if recordWhenHostReady {
// Session gone while waiting — escalate to a real cold start.
let shouldRecord = recordWhenHostReady
recordWhenHostReady = false
beginFlowStart(recordAfterHandoff: shouldRecord)
}
return
}
// Host busy ≠ waiting for ready. Do not spin the ready-wait poll.
if let reason = FlowSessionBridge.readySnapshot()?.reason,
reason == .recording || reason == .processing {
stopHostReadyWait()
recordWhenHostReady = false
return
}
if FlowSessionBridge.isHostReady() {
stopHostReadyWait()
finishHostReadyWaitIfNeeded()
return
}
// No mic intent + host already dead → leave cleanup to clearIfHostStale.
// Starting a wait poll here previously ended in an unprompted startflow.
if !recordWhenHostReady, isHostTrulyDeadForColdStart() {
stopHostReadyWait()
return
}
guard hostReadyWaitTask == nil else { return }
hostReadyWaitTask = Task { @MainActor [weak self] in
defer { self?.hostReadyWaitTask = nil }
for _ in 0..<20 {
guard let self, !Task.isCancelled else { return }
FlowSessionBridge.reloadFromDisk()
self.recomputeMicVoiceAvailability()
if self.state.micVoiceAvailability.isReady {
self.finishHostReadyWaitIfNeeded()
return
}
if self.state.micVoiceAvailability == .recording
|| self.state.micVoiceAvailability == .processing {
self.recordWhenHostReady = false
return
}
// Host died mid-wait — cold-start only after debounced dead
// samples AND an explicit mic-driven record intent.
let dead = self.isHostTrulyDeadForColdStart()
if self.coldStartDebouncer.observe(hostTrulyDead: dead) {
let shouldRecord = self.recordWhenHostReady
self.recordWhenHostReady = false
self.coldStartDebouncer.reset()
if shouldRecord {
self.beginFlowStart(recordAfterHandoff: true)
} else {
self.traceState(
"hostReadyWait.deadWithoutIntent",
extra: "skipColdStart=1"
)
self.stopHostReadyWait()
}
return
}
try? await Task.sleep(nanoseconds: 150_000_000)
}
// Timed out still not ready — if the user asked to record, cold-start.
guard let self else { return }
if self.recordWhenHostReady {
let shouldRecord = self.recordWhenHostReady
self.recordWhenHostReady = false
self.beginFlowStart(recordAfterHandoff: shouldRecord)
}
}
}
private func isHostTrulyDeadForColdStart() -> Bool {
FlowHandoffPolicy.shouldOpenHostColdStart(
sessionActive: FlowSessionBridge.isSessionActive(),
hostReachable: FlowSessionBridge.isHostReachable(),
hostStale: FlowSessionBridge.isHostStale(),
withinReadyGrace: false
)
}
private func finishHostReadyWaitIfNeeded() {
coldStartDebouncer.reset()
guard recordWhenHostReady else { return }
recordWhenHostReady = false
guard state.micVoiceAvailability.isReady else { return }
startFlowRecording()
traceState("hostReadyWait.recordStarted")
}
private func stopHostReadyWait() {
hostReadyWaitTask?.cancel()
hostReadyWaitTask = nil
}
func toggleRecording() {
// Every non-recording clipboard stage is explicitly cancellable.
if isClipboardCommandActive, state.phase != .recording {
cancelClipboardIntent(reason: "userCancel")
return
}
switch state.phase {
case .recording:
if isClipboardCommandUtterance {
requestClipboardStop()
} else {
pressEnded()
}
case .requestingPermissions:
// Clipboard preparing: tap cancels/stops once host confirms (+ min window).
if isClipboardCommandUtterance {
requestClipboardStop()
}
case .idle, .denied, .error:
pressBegan()
case .processing:
break
}
}
/// Long-press creates one persisted intent; acquisition and host warm-up resume automatically.
func clipboardCommandPressBegan() {
switch state.phase {
case .idle, .denied, .error:
break
case .processing:
return
default:
return
}
// Never overwrite an older recoverable intent with a second UUID.
if ClipboardCommandResume.currentIntent() != nil {
restoreClipboardCommandIfNeeded()
return
}
clearClipboardFailureHint()
guard hasFullAccess() else {
showClipboardFailure(.noFullAccess)
return
}
if fieldContextProvider()?.isSecureEntry == true {
showClipboardFailure(.secureField)
return
}
guard let intent = ClipboardCommandResume.beginIntent() else {
showClipboardFailure(.noFullAccess)
return
}
if state.surface != .voice {
state.setSurface(.voice)
}
currentUtteranceId = intent.id
isClipboardCommandUtterance = true
isAcquiringClipboardPaste = true
state.phase = .requestingPermissions
state.lastTranscript = ExtL10n.string("keyboard.placeholder.preparingRecording")
publishClipboardUIState()
scheduleClipboardAcquisition(intentId: intent.id)
traceState("clipboard.intent.created", extra: "intent=\(intent.id.uuidString.prefix(8))")
}
private func scheduleClipboardAcquisition(intentId: UUID) {
clipboardAcquisitionTask?.cancel()
clipboardAcquisitionTask = Task { @MainActor [weak self] in
// Commit the intent and render cancellable chrome before UIKit may
// present the system paste-consent sheet.
await Task.yield()
guard let self, !Task.isCancelled else { return }
self.acquireClipboardMaterial(intentId: intentId)
}
}
/// UIKit owns paste consent and may suspend the extension. The persisted
/// intent makes that interruption resumable even though the content read itself
/// must stay on the main actor.
private func acquireClipboardMaterial(intentId: UUID) {
guard let intent = ClipboardCommandResume.currentIntent(), intent.id == intentId else { return }
if let snapshot = intent.snapshot, !snapshot.isEmpty {
clipboardFrozenSnapshot = snapshot
isAcquiringClipboardPaste = false
continueClipboardIntent(intentId: intentId)
return
}
isAcquiringClipboardPaste = true
publishClipboardUIState()
let sample = ClipboardPasteboardReader.sample()
// The user may have cancelled while UIKit was returning from consent.
guard ClipboardCommandResume.currentIntent()?.id == intentId else { return }
isAcquiringClipboardPaste = false
guard let raw = sample.text else {
resetClipboardUtteranceState()
showClipboardFailure(
ClipboardPasteboardReader.hasStrings() ? .pasteDenied : .material(.empty)
)
refreshClipboardEligibility()
return
}
switch ClipboardMaterialFilter.evaluate(raw) {
case .rejected(let reason):
resetClipboardUtteranceState()
showClipboardFailure(.material(reason))
case .eligible(let snapshot):
clipboardFrozenSnapshot = snapshot
ClipboardCommandResume.storeSnapshot(snapshot)
continueClipboardIntent(intentId: intentId)
}
}
/// Advance the same intent through host warm-up to one idempotent start.
private func continueClipboardIntent(intentId: UUID) {
guard let intent = ClipboardCommandResume.currentIntent(),
intent.id == intentId,
let snapshot = intent.snapshot,
!snapshot.isEmpty else { return }
clipboardFrozenSnapshot = snapshot
currentUtteranceId = intentId
isClipboardCommandUtterance = true
isAcquiringClipboardPaste = false
clipboardAwaitingHostRecordConfirm = true
state.phase = .requestingPermissions
state.lastTranscript = ExtL10n.string("keyboard.placeholder.preparingRecording")
publishClipboardUIState()
recomputeMicVoiceAvailability()
let hostReadyRaw = FlowSessionBridge.isHostReady()
let withinReadyGrace = lastHostReadyAt > 0
&& (Date().timeIntervalSince1970 - lastHostReadyAt) <= Self.hostReadyGrace
// Force-quit leaves sessionActive=true; treat unreachable heartbeat as dead
// so clipboard can still open startflow (same grace as proactive PiP arm).
let heartbeatStale = FlowSessionBridge.heartbeatStaleness() ?? .infinity
let sessionEffectivelyDead = !FlowSessionBridge.isHostReachable()
&& heartbeatStale >= FlowHandoffPolicy.proactiveUnreachableArmGrace
let sessionActiveForGate = FlowSessionBridge.isSessionActive() && !sessionEffectivelyDead
let micAction: FlowMicPressAction
if hostReadyRaw, state.micVoiceAvailability.isReady {
micAction = .startRecording
} else {
micAction = FlowHandoffPolicy.micPressAction(
availability: hostReadyRaw ? state.micVoiceAvailability : .unavailable(.hostNotReady),
sessionActive: sessionActiveForGate,
hostReachable: FlowSessionBridge.isHostReachable(),
hostStale: FlowSessionBridge.isHostStale() || sessionEffectivelyDead,
withinReadyGrace: withinReadyGrace
)
}
switch ClipboardPreparingPolicy.hostGateAction(micPressAction: micAction) {
case .startRecordingNow:
startFlowRecording()
case .openHostColdStart:
detectAndStoreAppContext()
if !isPendingFlowStart, !FlowSessionBridge.isPiPArmInCooldown() {
beginFlowStart(recordAfterHandoff: true)
}
showClipboardHostWarmupHint()
case .waitForHost:
recordWhenHostReady = true
coldStartDebouncer.reset()
startHostReadyWaitIfNeeded()
showClipboardHostWarmupHint()
case .ignore:
recordWhenHostReady = true
startHostReadyWaitIfNeeded()
showClipboardHostWarmupHint()
}
traceState("clipboard.intent.advancing", extra: "intent=\(intentId.uuidString.prefix(8))")
}
/// Keep the intent live and let the existing host-ready loop auto-start it.
private func deferClipboardUntilHostWarm(reason: String) {
isClipboardCommandUtterance = true
clipboardAwaitingHostRecordConfirm = true
recordWhenHostReady = true
state.phase = .requestingPermissions
state.lastTranscript = ExtL10n.string("keyboard.placeholder.preparingRecording")
publishClipboardUIState()
startHostReadyWaitIfNeeded()
traceState("clipboard.intent.waitingHost", extra: reason)
}
/// Soft progress hint; the mic remains tappable to cancel the intent.
private func showClipboardHostWarmupHint() {
state.clipboardFailureHint = ExtL10n.string("keyboard.clipboard.hint.hostStarting")
clipboardFailureHintTask?.cancel()
let duration = ClipboardMaterialFilter.failureHintDuration
clipboardFailureHintTask = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000))
guard let self, !Task.isCancelled else { return }
self.clearClipboardFailureHint()
}
}
/// After paste-alert / cold-start recreation, resume the same persisted intent.
func restoreClipboardCommandIfNeeded() {
guard let intent = ClipboardCommandResume.currentIntent() else { return }
if state.surface != .voice {
state.setSurface(.voice)
}
// Snapshot for the next long-press (cold-start return) — not a live round yet.
if let snapshot = ClipboardCommandResume.pendingSnapshot(), !snapshot.isEmpty {
clipboardFrozenSnapshot = snapshot
}
let hasIssued = ClipboardCommandResume.hasStartIssued()
if hasIssued,
!isClipboardCommandUtterance,
let issued = ClipboardCommandResume.startIssuedUtteranceId() {
isClipboardCommandUtterance = true
currentUtteranceId = issued
traceState(
"clipboard.resume.rehydratedLive",
extra: "utterance=\(issued.uuidString.prefix(8))"
)
}
// Prefer adopting an in-flight host utterance (keeps one wire start).
FlowSessionBridge.reloadFromDisk()
if let ready = FlowSessionBridge.readySnapshot(),
ready.reason == .recording || ready.reason == .processing {
adoptHostBusyStateIfNeeded(snapshot: ready)
}
let preparingPhase: ClipboardPreparingPhase = {
switch state.phase {
case .idle: return .idle
case .denied: return .denied
case .error: return .error
case .requestingPermissions: return .requestingPermissions
case .recording: return .recording
case .processing: return .processing
}
}()
switch ClipboardPreparingPolicy.restoreAction(
hasStartIssued: hasIssued,
phase: preparingPhase
) {
case .awaitExistingStart:
isClipboardCommandUtterance = true
clipboardAwaitingHostRecordConfirm = true
clipboardPreparingStartedAt = Date().timeIntervalSince1970
state.phase = .requestingPermissions
state.lastTranscript = ExtL10n.string("keyboard.placeholder.preparingRecording")
publishClipboardUIState()
scheduleClipboardPreparingWatchdog()
ensureClipboardStartCommandWritten()
recoverClipboardPreparingIfHostMovedOn()
traceState("clipboard.resume.awaitExistingStart")
case .resumeIntent:
currentUtteranceId = intent.id
isClipboardCommandUtterance = true
clipboardAwaitingHostRecordConfirm = true
state.phase = .requestingPermissions
state.lastTranscript = ExtL10n.string("keyboard.placeholder.preparingRecording")
publishClipboardUIState()
if intent.snapshot?.isEmpty == false {
continueClipboardIntent(intentId: intent.id)
} else {
isAcquiringClipboardPaste = true
scheduleClipboardAcquisition(intentId: intent.id)
}
traceState("clipboard.resume.intent", extra: "intent=\(intent.id.uuidString.prefix(8))")
case .refreshOnly:
publishClipboardUIState()
if clipboardAwaitingHostRecordConfirm {
ensureClipboardStartCommandWritten()
}
recoverClipboardPreparingIfHostMovedOn()
}
}
/// Explicit tap-to-stop for an in-flight clipboard-command utterance.
func requestClipboardStop() {
guard isClipboardCommandUtterance else { return }
switch ClipboardPreparingPolicy.stopWhilePreparing(
awaitingHostConfirm: clipboardAwaitingHostRecordConfirm
) {
case .abortPreparing:
abortClipboardPreparing(reason: "userCancel", message: nil)
return
case .requestStop:
break
}
clipboardStopRequested = true
if let confirmedAt = clipboardHostRecordConfirmedAt {
let elapsed = Date().timeIntervalSince1970 - confirmedAt
let minimum = ClipboardMaterialFilter.minimumRecordingAfterHostConfirm
if elapsed < minimum {
scheduleClipboardDeferredStop(after: minimum - elapsed)
traceState(
"clipboard.stop.deferred",
extra: "reason=minRecording remaining=\(String(format: "%.2f", minimum - elapsed))"
)
return
}
}
pressEnded()
}
/// Cancel any non-recording clipboard stage and delete the persisted intent.
private func cancelClipboardIntent(reason: String) {
clipboardAcquisitionTask?.cancel()
clipboardAcquisitionTask = nil
if isFlowRecording || isAwaitingFlowResult {
writeCommand(.abort)
isFlowRecording = false
isAwaitingFlowResult = false
stopUtteranceCountdown()
ExtensionScreenWakeLock.release()
}
isPendingFlowStart = false
recordAfterHandoff = false
recordWhenHostReady = false
flowStartDeadline = 0
stopFlowWatchdog()
stopHostReadyWait()
FlowSessionBridge.setPendingKeyboardUtteranceId(nil)
currentUtteranceId = nil
resetClipboardUtteranceState()
state.phase = .idle
state.lastTranscript = ""
recomputeMicVoiceAvailability()
traceState("clipboard.intent.cancelled", extra: reason)
}
/// Idle affordance only: metadata `hasStrings`. Never reads pasteboard contents.
func setClipboardContentReadsEnabled(_ enabled: Bool) {
// Height-lock gate retained as a refresh hook after presentation; content
// reads are no longer tied to this flag.
if enabled {
refreshClipboardEligibility()
}
}
func refreshClipboardEligibility() {
let secure = fieldContextProvider()?.isSecureEntry == true
guard hasFullAccess(), !secure else {
state.clipboardCommandEligible = false
publishClipboardUIState()
return
}
// Sticky snapshot (after cold-start) keeps long-press affordance even if
// the pasteboard was cleared while the user was in the host app.
let hasStickyMaterial = !(ClipboardCommandResume.pendingSnapshot() ?? "").isEmpty
|| !(clipboardFrozenSnapshot ?? "").isEmpty
state.clipboardCommandEligible =
ClipboardPasteboardReader.hasStrings() || hasStickyMaterial
publishClipboardUIState()
}
private func resetClipboardUtteranceState() {
clipboardAcquisitionTask?.cancel()
clipboardAcquisitionTask = nil
clipboardDeferredStopTask?.cancel()
clipboardDeferredStopTask = nil
clipboardPreparingWatchdogTask?.cancel()
clipboardPreparingWatchdogTask = nil
clipboardPreparingStartedAt = 0
clipboardFrozenSnapshot = nil
isClipboardCommandUtterance = false
isAcquiringClipboardPaste = false
clipboardAwaitingHostRecordConfirm = false
clipboardHostRecordConfirmedAt = nil
clipboardStopRequested = false
ClipboardCommandResume.clear()
publishClipboardUIState()
}
private func publishClipboardUIState() {
state.clipboardCommandUtteranceActive =
isClipboardCommandUtterance || isAcquiringClipboardPaste
// Blue chrome only after host-confirmed capture — preparing stays grey.
state.clipboardCommandRecording =
isClipboardCommandUtterance
&& state.phase == .recording
&& !clipboardAwaitingHostRecordConfirm
}
private func showClipboardFailure(_ failure: ClipboardCommandFailure) {
ClipboardCommandResume.clear()
isClipboardCommandUtterance = false
isAcquiringClipboardPaste = false
clipboardFrozenSnapshot = nil
publishClipboardUIState()
state.clipboardFailureHint = ExtL10n.string(failure.localizationKey)
traceState("clipboard.rejected", extra: failure.localizationKey)
clipboardFailureHintTask?.cancel()
let duration = ClipboardMaterialFilter.failureHintDuration
clipboardFailureHintTask = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000))
guard let self, !Task.isCancelled else { return }
self.clearClipboardFailureHint()
}
}
private func clearClipboardFailureHint() {
clipboardFailureHintTask?.cancel()
clipboardFailureHintTask = nil
if state.clipboardFailureHint != nil {
state.clipboardFailureHint = nil
}
}
/// Promote preparing → recording when host publishes real capture; honor deferred stop.
private func noteClipboardHostRecordingConfirmed() {
let now = Date().timeIntervalSince1970
if clipboardAwaitingHostRecordConfirm || clipboardHostRecordConfirmedAt == nil {
clipboardAwaitingHostRecordConfirm = false
clipboardPreparingWatchdogTask?.cancel()
clipboardPreparingWatchdogTask = nil
clipboardPreparingStartedAt = 0
if clipboardHostRecordConfirmedAt == nil {
clipboardHostRecordConfirmedAt = now
}
state.phase = .recording
if state.lastTranscript == ExtL10n.string("keyboard.placeholder.preparingRecording")
|| state.lastTranscript == ExtL10n.string("keyboard.placeholder.preparing") {
state.lastTranscript = ""
}
publishClipboardUIState()
KeyboardHapticFeedback.play(role: .action, intensity: state.keyboardHapticIntensity)
traceState("clipboard.hostRecording.confirmed")
}
if clipboardStopRequested {
let confirmedAt = clipboardHostRecordConfirmedAt ?? now
let elapsed = now - confirmedAt
let minimum = ClipboardMaterialFilter.minimumRecordingAfterHostConfirm
if elapsed >= minimum {
pressEnded()
} else {
scheduleClipboardDeferredStop(after: minimum - elapsed)
}
}
}
private func scheduleClipboardDeferredStop(after delay: TimeInterval) {
clipboardDeferredStopTask?.cancel()
let seconds = max(0.05, delay)
clipboardDeferredStopTask = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
guard let self, !Task.isCancelled else { return }
guard self.isClipboardCommandUtterance, self.clipboardStopRequested else { return }
guard self.isFlowRecording else { return }
self.pressEnded()
}
}
private func scheduleClipboardPreparingWatchdog() {
clipboardPreparingWatchdogTask?.cancel()
let timeout = ClipboardCommandResume.preparingTimeout
clipboardPreparingWatchdogTask = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
guard let self, !Task.isCancelled else { return }
guard self.isClipboardCommandUtterance, self.clipboardAwaitingHostRecordConfirm else { return }
self.abortClipboardPreparing(
reason: "watchdog",
message: ExtL10n.string(ClipboardCommandFailure.prepareFailed.localizationKey)
)
}
}
/// Leave「准备录音…」without waiting for a host confirm that never arrives.
private func abortClipboardPreparing(reason: String, message: String?) {
guard isClipboardCommandUtterance, clipboardAwaitingHostRecordConfirm else { return }
clipboardPreparingWatchdogTask?.cancel()
clipboardPreparingWatchdogTask = nil
clipboardPreparingStartedAt = 0
clipboardAwaitingHostRecordConfirm = false
clipboardStopRequested = false
clipboardDeferredStopTask?.cancel()
clipboardDeferredStopTask = nil
if isFlowRecording {
writeCommand(.abort)
isFlowRecording = false
stopUtteranceCountdown()
ExtensionScreenWakeLock.release()
}
stopFlowWatchdog()
FlowSessionBridge.setPendingKeyboardUtteranceId(nil)
lastStoppedUtteranceId = currentUtteranceId
currentUtteranceId = nil
resetClipboardUtteranceState()
if let message, !message.isEmpty {
state.clipboardFailureHint = message
clipboardFailureHintTask?.cancel()
let duration = ClipboardMaterialFilter.failureHintDuration
clipboardFailureHintTask = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000))
guard let self, !Task.isCancelled else { return }
self.clearClipboardFailureHint()
}
state.phase = .idle
state.lastTranscript = ""
} else {
state.phase = .idle
state.lastTranscript = ""
}
recomputeMicVoiceAvailability()
traceState("clipboard.preparing.aborted", extra: reason)
}
/// If we already sent start and host is recording our utterance, confirm UI.
private func promoteClipboardRecordingFromSnapshotIfNeeded() {
guard isClipboardCommandUtterance, clipboardAwaitingHostRecordConfirm else { return }
guard let snapshot = FlowSessionBridge.readySnapshot(),
snapshot.reason == .recording,
let busyId = snapshot.busyUtteranceId,
busyId == currentUtteranceId else { return }
noteClipboardHostRecordingConfirmed()
}
/// Host finished/failed/took another utterance while UI still shows「准备录音…」.
private func recoverClipboardPreparingIfHostMovedOn() {
let hostReason: ClipboardHostBusyReason? = {
switch FlowSessionBridge.readySnapshot()?.reason {
case .recording: return .recording
case .processing: return .processing
default: return nil
}
}()
let action = ClipboardPreparingPolicy.recoverWhilePreparing(
awaitingHostConfirm: clipboardAwaitingHostRecordConfirm,
currentUtteranceId: currentUtteranceId,
hostBusyUtteranceId: FlowSessionBridge.readySnapshot()?.busyUtteranceId,
hostReason: hostReason,
hasTerminalFailureForCurrent: {
guard let result = matchingResult() else { return false }
return isTerminalFailure(result)
}()
)
switch action {
case .none, .wait:
return
case .abortForHostFailure:
if let result = matchingResult(), isTerminalFailure(result) {
FlowSessionBridge.writeAck(
FlowAck(
sessionId: result.sessionId,
utteranceId: result.utteranceId,
commandSeq: result.commandSeq,
hostGeneration: result.hostGeneration,
revision: result.revision
)
)
lastConsumedUtteranceId = result.utteranceId
abortClipboardPreparing(
reason: "hostTerminalFailure",
message: result.text
?? ExtL10n.string(ClipboardCommandFailure.prepareFailed.localizationKey)
)
} else {
abortClipboardPreparing(
reason: "hostTerminalFailure",
message: ExtL10n.string(ClipboardCommandFailure.prepareFailed.localizationKey)
)
}
case .confirmRecording:
noteClipboardHostRecordingConfirmed()
case .adoptSibling(let busyId):
currentUtteranceId = busyId
FlowSessionBridge.setPendingKeyboardUtteranceId(busyId)
ClipboardCommandResume.markStartIssued(busyId)
let snapshot = FlowSessionBridge.readySnapshot()
isFlowRecording = snapshot?.reason == .recording
if snapshot?.reason == .recording {
noteClipboardHostRecordingConfirmed()
} else {
clipboardAwaitingHostRecordConfirm = false
clipboardPreparingWatchdogTask?.cancel()
clipboardPreparingWatchdogTask = nil
state.phase = .processing
state.lastTranscript = ExtL10n.string("keyboard.flow.transcribing")
publishClipboardUIState()
startFlowResultWatchdog()
}
traceState(
"clipboard.preparing.adoptSibling",
extra: "busy=\(busyId.uuidString.prefix(8)) reason=\(snapshot?.reason.rawValue ?? "nil")"
)
}
}
/// After restore / claim: write at most one startRecording for the issued utterance.
private func ensureClipboardStartCommandWritten() {
guard isClipboardCommandUtterance else { return }
// The persisted intent id is the one and only utterance id. `startIssued`
// is committed only immediately before the wire command is written.
let issued = ClipboardCommandResume.currentIntent()?.id
let snapshot = FlowSessionBridge.readySnapshot()
let hostReason: ClipboardHostBusyReason? = {
switch snapshot?.reason {
case .recording: return .recording
case .processing: return .processing
default: return nil
}
}()
let action = ClipboardPreparingPolicy.ensureStartAction(
issuedUtteranceId: issued,
isFlowRecording: isFlowRecording,
currentUtteranceId: currentUtteranceId,
hostBusyUtteranceId: snapshot?.busyUtteranceId,
hostReason: hostReason,
hostReadyWithSession: snapshot?.sessionId != nil && FlowSessionBridge.isHostReady()
)
switch action {
case .none:
return
case .alreadyInFlight:
return
case .adoptBusy(let busyId, let reason):
currentUtteranceId = busyId
FlowSessionBridge.setPendingKeyboardUtteranceId(busyId)
ClipboardCommandResume.markStartIssued(busyId)
if reason == .recording {
isFlowRecording = true
noteClipboardHostRecordingConfirmed()
}
return
case .waitForHost:
recordWhenHostReady = true
startHostReadyWaitIfNeeded()
traceState("clipboard.ensureStart.waitHost")
return
case .writeStart(let utteranceId):
guard let sessionId = snapshot?.sessionId else {
recordWhenHostReady = true
startHostReadyWaitIfNeeded()
return
}
activeSessionId = sessionId
currentUtteranceId = utteranceId
FlowSessionBridge.setPendingKeyboardUtteranceId(utteranceId)
lastStoppedUtteranceId = nil
ClipboardCommandResume.markStartIssued(utteranceId)
writeCommand(.startRecording)
isFlowRecording = true
clipboardAwaitingHostRecordConfirm = true
if clipboardPreparingStartedAt <= 0 {
clipboardPreparingStartedAt = Date().timeIntervalSince1970
}
clipboardHostRecordConfirmedAt = nil
state.phase = .requestingPermissions
state.lastTranscript = ExtL10n.string("keyboard.placeholder.preparingRecording")
publishClipboardUIState()
scheduleClipboardPreparingWatchdog()
if let view = wakeLockView() {
ExtensionScreenWakeLock.acquire(from: view)
}
startUtteranceCountdown()
startFlowLevelWatchdog()
recomputeMicVoiceAvailability()
traceState(
"clipboard.ensureStart.written",
extra: "utterance=\(utteranceId.uuidString.prefix(8))"
)
}
}
func pressBegan() {
switch state.phase {
case .idle, .denied, .error:
break
default:
return
}
guard !isPendingFlowStart else { return }
recomputeMicVoiceAvailability()
switch state.micVoiceAvailability {
case .unavailable(.onboardingIncomplete):
promptFinishSetupInApp()
return
case .unavailable(.missingAPIKey):
return
case .unavailable(.noFullAccess):
let msg = ExtL10n.string("keyboard.error.fullAccessRequired")
state.phase = .error(.fullAccessRequired, message: msg)
scheduleAutoClearError()
recomputeMicVoiceAvailability()
return
case .unavailable(.appGroupUnavailable):
let msg = ExtL10n.string("keyboard.error.appGroupCommunication")
state.phase = .error(.appGroupUnavailable, message: msg)
scheduleAutoClearError()
recomputeMicVoiceAvailability()
return
default:
break
}
let withinReadyGrace = lastHostReadyAt > 0
&& (Date().timeIntervalSince1970 - lastHostReadyAt) <= Self.hostReadyGrace
let action = FlowHandoffPolicy.micPressAction(
availability: state.micVoiceAvailability,
sessionActive: FlowSessionBridge.isSessionActive(),
hostReachable: FlowSessionBridge.isHostReachable(),
hostStale: FlowSessionBridge.isHostStale(),
withinReadyGrace: withinReadyGrace
)
switch action {
case .startRecording:
detectAndStoreAppContext()
startFlowRecording()
case .waitForHostReady(let recordWhenReady):
detectAndStoreAppContext()
if isClipboardCommandUtterance {
deferClipboardUntilHostWarm(reason: "pressBegan.waitHost")
recordWhenHostReady = true
startHostReadyWaitIfNeeded()
showClipboardHostWarmupHint()
traceState("pressBegan.clipboardAutoResume", extra: "waitHost")
break
}
recordWhenHostReady = recordWhenReady
coldStartDebouncer.reset()
startHostReadyWaitIfNeeded()
traceState(
"pressBegan.waitForHostReady",
extra: recordWhenReady ? "recordWhenReady=1" : "recordWhenReady=0"
)
case .openHostColdStart:
detectAndStoreAppContext()
if isClipboardCommandUtterance {
deferClipboardUntilHostWarm(reason: "pressBegan.coldStart")
if !isPendingFlowStart {
beginFlowStart(recordAfterHandoff: true)
}
showClipboardHostWarmupHint()
traceState("pressBegan.clipboardAutoResume", extra: "coldStart")
break
}
beginFlowStart(recordAfterHandoff: true)
case .ignore:
return
}
}
func pressEnded() {
if isPendingFlowStart {
cancelPendingFlowStart()
return
}
guard isFlowRecording else { return }
clipboardDeferredStopTask?.cancel()
clipboardDeferredStopTask = nil
clipboardAwaitingHostRecordConfirm = false
clipboardStopRequested = false
isFlowRecording = false
stopUtteranceCountdown()
ExtensionScreenWakeLock.release()
lastStoppedUtteranceId = currentUtteranceId
writeCommand(.stopRecording)
debug("pressEnded wrote stop command")
state.phase = .processing
state.lastTranscript = ExtL10n.string("keyboard.flow.transcribing")
startFlowResultWatchdog()
recomputeMicVoiceAvailability()
publishClipboardUIState()
}
func beginFlowStart(recordAfterHandoff: Bool = false) {
guard state.hasCompletedOnboarding else {
promptFinishSetupInApp()
return
}
guard !isPendingFlowStart else {
traceState("beginFlowStart.ignored", extra: "reason=pendingAlreadyTrue")
return
}
self.recordAfterHandoff = recordAfterHandoff
recordWhenHostReady = false
coldStartDebouncer.reset()
isPendingFlowStart = true
isFlowRecording = false
flowStartDeadline = Date().timeIntervalSince1970 + FlowWatchdog.startTimeout
state.lastTranscript = ""
recomputeMicVoiceAvailability()
FlowSessionBridge.markPiPArmAttempt()
OSGDiag.log(
"beginFlowStart → openHostApp(startflow) recordAfterHandoff=\(recordAfterHandoff) "
+ "\(OSGDiag.memoryTag())",
category: "boot"
)
openHostApp("startflow")
startFlowStartWatchdog()
traceState(
"beginFlowStart.started",
extra: recordAfterHandoff ? "recordAfterHandoff=1" : "recordAfterHandoff=0"
)
}
func handleHostAppOpenResult(path: String, success: Bool) {
traceState("openHostApp.result", extra: "path=\(path) success=\(success)")
guard !success else { return }
// The open genuinely failed (iOS blocked it / no Full Access). Don't
// let the 30s watchdog spin — cancel the pending start immediately and
// guide the user to open OSGKeyboard manually.
if path == "startflow", isPendingFlowStart {
isPendingFlowStart = false
recordAfterHandoff = false
flowStartDeadline = 0
stopFlowWatchdog()
traceState("openHostApp.failed", extra: "path=startflow cancelPending=1")
showManualOpenHint(path: "startflow")
recomputeMicVoiceAvailability()
return
}
showManualOpenHint(path: path)
}
func cancelPipelineUnlessAwaitingResult() {
guard !isAwaitingFlowResult else { return }
// A clipboard intent is explicitly persisted to survive pressure,
// paste-consent suspension, and extension recreation.
guard !isClipboardCommandActive else { return }
if isFlowRecording || isPendingFlowStart {
if isFlowRecording {
writeCommand(.abort)
ExtensionScreenWakeLock.release()
}
currentUtteranceId = nil
lastStoppedUtteranceId = nil
isFlowRecording = false
isPendingFlowStart = false
recordAfterHandoff = false
stopUtteranceCountdown()
stopFlowWatchdog()
state.level = 0
recomputeMicVoiceAvailability()
}
resetClipboardUtteranceState()
}
// MARK: - Private
private func nextCommandSeq() -> Int64 {
let millis = Int64(Date().timeIntervalSince1970 * 1_000)
currentCommandSeq = max(currentCommandSeq + 1, millis)
return currentCommandSeq
}
private func writeCommand(_ action: FlowCommand.Action) {
guard let activeSessionId, let currentUtteranceId else { return }
let mode: FlowUtteranceMode? = isClipboardCommandUtterance ? .clipboardCommand : nil
let snapshot: String? = {
guard isClipboardCommandUtterance, action == .startRecording else { return nil }
return clipboardFrozenSnapshot
}()
let command = FlowCommand(
sessionId: activeSessionId,
utteranceId: currentUtteranceId,
commandSeq: nextCommandSeq(),
action: action,
localeId: state.localeId,
fieldContext: action == .stopRecording ? fieldContextProvider() : nil,
utteranceMode: mode,
clipboardSnapshot: snapshot,
previousOutput: nil
)
FlowSessionBridge.writeCommand(command)
debug(
"command \(action.rawValue) seq=\(command.commandSeq) " +
"utterance=\(currentUtteranceId.uuidString) mode=\(mode?.rawValue ?? "dictation") contextChars=" +
"\(command.fieldContext?.precedingText?.count ?? 0)/" +
"\(command.fieldContext?.followingText?.count ?? 0)"
)
// Start of one traceable utterance: everything the host logs afterwards
// belongs to this `utterance=` id until the matching keyboard.insert.
FlowTrace.keyboard(
"command.\(action.rawValue)",
"seq=\(command.commandSeq) utterance=\(currentUtteranceId.uuidString.prefix(8)) "
+ "locale=\(state.localeId) engine=\(state.engineMode) "
+ "hostReady=\(FlowSessionBridge.isHostReady() ? 1 : 0) "
+ "mode=\(mode?.rawValue ?? "dictation")"
)
}
private func consumePendingFlowDeliveryIfNeeded() {
if isAwaitingFlowResult {
if let result = matchingResult(), result.status == .final, let text = result.text, !text.isEmpty {
isAwaitingFlowResult = false
stopFlowWatchdog()
let wasClipboard = result.resolvedUtteranceMode == .clipboardCommand
|| isClipboardCommandUtterance
// Each clipboard round is independent — always append/insert, never replace.
textInserter.handleFlowTranscript(
TranscriptionDelivery(text: text, polishWarning: result.warning)
)
resetClipboardUtteranceState()
FlowSessionBridge.writeAck(
FlowAck(
sessionId: result.sessionId,
utteranceId: result.utteranceId,
commandSeq: result.commandSeq,
hostGeneration: result.hostGeneration,
revision: result.revision
)
)
FlowSessionBridge.setPendingKeyboardUtteranceId(nil)
lastConsumedUtteranceId = result.utteranceId
lastStoppedUtteranceId = nil
currentUtteranceId = nil
FlowTrace.transcript(
"keyboard.insert",
text,
"utterance=\(result.utteranceId.uuidString.prefix(8)) "
+ "commandSeq=\(result.commandSeq) warning=\(result.warning == nil ? 0 : 1) "
+ "clipboard=\(wasClipboard ? 1 : 0)"
)
recomputeMicVoiceAvailability()
return
}
if let result = matchingResult(), isTerminalFailure(result) {
FlowTrace.warn(
"keyboard.resultFailed",
"status=\(result.status.rawValue) "
+ "kind=\(result.errorKind?.rawValue ?? "none") "
+ "utterance=\(result.utteranceId.uuidString.prefix(8)) "
+ "message=\(result.text ?? "nil")"
)
isAwaitingFlowResult = false
stopFlowWatchdog()
resetClipboardUtteranceState()
FlowSessionBridge.writeAck(
FlowAck(
sessionId: result.sessionId,
utteranceId: result.utteranceId,
commandSeq: result.commandSeq,
hostGeneration: result.hostGeneration,
revision: result.revision
)
)
FlowSessionBridge.setPendingKeyboardUtteranceId(nil)
lastConsumedUtteranceId = result.utteranceId
lastStoppedUtteranceId = nil
currentUtteranceId = nil
let error = FlowTranscriptionError(
message: result.text ?? ExtL10n.string("keyboard.flow.resultTimeout"),
kind: result.errorKind ?? .generic
)
state.phase = .error(
.fromFlowTranscription(error),
message: error.message
)
scheduleAutoClearError()
recomputeMicVoiceAvailability()
return
}
}
if isPendingFlowStart, FlowSessionBridge.isHostReady() {
completeFlowStartHandoff()
}
}
private func adoptPendingResultIfNeeded() {
guard !isAwaitingFlowResult, currentUtteranceId == nil,
let pendingId = FlowSessionBridge.pendingKeyboardUtteranceId(),
let result = FlowSessionBridge.latestResult(),
result.utteranceId == pendingId,
result.status == .final || isTerminalFailure(result) else {
return
}
let currentField = fieldContextProvider()
if let expected = result.fieldFingerprint,
let current = currentField?.deliveryFingerprint,
expected != current {
if let text = result.text,
currentField?.precedingText?.hasSuffix(text) == true {
FlowSessionBridge.writeAck(
FlowAck(
sessionId: result.sessionId,
utteranceId: result.utteranceId,
commandSeq: result.commandSeq,
hostGeneration: result.hostGeneration,
revision: result.revision
)
)
FlowSessionBridge.setPendingKeyboardUtteranceId(nil)
lastConsumedUtteranceId = result.utteranceId
traceState(
"pendingResult.acknowledged",
extra: "reason=textAlreadyPresent"
)
return
}
traceState(
"pendingResult.deferred",
extra: "reason=fieldFingerprintMismatch"
)
return
}
activeSessionId = result.sessionId
currentUtteranceId = result.utteranceId
isAwaitingFlowResult = true
state.phase = .processing
traceState(
"pendingResult.adopted",
extra: "utterance=\(pendingId.uuidString.prefix(8))"
)
}
private func matchingResult() -> FlowResult? {
FlowKeyboardResultMatcher.matchingResult(
latest: FlowSessionBridge.latestResult(),
activeSessionId: activeSessionId,
currentUtteranceId: currentUtteranceId,
currentHostGeneration: FlowSessionBridge.currentHostGeneration()
)
}
private func isTerminalFailure(_ result: FlowResult) -> Bool {
FlowKeyboardResultMatcher.isTerminalFailure(result)
}
/// When the host process died mid-utterance, abort local recording / waiting
/// so the user is not stuck until the long result watchdog fires.
private func recoverFromDeadHostIfNeeded() {
guard FlowSessionBridge.isHostStale() else { return }
if isFlowRecording {
isFlowRecording = false
stopUtteranceCountdown()
ExtensionScreenWakeLock.release()
writeCommand(.abort)
currentUtteranceId = nil
lastStoppedUtteranceId = nil
stopFlowWatchdog()
state.level = 0
state.phase = .idle
state.lastTranscript = ""
recomputeMicVoiceAvailability()
debug("aborted recording — host heartbeat zombie")
return
}
if isAwaitingFlowResult {
failHostDisconnected()
}
}
private func failHostDisconnected() {
if deliverRawFallbackIfAvailable(reason: "hostDisconnected") {
return
}
traceState("hostDisconnected.fail")
isAwaitingFlowResult = false
isFlowRecording = false
isPendingFlowStart = false
recordAfterHandoff = false
stopUtteranceCountdown()
ExtensionScreenWakeLock.release()
writeCommand(.abort)
currentUtteranceId = nil
lastStoppedUtteranceId = nil
stopFlowWatchdog()
state.level = 0
let message = ExtL10n.string("keyboard.flow.hostDisconnected")
state.phase = .error(.flowSessionExpired, message: message)
scheduleAutoClearError()
recomputeMicVoiceAvailability()
debug("host disconnected while awaiting Flow result")
}
@discardableResult
private func deliverRawFallbackIfAvailable(reason: String) -> Bool {
FlowSessionBridge.reloadFromDisk()
guard let result = matchingResult(),
result.allowsRawFallback,
result.status == .partial
|| result.status == .rawReady
|| (result.status == .final && result.rawText != nil),
let raw = (result.rawText ?? result.text)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!raw.isEmpty else {
return false
}
textInserter.handleFlowTranscript(
TranscriptionDelivery(text: raw, polishWarning: nil)
)
FlowSessionBridge.writeAck(
FlowAck(
sessionId: result.sessionId,
utteranceId: result.utteranceId,
commandSeq: result.commandSeq,
hostGeneration: result.hostGeneration,
revision: nil
)
)
FlowSessionBridge.setPendingKeyboardUtteranceId(nil)
lastConsumedUtteranceId = result.utteranceId
lastStoppedUtteranceId = nil
currentUtteranceId = nil
isAwaitingFlowResult = false
isFlowRecording = false
stopFlowWatchdog()
state.level = 0
state.phase = .idle
state.lastTranscript = ""
recomputeMicVoiceAvailability()
FlowTrace.transcript(
"keyboard.insert",
raw,
"via=rawFallback reason=\(reason) utterance=\(result.utteranceId.uuidString.prefix(8))"
)
return true
}
private func showFlowSessionExpiredHint() {
let message = ExtL10n.string("keyboard.flow.sessionExpired")
state.phase = .error(.flowSessionExpired, message: message)
scheduleAutoClearError()
recomputeMicVoiceAvailability()
}
private func showManualOpenHint(path: String) {
let msg: String
if !hasFullAccess() {
msg = ExtL10n.string("keyboard.error.fullAccessForJump")
} else if path == "settings" {
msg = ExtL10n.string("keyboard.error.manualOpenSettings")
} else if path == "startflow" {
msg = ExtL10n.string("keyboard.error.manualOpenForFlow")
} else {
msg = ExtL10n.string("keyboard.error.manualOpenSettings")
}
state.phase = .error(.manualOpenRequired, message: msg)
scheduleAutoClearError()
recomputeMicVoiceAvailability()
}
/// Scheme C: voice needs host-app setup; typing stays available.
private func promptFinishSetupInApp() {
let msg = ExtL10n.string("keyboard.hint.finishSetupInApp")
state.phase = .error(.manualOpenRequired, message: msg)
scheduleAutoClearError()
recomputeMicVoiceAvailability()
openHostApp("settings")
traceState("onboarding.incomplete", extra: "action=openHostApp(settings)")
}
private func startFlowRecording() {
recomputeMicVoiceAvailability()
let withinReadyGrace = lastHostReadyAt > 0
&& (Date().timeIntervalSince1970 - lastHostReadyAt) <= Self.hostReadyGrace
if !state.micVoiceAvailability.isReady {
let action = FlowHandoffPolicy.micPressAction(
availability: state.micVoiceAvailability,
sessionActive: FlowSessionBridge.isSessionActive(),
hostReachable: FlowSessionBridge.isHostReachable(),
hostStale: FlowSessionBridge.isHostStale(),
withinReadyGrace: withinReadyGrace
)
traceState(
"startFlowRecording.blocked",
extra: "availability=\(String(describing: state.micVoiceAvailability)) action=\(action)"
)
switch action {
case .waitForHostReady(let recordWhenReady):
if isClipboardCommandUtterance {
deferClipboardUntilHostWarm(reason: "startFlowRecording.waitHost")
showClipboardHostWarmupHint()
return
}
recordWhenHostReady = recordWhenReady
startHostReadyWaitIfNeeded()
case .openHostColdStart:
if isClipboardCommandUtterance {
deferClipboardUntilHostWarm(reason: "startFlowRecording.coldStart")
if !isPendingFlowStart {
beginFlowStart(recordAfterHandoff: true)
}
showClipboardHostWarmupHint()
return
}
beginFlowStart(recordAfterHandoff: true)
case .startRecording, .ignore:
break
}
return
}
isPendingFlowStart = false
flowStartDeadline = 0
stopFlowWatchdog()
guard let sessionId = FlowSessionBridge.readySnapshot()?.sessionId else {
traceState("startFlowRecording.blocked", extra: "reason=missingSessionIdInReadySnapshot")
// Snapshot lag with a live session → wait; only cold-start if host is dead.
if FlowHandoffPolicy.shouldOpenHostColdStart(
sessionActive: FlowSessionBridge.isSessionActive(),
hostReachable: FlowSessionBridge.isHostReachable(),
hostStale: FlowSessionBridge.isHostStale(),
withinReadyGrace: withinReadyGrace
) {
if isClipboardCommandUtterance {
deferClipboardUntilHostWarm(reason: "startFlowRecording.missingSession.coldStart")
if !isPendingFlowStart {
beginFlowStart(recordAfterHandoff: true)
}
showClipboardHostWarmupHint()
} else {
beginFlowStart(recordAfterHandoff: true)
}
} else if isClipboardCommandUtterance {
deferClipboardUntilHostWarm(reason: "startFlowRecording.missingSession.wait")
showClipboardHostWarmupHint()
} else {
recordWhenHostReady = true
startHostReadyWaitIfNeeded()
}
return
}
// Clipboard: never send a second startRecording for the same round
// (paste-alert restore used to call pressBegan again → stuck「准备录音…」).
if isClipboardCommandUtterance {
if isFlowRecording {
scheduleClipboardPreparingWatchdog()
recoverClipboardPreparingIfHostMovedOn()
recomputeMicVoiceAvailability()
traceState("startFlowRecording.deduped", extra: "reason=alreadyInFlight")
return
}
ensureClipboardStartCommandWritten()
return
}
activeSessionId = sessionId
currentUtteranceId = UUID()
FlowSessionBridge.setPendingKeyboardUtteranceId(currentUtteranceId)
lastStoppedUtteranceId = nil
writeCommand(.startRecording)
isFlowRecording = true
clipboardAwaitingHostRecordConfirm = false
clipboardHostRecordConfirmedAt = nil
clipboardStopRequested = false
state.lastTranscript = ""
state.phase = .recording
recomputeMicVoiceAvailability()
if let view = wakeLockView() {
ExtensionScreenWakeLock.acquire(from: view)
}
startUtteranceCountdown()
startFlowLevelWatchdog()
traceState("startFlowRecording.started", extra: "clipboardPreparing=0")
}
private func startUtteranceCountdown() {
utteranceStartedAt = Date().timeIntervalSince1970
state.utteranceRemainingSeconds = Int(FlowSessionKeys.maxUtteranceDuration)
utteranceTimerTask?.cancel()
utteranceTimerTask = Task { @MainActor [weak self] in
while let self, self.isFlowRecording, !Task.isCancelled {
let elapsed = Date().timeIntervalSince1970 - self.utteranceStartedAt
let remaining = max(0, Int(ceil(FlowSessionKeys.maxUtteranceDuration - elapsed)))
self.state.utteranceRemainingSeconds = remaining
if remaining <= 0 {
self.pressEnded()
return
}
try? await Task.sleep(nanoseconds: 200_000_000)
}
}
}
private func stopUtteranceCountdown() {
utteranceTimerTask?.cancel()
utteranceTimerTask = nil
state.utteranceRemainingSeconds = Int(FlowSessionKeys.maxUtteranceDuration)
}
private func cancelPendingFlowStart() {
isPendingFlowStart = false
recordAfterHandoff = false
recordWhenHostReady = false
flowStartDeadline = 0
coldStartDebouncer.reset()
stopFlowWatchdog()
stopHostReadyWait()
state.phase = .idle
state.lastTranscript = ""
recomputeMicVoiceAvailability()
traceState("pendingStart.cancelledByUser")
}
private func startFlowStartWatchdog() {
stopFlowWatchdog()
flowWatchdogTask = Task { @MainActor [weak self] in
while let self, !Task.isCancelled, self.isPendingFlowStart {
self.recomputeMicVoiceAvailability()
if FlowSessionBridge.isHostReady() {
self.completeFlowStartHandoff()
return
}
let now = Date().timeIntervalSince1970
if self.flowStartDeadline > 0, now > self.flowStartDeadline {
self.isPendingFlowStart = false
self.recordAfterHandoff = false
self.flowStartDeadline = 0
self.traceState("startWatchdog.timeout")
if self.isClipboardCommandActive {
self.cancelClipboardIntent(reason: "hostWarmTimeout")
self.showClipboardFailure(.prepareFailed)
} else {
self.showManualOpenHint(path: "startflow")
}
return
}
try? await Task.sleep(nanoseconds: FlowWatchdog.pollIntervalNs)
}
}
}
private func completeFlowStartHandoff() {
let shouldRecord = recordAfterHandoff
isPendingFlowStart = false
recordAfterHandoff = false
flowStartDeadline = 0
stopFlowWatchdog()
state.lastTranscript = ""
refreshSessionState()
if shouldRecord {
startFlowRecording()
traceState("completeFlowStartHandoff.done", extra: "record=1")
} else {
recomputeMicVoiceAvailability()
traceState("completeFlowStartHandoff.done", extra: "record=0 warmOnly")
}
}
private func startFlowLevelWatchdog() {
stopFlowWatchdog()
flowWatchdogTask = Task { @MainActor [weak self] in
while let self, !Task.isCancelled, self.isFlowRecording {
let levels = FlowSessionBridge.audioLevels()
if let peak = levels.max(), peak > 0 {
self.state.level = Double(peak)
}
self.refreshFlowPartialIfNeeded()
let staleness = FlowSessionBridge.heartbeatStaleness() ?? .infinity
if staleness > 5 {
self.debug("levelWatchdog: host heartbeat stale while recording")
self.failHostDisconnected()
return
}
try? await Task.sleep(nanoseconds: FlowWatchdog.pollIntervalNs)
}
}
}
private func refreshFlowPartialIfNeeded() {
guard isFlowRecording || isAwaitingFlowResult else { return }
switch state.phase {
case .recording, .processing:
if let result = matchingResult(),
result.status == .partial || result.status == .rawReady,
let partial = result.text,
!partial.isEmpty {
state.lastTranscript = partial
}
default:
break
}
}
private func startFlowResultWatchdog() {
stopFlowWatchdog()
isAwaitingFlowResult = true
let startedAt = Date().timeIntervalSince1970
let resultTimeout = FlowWatchdog.resultTimeout(engineMode: state.engineMode)
debug("resultWatchdog started timeout=\(Int(resultTimeout))s engine=\(state.engineMode)")
flowWatchdogTask = Task { @MainActor [weak self] in
while let self, !Task.isCancelled {
FlowSessionBridge.reloadFromDisk()
if let result = self.matchingResult(), result.status == .final, let text = result.text, !text.isEmpty {
self.isAwaitingFlowResult = false
self.stopFlowWatchdog()
self.textInserter.handleFlowTranscript(
TranscriptionDelivery(text: text, polishWarning: result.warning)
)
FlowSessionBridge.writeAck(
FlowAck(
sessionId: result.sessionId,
utteranceId: result.utteranceId,
commandSeq: result.commandSeq,
hostGeneration: result.hostGeneration,
revision: result.revision
)
)
FlowSessionBridge.setPendingKeyboardUtteranceId(nil)
self.lastConsumedUtteranceId = result.utteranceId
self.lastStoppedUtteranceId = nil
self.currentUtteranceId = nil
self.debug("resultWatchdog consumed delivery len=\(text.count)")
FlowTrace.transcript(
"keyboard.insert",
text,
"via=resultWatchdog utterance=\(result.utteranceId.uuidString.prefix(8)) "
+ "commandSeq=\(result.commandSeq) "
+ "waitedSeconds=\(String(format: "%.2f", Date().timeIntervalSince1970 - startedAt))"
)
return
}
if let result = self.matchingResult(), self.isTerminalFailure(result) {
self.isAwaitingFlowResult = false
self.stopFlowWatchdog()
FlowSessionBridge.writeAck(
FlowAck(
sessionId: result.sessionId,
utteranceId: result.utteranceId,
commandSeq: result.commandSeq,
hostGeneration: result.hostGeneration,
revision: result.revision
)
)
FlowSessionBridge.setPendingKeyboardUtteranceId(nil)
self.lastConsumedUtteranceId = result.utteranceId
self.lastStoppedUtteranceId = nil
self.currentUtteranceId = nil
let error = FlowTranscriptionError(
message: result.text ?? ExtL10n.string("keyboard.flow.resultTimeout"),
kind: result.errorKind ?? .generic
)
self.debug("resultWatchdog consumed error kind=\(error.kind.rawValue)")
FlowTrace.warn(
"keyboard.resultFailed",
"via=resultWatchdog status=\(result.status.rawValue) "
+ "kind=\(error.kind.rawValue) "
+ "utterance=\(result.utteranceId.uuidString.prefix(8)) "
+ "message=\(error.message)"
)
self.state.phase = .error(
.fromFlowTranscription(error),
message: error.message
)
self.scheduleAutoClearError()
self.recomputeMicVoiceAvailability()
return
}
self.refreshFlowPartialIfNeeded()
let now = Date().timeIntervalSince1970
let staleness = FlowSessionBridge.heartbeatStaleness() ?? .infinity
if self.isFlowRecording, staleness > 5 {
self.debug("level/result watchdog: host heartbeat stale while recording")
self.failHostDisconnected()
return
}
if staleness > FlowSessionKeys.heartbeatZombieInterval {
self.debug("resultWatchdog: host heartbeat zombie (staleness=\(String(format: "%.1f", staleness))s)")
self.failHostDisconnected()
return
}
if !FlowSessionBridge.isHostReachable(),
now - startedAt > FlowSessionKeys.keyboardHostDisconnectFailFast {
self.debug("resultWatchdog: host unreachable after \(String(format: "%.1f", now - startedAt))s")
self.failHostDisconnected()
return
}
if now - startedAt > resultTimeout {
if self.deliverRawFallbackIfAvailable(reason: "resultTimeout") {
return
}
self.isAwaitingFlowResult = false
self.stopFlowWatchdog()
self.currentUtteranceId = nil
self.lastStoppedUtteranceId = nil
self.debug("resultWatchdog TIMEOUT after \(Int(resultTimeout))s — no result from host")
let msg = ExtL10n.string("keyboard.flow.resultTimeout")
self.state.phase = .error(.flowResultTimeout, message: msg)
self.scheduleAutoClearError()
self.recomputeMicVoiceAvailability()
return
}
try? await Task.sleep(nanoseconds: FlowWatchdog.pollIntervalNs)
}
}
}
private func stopFlowWatchdog() {
flowWatchdogTask?.cancel()
flowWatchdogTask = nil
}
private func debug(_ message: String) {
OSGLog.keyboardExt.info("\(message, privacy: .public)")
}
private func traceState(_ event: String, extra: String? = nil) {
let staleness = FlowSessionBridge.heartbeatStaleness().map { String(format: "%.1f", $0) } ?? "nil"
let sessionId = activeSessionId?.uuidString ?? "nil"
let utteranceId = currentUtteranceId?.uuidString ?? "nil"
let summary = [
"event=\(event)",
"phase=\(String(describing: state.phase))",
"availability=\(String(describing: state.micVoiceAvailability))",
"pending=\(isPendingFlowStart)",
"recording=\(isFlowRecording)",
"awaiting=\(isAwaitingFlowResult)",
"sessionId=\(sessionId)",
"utteranceId=\(utteranceId)",
"cmdSeq=\(currentCommandSeq)",
"sessionActive=\(FlowSessionBridge.isSessionActive())",
"hostReady=\(FlowSessionBridge.isHostReady())",
"heartbeatStaleness=\(staleness)"
].joined(separator: " ")
if let extra, !extra.isEmpty {
debug("[trace] \(summary) \(extra)")
} else {
debug("[trace] \(summary)")
}
}
}