feat(keyboard): improve typing, voice flow, and polish reliability
Reduce extension memory pressure and delivery races while adding richer candidates, tactile feedback, and safer two-level creative polishing.
This commit is contained in:
@@ -41,6 +41,7 @@ public struct AppGroupPersistor {
|
||||
state.translationTargetLocaleId = store.translationTargetLocaleId
|
||||
state.handednessPreference = store.handednessPreference
|
||||
state.cursorDragNavigationEnabled = store.cursorDragNavigationEnabled
|
||||
state.keyboardHapticIntensity = store.keyboardHapticIntensity
|
||||
state.micDisabled = store.isCloudAPIKeyMissingForVoiceInput
|
||||
state.micDisabledHint = store.isCloudAPIKeyMissingForVoiceInput
|
||||
? ExtL10n.string("keyboard.mic.disabled.missingApiKey")
|
||||
@@ -85,12 +86,15 @@ public struct AppGroupPersistor {
|
||||
guard AppGroup.isAvailable else { return }
|
||||
let store = AppGroupStore()
|
||||
state.engineMode = store.engineMode
|
||||
let shouldProtectTranslation = protectTranslationUntil.map { Date() < $0 } ?? false
|
||||
let shouldProtectTranslation = KeyboardTranslationConfigProtection.shouldProtect(
|
||||
until: protectTranslationUntil
|
||||
)
|
||||
if !shouldProtectTranslation {
|
||||
state.translationTargetLocaleId = store.translationTargetLocaleId
|
||||
}
|
||||
state.handednessPreference = store.handednessPreference
|
||||
state.cursorDragNavigationEnabled = store.cursorDragNavigationEnabled
|
||||
state.keyboardHapticIntensity = store.keyboardHapticIntensity
|
||||
state.micDisabled = store.isCloudAPIKeyMissingForVoiceInput
|
||||
state.micDisabledHint = store.isCloudAPIKeyMissingForVoiceInput
|
||||
? ExtL10n.string("keyboard.mic.disabled.missingApiKey")
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// KeyboardConfigSync.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// App Group config hydration, Darwin observers, and onboarding mirroring.
|
||||
// App Group config hydration, Darwin observers, and onboarding-complete
|
||||
// mirroring (mic gate only — full setup lives in the host app).
|
||||
|
||||
import Foundation
|
||||
import OSGKeyboardShared
|
||||
@@ -74,39 +75,17 @@ final class KeyboardConfigSync {
|
||||
into: state,
|
||||
protectTranslationUntil: translationConfigProtectedUntil
|
||||
)
|
||||
// Host may complete (or reset) onboarding while the extension stays alive.
|
||||
syncOnboardingStateFromAppGroup()
|
||||
}
|
||||
|
||||
/// Mirrors host-app onboarding completion for the mic gate only.
|
||||
/// Does not sync `onboardingPage` — page flow is host-app exclusive.
|
||||
func syncOnboardingStateFromAppGroup() {
|
||||
let store = AppGroupStore()
|
||||
// Fall back to the reboot-durable Keychain marker so a device restart
|
||||
// does not resurrect the in-keyboard onboarding overlay when the App
|
||||
// Group value transiently reads empty.
|
||||
// Keychain fallback: a reboot must not resurrect the mic gate when
|
||||
// App Group transiently reads empty.
|
||||
state.hasCompletedOnboarding = store.hasCompletedOnboarding || Keychain.hasCompletedOnboarding()
|
||||
state.onboardingPage = store.onboardingPage
|
||||
}
|
||||
|
||||
func autoAdvancePastKeyboardSetupStepIfNeeded() {
|
||||
guard !state.hasCompletedOnboarding else { return }
|
||||
guard state.onboardingPage == 3 else { return }
|
||||
guard KeyboardSetupBridge.isReadyForOnboardingSkip else { return }
|
||||
let store = AppGroupStore()
|
||||
store.setOnboardingPage(4)
|
||||
state.onboardingPage = 4
|
||||
}
|
||||
|
||||
func advanceOnboarding() {
|
||||
let store = AppGroupStore()
|
||||
let nextPage = min(4, store.onboardingPage + 1)
|
||||
store.setOnboardingPage(nextPage)
|
||||
state.onboardingPage = nextPage
|
||||
}
|
||||
|
||||
func completeOnboarding() {
|
||||
let store = AppGroupStore()
|
||||
store.setHasCompletedOnboarding(true)
|
||||
store.setOnboardingPage(4)
|
||||
state.hasCompletedOnboarding = true
|
||||
state.onboardingPage = 4
|
||||
}
|
||||
|
||||
func persistLocale(_ id: String) {
|
||||
@@ -122,7 +101,7 @@ final class KeyboardConfigSync {
|
||||
func persistTranslationTargetLocaleId(_ id: String) {
|
||||
let resolved = TranslationLanguageCatalog.resolve(id).id
|
||||
state.translationTargetLocaleId = resolved
|
||||
translationConfigProtectedUntil = Date().addingTimeInterval(2.5)
|
||||
translationConfigProtectedUntil = KeyboardTranslationConfigProtection.protectionDeadline()
|
||||
persistor.persist(translationTargetLocaleId: resolved)
|
||||
}
|
||||
|
||||
|
||||
@@ -117,6 +117,7 @@ final class KeyboardFlowCoordinator {
|
||||
FlowSessionBridge.reloadFromDisk()
|
||||
refreshConfigFromAppGroup()
|
||||
refreshFlowPartialIfNeeded()
|
||||
adoptPendingResultIfNeeded()
|
||||
consumePendingFlowDeliveryIfNeeded()
|
||||
|
||||
recoverFromDeadHostIfNeeded()
|
||||
@@ -179,20 +180,19 @@ final class KeyboardFlowCoordinator {
|
||||
// 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 = readySnapshot?.reason == .recording
|
||||
|| readySnapshot?.reason == .processing
|
||||
let hostBusy = FlowKeyboardHostWarming.isHostBusy(reason: readySnapshot?.reason)
|
||||
// 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 = !hostReady
|
||||
&& !hostBusy
|
||||
&& FlowSessionBridge.isSessionActive()
|
||||
&& (
|
||||
FlowSessionBridge.isHostReachable()
|
||||
|| isPendingFlowStart
|
||||
|| withinReadyGrace
|
||||
|| readySnapshot?.reason == .starting
|
||||
)
|
||||
let hostWarming = FlowKeyboardHostWarming.isHostWarming(
|
||||
hostReady: hostReady,
|
||||
hostBusy: hostBusy,
|
||||
sessionActive: FlowSessionBridge.isSessionActive(),
|
||||
hostReachable: FlowSessionBridge.isHostReachable(),
|
||||
isPendingFlowStart: isPendingFlowStart,
|
||||
withinReadyGrace: withinReadyGrace,
|
||||
snapshotReason: readySnapshot?.reason
|
||||
)
|
||||
state.flowSessionActive = FlowSessionBridge.isSessionActive()
|
||||
state.debugPendingFlowStart = isPendingFlowStart
|
||||
state.debugFlowRecording = isFlowRecording
|
||||
@@ -204,7 +204,8 @@ final class KeyboardFlowCoordinator {
|
||||
hasFullAccess: hasFullAccess(),
|
||||
appGroupAvailable: AppGroup.isAvailable,
|
||||
hostReady: hostReady,
|
||||
isPreparingSession: isPendingFlowStart || hostWarming
|
||||
isPreparingSession: isPendingFlowStart || hostWarming,
|
||||
hasCompletedOnboarding: state.hasCompletedOnboarding
|
||||
)
|
||||
let signature = [
|
||||
"phase=\(String(describing: state.phase))",
|
||||
@@ -224,30 +225,22 @@ final class KeyboardFlowCoordinator {
|
||||
|
||||
/// Re-attach to a host utterance this keyboard process no longer owns.
|
||||
private func adoptHostBusyStateIfNeeded(snapshot: FlowReadySnapshot?) {
|
||||
guard let snapshot, let sessionId = snapshot.sessionId else { return }
|
||||
// Ignore snapshots from a dead host generation.
|
||||
if let snapGen = snapshot.hostGeneration,
|
||||
let liveGen = FlowSessionBridge.currentHostGeneration(),
|
||||
snapGen != liveGen {
|
||||
let action = FlowKeyboardAdoptBusyPolicy.decide(
|
||||
snapshot: snapshot,
|
||||
currentHostGeneration: FlowSessionBridge.currentHostGeneration(),
|
||||
isFlowRecording: isFlowRecording,
|
||||
isAwaitingFlowResult: isAwaitingFlowResult,
|
||||
lastConsumedUtteranceId: lastConsumedUtteranceId,
|
||||
lastStoppedUtteranceId: lastStoppedUtteranceId
|
||||
)
|
||||
switch action {
|
||||
case .none:
|
||||
return
|
||||
}
|
||||
|
||||
// Host already finished — never re-adopt a consumed utterance, and
|
||||
// clear sticky local processing left behind by a stale busy snapshot.
|
||||
if snapshot.reason != .recording, snapshot.reason != .processing {
|
||||
clearStickyProcessingIfNeeded(hostReady: snapshot.ready)
|
||||
return
|
||||
}
|
||||
|
||||
switch snapshot.reason {
|
||||
case .recording:
|
||||
guard !isFlowRecording else { return }
|
||||
guard !isAwaitingFlowResult else { 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.
|
||||
guard let busyId = snapshot.busyUtteranceId else { return }
|
||||
guard busyId != lastConsumedUtteranceId else { return }
|
||||
guard busyId != lastStoppedUtteranceId else { return }
|
||||
activeSessionId = sessionId
|
||||
currentUtteranceId = busyId
|
||||
isPendingFlowStart = false
|
||||
@@ -264,10 +257,7 @@ final class KeyboardFlowCoordinator {
|
||||
startUtteranceCountdown()
|
||||
startFlowLevelWatchdog()
|
||||
traceState("adoptHostBusy.recording", extra: "session=\(sessionId)")
|
||||
case .processing:
|
||||
guard !isAwaitingFlowResult else { return }
|
||||
guard let busyId = snapshot.busyUtteranceId else { return }
|
||||
guard busyId != lastConsumedUtteranceId else { return }
|
||||
case .adoptProcessing(let sessionId, let busyId):
|
||||
activeSessionId = sessionId
|
||||
currentUtteranceId = busyId
|
||||
isPendingFlowStart = false
|
||||
@@ -281,8 +271,6 @@ final class KeyboardFlowCoordinator {
|
||||
}
|
||||
startFlowResultWatchdog()
|
||||
traceState("adoptHostBusy.processing", extra: "session=\(sessionId)")
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,6 +292,10 @@ final class KeyboardFlowCoordinator {
|
||||
|
||||
/// 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 {
|
||||
@@ -329,6 +321,13 @@ final class KeyboardFlowCoordinator {
|
||||
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 }
|
||||
@@ -345,18 +344,22 @@ final class KeyboardFlowCoordinator {
|
||||
self.recordWhenHostReady = false
|
||||
return
|
||||
}
|
||||
// Host died mid-wait — only cold-start after debounced dead samples.
|
||||
let dead = FlowHandoffPolicy.shouldOpenHostColdStart(
|
||||
sessionActive: FlowSessionBridge.isSessionActive(),
|
||||
hostReachable: FlowSessionBridge.isHostReachable(),
|
||||
hostStale: FlowSessionBridge.isHostStale(),
|
||||
withinReadyGrace: false
|
||||
)
|
||||
// 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()
|
||||
self.beginFlowStart(recordAfterHandoff: shouldRecord)
|
||||
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)
|
||||
@@ -371,6 +374,15 @@ final class KeyboardFlowCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
@@ -408,6 +420,9 @@ final class KeyboardFlowCoordinator {
|
||||
recomputeMicVoiceAvailability()
|
||||
|
||||
switch state.micVoiceAvailability {
|
||||
case .unavailable(.onboardingIncomplete):
|
||||
promptFinishSetupInApp()
|
||||
return
|
||||
case .unavailable(.missingAPIKey):
|
||||
return
|
||||
case .unavailable(.noFullAccess):
|
||||
@@ -476,6 +491,10 @@ final class KeyboardFlowCoordinator {
|
||||
}
|
||||
|
||||
func beginFlowStart(recordAfterHandoff: Bool = false) {
|
||||
guard state.hasCompletedOnboarding else {
|
||||
promptFinishSetupInApp()
|
||||
return
|
||||
}
|
||||
guard !isPendingFlowStart else {
|
||||
traceState("beginFlowStart.ignored", extra: "reason=pendingAlreadyTrue")
|
||||
return
|
||||
@@ -492,6 +511,11 @@ final class KeyboardFlowCoordinator {
|
||||
: "keyboard.flow.startingSession"
|
||||
)
|
||||
recomputeMicVoiceAvailability()
|
||||
OSGDiag.log(
|
||||
"beginFlowStart → openHostApp(startflow) recordAfterHandoff=\(recordAfterHandoff) "
|
||||
+ "\(OSGDiag.memoryTag())",
|
||||
category: "boot"
|
||||
)
|
||||
openHostApp("startflow")
|
||||
startFlowStartWatchdog()
|
||||
traceState(
|
||||
@@ -580,14 +604,19 @@ final class KeyboardFlowCoordinator {
|
||||
if let result = matchingResult(), result.status == .final, let text = result.text, !text.isEmpty {
|
||||
isAwaitingFlowResult = false
|
||||
stopFlowWatchdog()
|
||||
textInserter.handleFlowTranscript(
|
||||
TranscriptionDelivery(text: text, polishWarning: result.warning)
|
||||
)
|
||||
FlowSessionBridge.writeAck(
|
||||
FlowAck(
|
||||
sessionId: result.sessionId,
|
||||
utteranceId: result.utteranceId,
|
||||
commandSeq: result.commandSeq
|
||||
commandSeq: result.commandSeq,
|
||||
hostGeneration: result.hostGeneration,
|
||||
revision: result.revision
|
||||
)
|
||||
)
|
||||
FlowSessionBridge.clearResult()
|
||||
FlowSessionBridge.setPendingKeyboardUtteranceId(nil)
|
||||
lastConsumedUtteranceId = result.utteranceId
|
||||
lastStoppedUtteranceId = nil
|
||||
currentUtteranceId = nil
|
||||
@@ -597,9 +626,6 @@ final class KeyboardFlowCoordinator {
|
||||
"utterance=\(result.utteranceId.uuidString.prefix(8)) "
|
||||
+ "commandSeq=\(result.commandSeq) warning=\(result.warning == nil ? 0 : 1)"
|
||||
)
|
||||
textInserter.handleFlowTranscript(
|
||||
TranscriptionDelivery(text: text, polishWarning: result.warning)
|
||||
)
|
||||
return
|
||||
}
|
||||
if let result = matchingResult(), isTerminalFailure(result) {
|
||||
@@ -612,7 +638,16 @@ final class KeyboardFlowCoordinator {
|
||||
)
|
||||
isAwaitingFlowResult = false
|
||||
stopFlowWatchdog()
|
||||
FlowSessionBridge.clearResult()
|
||||
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
|
||||
@@ -635,18 +670,64 @@ final class KeyboardFlowCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
private func matchingResult() -> FlowResult? {
|
||||
guard let result = FlowSessionBridge.latestResult() else { return nil }
|
||||
guard let activeSessionId, let currentUtteranceId else { return nil }
|
||||
guard result.sessionId == activeSessionId,
|
||||
result.utteranceId == currentUtteranceId else {
|
||||
return nil
|
||||
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
|
||||
}
|
||||
return result
|
||||
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 {
|
||||
result.status == .error || result.status == .timeout || result.status == .aborted
|
||||
FlowKeyboardResultMatcher.isTerminalFailure(result)
|
||||
}
|
||||
|
||||
/// When the host process died mid-utterance, abort local recording / waiting
|
||||
@@ -676,6 +757,9 @@ final class KeyboardFlowCoordinator {
|
||||
}
|
||||
|
||||
private func failHostDisconnected() {
|
||||
if deliverRawFallbackIfAvailable(reason: "hostDisconnected") {
|
||||
return
|
||||
}
|
||||
traceState("hostDisconnected.fail")
|
||||
isAwaitingFlowResult = false
|
||||
isFlowRecording = false
|
||||
@@ -695,6 +779,49 @@ final class KeyboardFlowCoordinator {
|
||||
debug("host disconnected while awaiting Flow result")
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func deliverRawFallbackIfAvailable(reason: String) -> Bool {
|
||||
FlowSessionBridge.reloadFromDisk()
|
||||
guard let result = matchingResult(),
|
||||
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)
|
||||
@@ -718,6 +845,16 @@ final class KeyboardFlowCoordinator {
|
||||
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
|
||||
@@ -767,6 +904,7 @@ final class KeyboardFlowCoordinator {
|
||||
}
|
||||
activeSessionId = sessionId
|
||||
currentUtteranceId = UUID()
|
||||
FlowSessionBridge.setPendingKeyboardUtteranceId(currentUtteranceId)
|
||||
lastStoppedUtteranceId = nil
|
||||
writeCommand(.startRecording)
|
||||
isFlowRecording = true
|
||||
@@ -884,7 +1022,7 @@ final class KeyboardFlowCoordinator {
|
||||
switch state.phase {
|
||||
case .recording, .processing:
|
||||
if let result = matchingResult(),
|
||||
result.status == .partial,
|
||||
result.status == .partial || result.status == .rawReady,
|
||||
let partial = result.text,
|
||||
!partial.isEmpty {
|
||||
state.lastTranscript = partial
|
||||
@@ -902,17 +1040,23 @@ final class KeyboardFlowCoordinator {
|
||||
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
|
||||
commandSeq: result.commandSeq,
|
||||
hostGeneration: result.hostGeneration,
|
||||
revision: result.revision
|
||||
)
|
||||
)
|
||||
FlowSessionBridge.clearResult()
|
||||
FlowSessionBridge.setPendingKeyboardUtteranceId(nil)
|
||||
self.lastConsumedUtteranceId = result.utteranceId
|
||||
self.lastStoppedUtteranceId = nil
|
||||
self.currentUtteranceId = nil
|
||||
@@ -924,15 +1068,21 @@ final class KeyboardFlowCoordinator {
|
||||
+ "commandSeq=\(result.commandSeq) "
|
||||
+ "waitedSeconds=\(String(format: "%.2f", Date().timeIntervalSince1970 - startedAt))"
|
||||
)
|
||||
self.textInserter.handleFlowTranscript(
|
||||
TranscriptionDelivery(text: text, polishWarning: result.warning)
|
||||
)
|
||||
return
|
||||
}
|
||||
if let result = self.matchingResult(), self.isTerminalFailure(result) {
|
||||
self.isAwaitingFlowResult = false
|
||||
self.stopFlowWatchdog()
|
||||
FlowSessionBridge.clearResult()
|
||||
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
|
||||
@@ -976,6 +1126,9 @@ final class KeyboardFlowCoordinator {
|
||||
return
|
||||
}
|
||||
if now - startedAt > resultTimeout {
|
||||
if self.deliverRawFallbackIfAvailable(reason: "resultTimeout") {
|
||||
return
|
||||
}
|
||||
self.isAwaitingFlowResult = false
|
||||
self.stopFlowWatchdog()
|
||||
self.currentUtteranceId = nil
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
// PermissionManager.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// Extracted from KeyboardViewController so the view controller doesn't
|
||||
// need to know about AVAudioApplication vs AVAudioSession branching
|
||||
// or SFSpeechRecognizer.requestAuthorization callback bridging.
|
||||
//
|
||||
// Contract:
|
||||
// • `requestMicPermission()` returns true if the user has authorised
|
||||
// or *just* authorised; false otherwise. Idempotent within a
|
||||
// process — the second call will not prompt again if the user has
|
||||
// already answered.
|
||||
// • `requestSpeechPermission()` mirrors the same shape but for
|
||||
// SFSpeechRecognizer.
|
||||
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import Speech
|
||||
|
||||
@MainActor
|
||||
public final class PermissionManager: @unchecked Sendable {
|
||||
|
||||
public init() {}
|
||||
|
||||
private var didRequestMicOnce: Bool = false
|
||||
|
||||
/// Request microphone access. Returns true if granted (already or
|
||||
/// after this call). Uses the iOS 17+ `AVAudioApplication` API.
|
||||
public func requestMicPermission() async -> Bool {
|
||||
switch AVAudioApplication.shared.recordPermission {
|
||||
case .granted: return true
|
||||
case .denied: return false
|
||||
case .undetermined:
|
||||
if !didRequestMicOnce {
|
||||
didRequestMicOnce = true
|
||||
return await AVAudioApplication.requestRecordPermission()
|
||||
}
|
||||
return false
|
||||
@unknown default: return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Request Speech Recognition permission. Returns true if granted
|
||||
/// (already or after this call). The `SFSpeechRecognizer` plist
|
||||
/// key + this call are still required even on iOS 26 — the
|
||||
/// `SpeechAnalyzer` API does not expose an explicit request
|
||||
/// method of its own and the framework checks the same TCC
|
||||
/// entry on first use.
|
||||
public func requestSpeechPermission() async -> Bool {
|
||||
await Self.requestSpeechPermissionNonisolated()
|
||||
}
|
||||
|
||||
// MARK: - Nonisolated permission bridge
|
||||
//
|
||||
// `SFSpeechRecognizer.requestAuthorization` callback is not guaranteed
|
||||
// to run on main queue. Building the callback inline inside a
|
||||
// `@MainActor` method can trigger runtime actor/isolation assertions.
|
||||
// Keep the continuation + callback creation in nonisolated helpers.
|
||||
private nonisolated static func requestSpeechPermissionNonisolated() async -> Bool {
|
||||
await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
|
||||
SFSpeechRecognizer.requestAuthorization(
|
||||
makeSpeechAuthHandler(continuation: cont)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func makeSpeechAuthHandler(
|
||||
continuation: CheckedContinuation<Bool, Never>
|
||||
) -> @Sendable (SFSpeechRecognizerAuthorizationStatus) -> Void {
|
||||
return { status in
|
||||
switch status {
|
||||
case .authorized:
|
||||
continuation.resume(returning: true)
|
||||
case .denied, .restricted, .notDetermined:
|
||||
continuation.resume(returning: false)
|
||||
@unknown default:
|
||||
continuation.resume(returning: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user