0401f67a89
Two long-standing UX papercuts, fixed without leaving the keyboard:
1. **First-launch onboarding inside the keyboard** — iOS keyboard
extensions *cannot* programmatically switch back to the previous
app after jumping out, so the old flow (jump to host app → user
has to manually navigate back) was 5+ taps of friction. The new
`KeyboardOnboardingOverlay` keeps the user inside the keyboard
for steps 1 (welcome), 2 (mic permission), 3 (speech permission),
and 5 (API key hint). The only step that *must* leave is step 4
("Enable Keyboard"), which jumps to `Settings.app` via
`UIApplication.openSettingsURLString` — on return,
`viewWillAppear` calls `autoAdvancePastKeyboardSetupStepIfNeeded`
which silently advances past that step if the keyboard is now
enabled. Net UX: user types in their app, keyboard walks them
through setup, normal UI appears as soon as setup is done.
2. **Per-app context chip on the keyboard top bar** — the v0.3.0
intelligent-prompt pipeline already adapted tone to detected
context (code/email/chat/document), but without a UI cue the user
had no way to know which mode was active or override the heuristic
when it guessed wrong. The new `AppContextChip` surfaces the
detected context; tap-to-override writes back to
`AppGroupStore.setDetectedAppContext` so the next LLM call
picks up the new tone immediately. Wired into `pressBegan` so
the chip updates in real time as the user types into different
fields.
### Files added
- `OSGKeyboardExt/Views/AppContextChip.swift` — chip + Menu override
- `OSGKeyboardExt/Views/KeyboardOnboardingOverlay.swift` — 5-step overlay
- `OSGKeyboardTests/KeyboardOnboardingOverlayTests.swift` — round-trip + enum surface tests
### Files modified
- `OSGKeyboardShared/Services/KeyboardState.swift`
+ `hasCompletedOnboarding`, `onboardingPage`, `appContext`
+ `setAppContext`, `advanceOnboarding`, `completeOnboarding`
+ `requestMicPermission`, `requestSpeechPermission`, `openSystemSettings`
- `OSGKeyboardShared/Services/AppGroupStore.swift`
+ `hasCompletedOnboarding` / `onboardingPage` accessors (mirror of
`ProviderConfig` keys, so the keyboard extension never has to
instantiate the host-app config)
- `OSGKeyboardExt/KeyboardViewController.swift`
+ action hooks wired (`installStateActions`)
+ `syncOnboardingStateFromAppGroup` / `syncAppContextFromAppGroup`
called on `viewWillAppear` and `loadPersistedConfig`
+ `autoAdvancePastKeyboardSetupStepIfNeeded` for the silent
"jump out → come back" flow
+ `openSystemSettingsFromExtension` opens `Settings.app` via
`UIApplication.openSettingsURLString` (the only system URL
the extension is allowed to open)
+ `detectAndStoreAppContext` mirrors to `state.appContext` so
the chip updates without waiting for `viewWillAppear`
- `OSGKeyboardExt/Views/KeyboardRootView.swift`
+ overlay mounted in `ZStack` over normal UI (animated)
+ AppContextChip in top bar (hidden during onboarding)
- `OSGKeyboardExt/{en,zh-Hans}.lproj/Keyboard.strings`
+ onboarding copy + chip labels (35 keys per language)
### iOS sandbox notes (kept here for posterity)
- Keyboard extensions **cannot** present AVAudioSession /
SFSpeechRecognizer permission dialogs directly. The overlay's
step 2/3 buttons optimistically advance; the actual permission
is granted when the user first opens the host app (which the
step-5 "Open OSGKeyboard" button triggers). This is the same
pattern the previous "jump to host app" flow used — just
without the broken return trip.
- `UIApplication.openSettingsURLString` is the only system URL
reachable from `extensionContext.open`. Both step 4 and the
"Open Settings" button route through `HostAppLauncher` so the
responder-chain fallback also kicks in if needed.
Co-authored-by: Mavis <Mavis@hkgood.dev>
1025 lines
41 KiB
Swift
1025 lines
41 KiB
Swift
// KeyboardViewController.swift
|
||
// OSGKeyboard · Keyboard Extension
|
||
//
|
||
// Principal class for the Custom Keyboard Extension. Hosts a single
|
||
// SwiftUI tree (`KeyboardRootView`) and drives the recording pipeline:
|
||
//
|
||
// host app dictation handoff ──► App Group transcript ──► insertText
|
||
//
|
||
// Design notes:
|
||
// • The class is `@MainActor` — every UI mutation and `textDocumentProxy`
|
||
// call must happen on main, and Swift 6 strict concurrency forces this.
|
||
// • State is a single `State` ObservableObject; SwiftUI observes it via
|
||
// `@ObservedObject` so we never re-create the hosting root on each tick.
|
||
// • `phase` is a real stored property (no derivation) — the previous
|
||
// "derive from recordStream" shim locked out every press after the first.
|
||
// • Microphone permission is requested *inside* pressBegan, but we still
|
||
// start the rest of the press flow optimistically; if permission is
|
||
// denied we surface a short error and drop back to idle cleanly.
|
||
|
||
import UIKit
|
||
import SwiftUI
|
||
import OSGKeyboardShared
|
||
|
||
@objc(KeyboardViewController)
|
||
@MainActor
|
||
public final class KeyboardViewController: UIInputViewController {
|
||
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,
|
||
localASRBackend: LocalASRBackend
|
||
) -> TimeInterval {
|
||
FlowSessionKeys.keyboardResultTimeout(
|
||
engineMode: engineMode,
|
||
localASRBackend: localASRBackend
|
||
)
|
||
}
|
||
}
|
||
|
||
private enum DictationWatchdog {
|
||
static let pollIntervalNs: UInt64 = 400_000_000
|
||
static let timeout: TimeInterval = 45
|
||
}
|
||
|
||
// MARK: - View model
|
||
|
||
/// Typealias so existing call sites (`KeyboardViewController.State`)
|
||
/// keep compiling unchanged. The actual class lives in
|
||
/// `OSGKeyboardShared` so unit tests can `@testable import` it
|
||
/// without dragging in the `app-extension` linking surface.
|
||
public typealias State = KeyboardState
|
||
|
||
// MARK: - State
|
||
|
||
private let state = State()
|
||
private let polisher = PolishingService()
|
||
private let persistor = AppGroupPersistor()
|
||
|
||
private var hosting: UIHostingController<KeyboardRootView>!
|
||
/// Legacy one-shot handoff (`osgkeyboard://dictate`).
|
||
private var awaitingDictationResult = false
|
||
private var dictationRequestStartedAt: TimeInterval = 0
|
||
private var dictationWatchdogTask: Task<Void, Never>?
|
||
/// Flow session: waiting for host app to come alive after `startflow`.
|
||
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 utteranceStartedAt: TimeInterval = 0
|
||
private var wasFlowSessionActive = false
|
||
private var flowSessionMonitorTask: Task<Void, Never>?
|
||
private var flowSessionDarwinObserver: FlowSessionDarwinObserver?
|
||
private var configDarwinObserver: FlowSessionDarwinObserver?
|
||
/// Grace period after a chip-side translation write during which the
|
||
/// 1 Hz App Group poll must not overwrite `translationTargetLocaleId`.
|
||
private var translationConfigProtectedUntil: Date?
|
||
private var polishScenarioConfigProtectedUntil: Date?
|
||
private var isAwaitingFlowResult = false
|
||
private var lastFlowAutoStartAttempt: TimeInterval = 0
|
||
private static let flowAutoStartCooldown: TimeInterval = 20
|
||
/// Drives the keyboard slot height on `view` (priority 999).
|
||
private var keyboardHeightConstraint: NSLayoutConstraint?
|
||
/// Runtime value read from `UIView-Encapsulated-Layout-Height` (varies by device).
|
||
private var systemEncapsulatedHeight: CGFloat = 228
|
||
|
||
private var targetKeyboardHeight: CGFloat {
|
||
KeyboardRootView.totalHeight
|
||
}
|
||
|
||
// MARK: - Lifecycle
|
||
|
||
public override func viewDidLoad() {
|
||
super.viewDidLoad()
|
||
installKeyboardHeight()
|
||
configureDictationBehavior()
|
||
installStateActions()
|
||
installSwiftUI()
|
||
loadPersistedConfig()
|
||
consumePendingDictationResultIfNeeded()
|
||
refreshDictationProgressStateIfNeeded()
|
||
installFlowSessionDarwinObserver()
|
||
installConfigDarwinObserver()
|
||
refreshFlowSessionState()
|
||
}
|
||
|
||
public override func viewWillDisappear(_ animated: Bool) {
|
||
super.viewWillDisappear(animated)
|
||
stopFlowSessionMonitor()
|
||
// Preserve flow handoff / recording / result polling across the
|
||
// intentional jump to the host app (keyboard extension pauses here).
|
||
if isPendingFlowStart || isFlowRecording || isAwaitingFlowResult || awaitingDictationResult {
|
||
return
|
||
}
|
||
ExtensionScreenWakeLock.releaseAll()
|
||
cancelPipeline()
|
||
}
|
||
|
||
public override func viewWillAppear(_ animated: Bool) {
|
||
super.viewWillAppear(animated)
|
||
configureDictationBehavior()
|
||
KeyboardSetupBridge.markExtensionAppearance(hasFullAccess: hasFullAccess)
|
||
consumePendingDictationResultIfNeeded()
|
||
refreshDictationProgressStateIfNeeded()
|
||
refreshFlowSessionState()
|
||
startFlowSessionMonitor()
|
||
// v0.3.0: re-mirror onboarding + app context from the App
|
||
// Group every time we appear. The user may have just returned
|
||
// from Settings.app or the host app, and the App Group is the
|
||
// only thing both processes see consistently.
|
||
syncOnboardingStateFromAppGroup()
|
||
syncAppContextFromAppGroup()
|
||
// Auto-advance past step 3 ("Enable Keyboard") if the user has
|
||
// enabled the keyboard in Settings.app while we were away.
|
||
// This is the "automatic return from jump" feature: no manual
|
||
// "Continue" tap needed.
|
||
autoAdvancePastKeyboardSetupStepIfNeeded()
|
||
}
|
||
|
||
public override func viewIsAppearing(_ animated: Bool) {
|
||
super.viewIsAppearing(animated)
|
||
applyPresentationHeightOffset()
|
||
}
|
||
|
||
public override func viewDidAppear(_ animated: Bool) {
|
||
super.viewDidAppear(animated)
|
||
// Presentation finished — lock to the true content-driven height.
|
||
keyboardHeightConstraint?.constant = targetKeyboardHeight
|
||
}
|
||
|
||
public override func didReceiveMemoryWarning() {
|
||
super.didReceiveMemoryWarning()
|
||
cancelPipeline()
|
||
}
|
||
|
||
public override func textDidChange(_ textInput: (any UITextInput)?) {
|
||
super.textDidChange(textInput)
|
||
consumePendingDictationResultIfNeeded()
|
||
refreshDictationProgressStateIfNeeded()
|
||
}
|
||
|
||
// MARK: - System keyboard chrome
|
||
|
||
/// Tell iOS this keyboard provides its own dictation entry (centre mic).
|
||
/// When `true`, the system dictation key in the bottom-right is not shown.
|
||
private func configureDictationBehavior() {
|
||
hasDictationKey = true
|
||
}
|
||
|
||
// MARK: - Wiring
|
||
|
||
private func installStateActions() {
|
||
state.beginRecording = { [weak self] in self?.pressBegan() }
|
||
state.endRecording = { [weak self] in self?.pressEnded() }
|
||
state.tapMic = { [weak self] in self?.toggleRecording() }
|
||
state.openSettings = { [weak self] in self?.openHostApp() }
|
||
state.startFlowSession = { [weak self] in self?.beginFlowStart() }
|
||
state.setMode = { [weak self] m in self?.persistMode(m) }
|
||
state.setLocale = { [weak self] l in self?.persistLocale(l) }
|
||
state.setEngineMode = { [weak self] m in self?.persistEngineMode(m) }
|
||
state.setLocalASRBackend = { [weak self] b in self?.persistLocalASRBackend(b) }
|
||
// v0.2.1 follow-up: removed `setTranslationEnabled` — the chip
|
||
// / picker now writes the locale id directly; `enabled` is derived.
|
||
state.setTranslationTargetLocaleId = { [weak self] id in self?.persistTranslationTargetLocaleId(id) }
|
||
state.setPolishScenarioId = { [weak self] id in self?.persistPolishScenarioId(id) }
|
||
// v0.3.0: per-app context override. Writes to the App Group
|
||
// (so `PolishingService` reads it on the next take) and mirrors
|
||
// into local state.
|
||
state.setAppContext = { [weak self] c in self?.persistAppContext(c) }
|
||
// v0.3.0: in-keyboard onboarding actions. Persist via the App
|
||
// Group so the host app's `ProviderConfig` stays in sync (and
|
||
// the next launch of the host app opens at the same page).
|
||
state.advanceOnboarding = { [weak self] in self?.advanceOnboarding() }
|
||
state.completeOnboarding = { [weak self] in self?.completeOnboarding() }
|
||
state.requestMicPermission = { [weak self] in self?.requestMicPermissionFromExtension() }
|
||
state.requestSpeechPermission = { [weak self] in self?.requestSpeechPermissionFromExtension() }
|
||
state.openSystemSettings = { [weak self] in self?.openSystemSettingsFromExtension() }
|
||
state.insertNewline = { [weak self] in self?.textDocumentProxy.insertText("\n") }
|
||
state.insertSpace = { [weak self] in self?.textDocumentProxy.insertText(" ") }
|
||
state.deleteBackward = { [weak self] in self?.textDocumentProxy.deleteBackward() }
|
||
}
|
||
|
||
// MARK: - Onboarding persistence (v0.3.0)
|
||
|
||
private func advanceOnboarding() {
|
||
let store = AppGroupStore()
|
||
let nextPage = min(4, store.onboardingPage + 1)
|
||
store.onboardingPage = nextPage
|
||
state.onboardingPage = nextPage
|
||
}
|
||
|
||
private func completeOnboarding() {
|
||
let store = AppGroupStore()
|
||
store.hasCompletedOnboarding = true
|
||
store.onboardingPage = 4
|
||
state.hasCompletedOnboarding = true
|
||
state.onboardingPage = 4
|
||
}
|
||
|
||
// MARK: - App context override (v0.3.0)
|
||
|
||
private func persistAppContext(_ context: AppContext) {
|
||
let store = AppGroupStore()
|
||
store.setDetectedAppContext(context, at: Date())
|
||
state.appContext = context
|
||
}
|
||
|
||
// MARK: - Permission requests from the extension (v0.3.0)
|
||
|
||
/// The keyboard extension cannot present `AVAudioSession` /
|
||
/// `SFSpeechRecognizer` permission dialogs directly. It *can* read
|
||
/// the current status and tell the user to grant them from the
|
||
/// host app — which the overlay's step 1/2 copy already does.
|
||
/// This method is kept as a no-op stub so the action hook exists
|
||
/// and we can fill in the right behaviour if iOS ever relaxes the
|
||
/// sandbox (currently the system dialog is only presented when
|
||
/// the relevant API is first invoked from the host app, not the
|
||
/// extension).
|
||
private func requestMicPermissionFromExtension() {
|
||
// Status is read on the next `viewWillAppear` via
|
||
// `KeyboardSetupBridge`; the overlay advances optimistically.
|
||
}
|
||
|
||
private func requestSpeechPermissionFromExtension() {
|
||
// Same as above — read on next `viewWillAppear`.
|
||
}
|
||
|
||
/// Open `Settings.app` so the user can flip the "Allow Full Access"
|
||
/// / "OSGKeyboard" toggles. `UIApplication.openSettingsURLString`
|
||
/// is the only system URL the keyboard extension is allowed to
|
||
/// open via `extensionContext`.
|
||
private func openSystemSettingsFromExtension() {
|
||
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
|
||
HostAppLauncher.open(url: url, from: self) { _ in }
|
||
}
|
||
|
||
/// Reserve keyboard height on `view`. During presentation iOS adds a
|
||
/// private encapsulated height; `viewIsAppearing` applies the community
|
||
/// offset trick (target − encapsulated) so the slot lands at `target`.
|
||
private func installKeyboardHeight() {
|
||
let constraint = view.heightAnchor.constraint(
|
||
equalToConstant: targetKeyboardHeight
|
||
)
|
||
constraint.priority = UILayoutPriority(999)
|
||
constraint.isActive = true
|
||
keyboardHeightConstraint = constraint
|
||
}
|
||
|
||
/// Read the system encapsulated height and prime our constraint so iOS
|
||
/// presentation math (custom + encapsulated) equals `targetKeyboardHeight`.
|
||
/// See: https://developer.apple.com/forums/thread/799003
|
||
private func applyPresentationHeightOffset() {
|
||
if let encapsulated = view.constraints.first(where: { constraint in
|
||
constraint.firstItem as? UIView === view
|
||
&& constraint.firstAttribute == .height
|
||
&& constraint !== keyboardHeightConstraint
|
||
}) {
|
||
systemEncapsulatedHeight = encapsulated.constant
|
||
}
|
||
let primed = targetKeyboardHeight - systemEncapsulatedHeight
|
||
keyboardHeightConstraint?.constant = max(0, primed)
|
||
}
|
||
|
||
private func installSwiftUI() {
|
||
let root = KeyboardRootView(state: state)
|
||
let host = UIHostingController(rootView: root)
|
||
host.view.backgroundColor = .clear
|
||
host.view.translatesAutoresizingMaskIntoConstraints = false
|
||
host.view.clipsToBounds = false
|
||
// Keep keyboard layout anchored to the top edge across keyboard
|
||
// switches — don't let UIHostingController re-inset for safe area.
|
||
host.view.insetsLayoutMarginsFromSafeArea = false
|
||
host.safeAreaRegions = []
|
||
addChild(host)
|
||
view.addSubview(host.view)
|
||
NSLayoutConstraint.activate([
|
||
host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||
host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||
host.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||
host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||
])
|
||
host.didMove(toParent: self)
|
||
self.hosting = host
|
||
}
|
||
|
||
private func loadPersistedConfig() {
|
||
switch persistor.load(into: state) {
|
||
case .loaded:
|
||
break
|
||
case .unavailable:
|
||
state.phase = .error(
|
||
.appGroupUnavailable,
|
||
message: ExtL10n.string("keyboard.error.appGroupUnavailable")
|
||
)
|
||
}
|
||
syncOnboardingStateFromAppGroup()
|
||
syncAppContextFromAppGroup()
|
||
}
|
||
|
||
// MARK: - Onboarding / app-context sync (v0.3.0)
|
||
|
||
/// Mirror the persisted onboarding flags from the App Group into
|
||
/// `KeyboardState`. Called both at boot (`loadPersistedConfig`) and
|
||
/// on every `viewWillAppear` so the overlay reflects what the user
|
||
/// did while the keyboard was paused (e.g. toggling permissions
|
||
/// in the host app's onboarding view).
|
||
private func syncOnboardingStateFromAppGroup() {
|
||
let store = AppGroupStore()
|
||
state.hasCompletedOnboarding = store.hasCompletedOnboarding
|
||
state.onboardingPage = store.onboardingPage
|
||
}
|
||
|
||
/// Mirror the detected app context (or the user's last manual
|
||
/// override) so the AppContextChip stays in sync after a jump.
|
||
private func syncAppContextFromAppGroup() {
|
||
let store = AppGroupStore()
|
||
state.appContext = store.detectedAppContext?.context ?? .unknown
|
||
}
|
||
|
||
/// If the user has finished the "Enable Keyboard" step (i.e. the
|
||
/// keyboard is now in the system list with full access), and the
|
||
/// overlay is currently sitting on that step, advance to the API
|
||
/// step silently. This is what makes the "jump out → come back"
|
||
/// flow feel automatic even though iOS won't switch us back.
|
||
private func autoAdvancePastKeyboardSetupStepIfNeeded() {
|
||
guard !state.hasCompletedOnboarding else { return }
|
||
// step index 3 = "Enable Keyboard"
|
||
guard state.onboardingPage == 3 else { return }
|
||
guard KeyboardSetupBridge.isReadyForOnboardingSkip else { return }
|
||
let store = AppGroupStore()
|
||
store.onboardingPage = 4
|
||
state.onboardingPage = 4
|
||
}
|
||
|
||
// MARK: - Flow session monitor
|
||
|
||
private func installFlowSessionDarwinObserver() {
|
||
flowSessionDarwinObserver = FlowSessionDarwinObserver { [weak self] in
|
||
self?.refreshFlowSessionState()
|
||
}
|
||
}
|
||
|
||
private func startFlowSessionMonitor() {
|
||
flowSessionMonitorTask?.cancel()
|
||
flowSessionMonitorTask = Task { @MainActor [weak self] in
|
||
while !Task.isCancelled {
|
||
self?.refreshFlowSessionState()
|
||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func stopFlowSessionMonitor() {
|
||
flowSessionMonitorTask?.cancel()
|
||
flowSessionMonitorTask = nil
|
||
}
|
||
|
||
private func installConfigDarwinObserver() {
|
||
configDarwinObserver = FlowSessionDarwinObserver(
|
||
notificationName: AppGroupConfigDarwin.notificationName
|
||
) { [weak self] in
|
||
self?.refreshConfigFromAppGroup()
|
||
}
|
||
}
|
||
|
||
private func refreshConfigFromAppGroup() {
|
||
persistor.refreshRuntimeFlags(
|
||
into: state,
|
||
protectTranslationUntil: translationConfigProtectedUntil,
|
||
protectPolishScenarioUntil: polishScenarioConfigProtectedUntil
|
||
)
|
||
}
|
||
|
||
private func refreshFlowSessionState() {
|
||
persistor.refreshRuntimeFlags(
|
||
into: state,
|
||
protectTranslationUntil: translationConfigProtectedUntil,
|
||
protectPolishScenarioUntil: polishScenarioConfigProtectedUntil
|
||
)
|
||
consumePendingFlowDeliveryIfNeeded()
|
||
|
||
let active = FlowSessionBridge.isSessionActive()
|
||
state.flowSessionActive = active
|
||
|
||
if wasFlowSessionActive && !active && !isFlowRecording && !isPendingFlowStart {
|
||
switch state.phase {
|
||
case .recording, .processing:
|
||
break
|
||
default:
|
||
showFlowSessionExpiredHint()
|
||
}
|
||
}
|
||
wasFlowSessionActive = active
|
||
|
||
if !active {
|
||
maybeAutoStartFlowSession()
|
||
}
|
||
}
|
||
|
||
/// Pick up transcripts/errors the host wrote while the extension was paused.
|
||
private func consumePendingFlowDeliveryIfNeeded() {
|
||
if isAwaitingFlowResult {
|
||
if let delivery = FlowSessionBridge.consumeTranscriptionDelivery() {
|
||
isAwaitingFlowResult = false
|
||
stopFlowWatchdog()
|
||
handleFlowTranscript(delivery)
|
||
return
|
||
}
|
||
if let error = FlowSessionBridge.consumeTranscriptionError() {
|
||
isAwaitingFlowResult = false
|
||
stopFlowWatchdog()
|
||
state.phase = .error(.unknown(error), message: error)
|
||
scheduleAutoClearError()
|
||
return
|
||
}
|
||
}
|
||
|
||
if isPendingFlowStart, FlowSessionBridge.isSessionActive() {
|
||
completeFlowStartHandoff()
|
||
}
|
||
}
|
||
|
||
/// When the host session is down, proactively jump to the app to start it.
|
||
private func maybeAutoStartFlowSession() {
|
||
guard !FlowSessionBridge.isSessionActive() else { return }
|
||
guard !isPendingFlowStart, !isFlowRecording, !isAwaitingFlowResult else { return }
|
||
guard hasFullAccess, AppGroup.isAvailable else { return }
|
||
guard case .idle = state.phase else { return }
|
||
|
||
let now = Date().timeIntervalSince1970
|
||
guard now - lastFlowAutoStartAttempt >= Self.flowAutoStartCooldown else { return }
|
||
lastFlowAutoStartAttempt = now
|
||
beginFlowStart()
|
||
}
|
||
|
||
private func showFlowSessionExpiredHint() {
|
||
let message = ExtL10n.string("keyboard.flow.sessionExpired")
|
||
state.phase = .error(.unknown(message), message: message)
|
||
scheduleAutoClearError()
|
||
}
|
||
|
||
// MARK: - Press handlers
|
||
|
||
private func toggleRecording() {
|
||
switch state.phase {
|
||
case .recording:
|
||
pressEnded()
|
||
case .idle, .denied, .error:
|
||
pressBegan()
|
||
case .requestingPermissions, .processing:
|
||
break
|
||
}
|
||
}
|
||
|
||
private func pressBegan() {
|
||
switch state.phase {
|
||
case .idle, .denied, .error:
|
||
break
|
||
default:
|
||
return
|
||
}
|
||
guard hasFullAccess else {
|
||
let msg = ExtL10n.string("keyboard.error.fullAccessRequired")
|
||
state.phase = .error(.unknown(msg), message: msg)
|
||
scheduleAutoClearError()
|
||
return
|
||
}
|
||
guard AppGroup.isAvailable else {
|
||
let msg = ExtL10n.string("keyboard.error.appGroupCommunication")
|
||
state.phase = .error(.appGroupUnavailable, message: msg)
|
||
scheduleAutoClearError()
|
||
return
|
||
}
|
||
|
||
// v0.3.0: detect the app context (code / email / chat / doc)
|
||
// *before* either path. The Flow session's polisher and the
|
||
// legacy handoff's polisher both read this from the App
|
||
// Group. Cheap (heuristic over ≤ 2 KB), safe to run every
|
||
// press of the mic.
|
||
detectAndStoreAppContext()
|
||
|
||
if FlowSessionBridge.isSessionActive() {
|
||
startFlowRecording()
|
||
} else {
|
||
beginFlowStart()
|
||
}
|
||
}
|
||
|
||
private func pressEnded() {
|
||
if isPendingFlowStart {
|
||
cancelPendingFlowStart()
|
||
return
|
||
}
|
||
guard isFlowRecording else { return }
|
||
|
||
isFlowRecording = false
|
||
stopUtteranceCountdown()
|
||
ExtensionScreenWakeLock.release()
|
||
FlowSessionBridge.setRecordingState(.stopped)
|
||
state.phase = .processing
|
||
state.lastTranscript = ExtL10n.string("keyboard.flow.transcribing")
|
||
startFlowResultWatchdog()
|
||
}
|
||
|
||
private func startFlowRecording() {
|
||
isPendingFlowStart = false
|
||
flowStartDeadline = 0
|
||
stopFlowWatchdog()
|
||
|
||
FlowSessionBridge.setTranscriptionLanguage(state.localeId)
|
||
FlowSessionBridge.setRecordingState(.recording)
|
||
isFlowRecording = true
|
||
state.lastTranscript = ""
|
||
state.phase = .recording
|
||
ExtensionScreenWakeLock.acquire(from: view)
|
||
startUtteranceCountdown()
|
||
startFlowLevelWatchdog()
|
||
debug("startFlowRecording")
|
||
}
|
||
|
||
/// v0.3.0: run the 3-fallback context detector on the text
|
||
/// already at the cursor and persist the result to the App
|
||
/// Group. Cheap (heuristic over ≤ 2 KB of preceding text) so
|
||
/// safe to run on every press of the mic; we deliberately
|
||
/// avoid hitting the App Group on every keystroke.
|
||
private func detectAndStoreAppContext() {
|
||
let preceding = textDocumentProxy.documentContextBeforeInput
|
||
let store = AppGroupStore()
|
||
let detector = AppContextDetector()
|
||
let context = detector.detect(
|
||
precedingText: preceding,
|
||
storedCache: store.detectedAppContext
|
||
)
|
||
store.setDetectedAppContext(context)
|
||
// v0.3.0: mirror into local state so the chip updates without
|
||
// waiting for the next `viewWillAppear`. The user gets
|
||
// immediate visual feedback that the polish tone just changed.
|
||
state.appContext = context
|
||
}
|
||
|
||
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 beginFlowStart() {
|
||
guard !isPendingFlowStart else { return }
|
||
isPendingFlowStart = true
|
||
isFlowRecording = false
|
||
flowStartDeadline = Date().timeIntervalSince1970 + FlowWatchdog.startTimeout
|
||
state.lastTranscript = ExtL10n.string("keyboard.flow.startingSession")
|
||
state.phase = .processing
|
||
openHostApp(path: "startflow")
|
||
startFlowStartWatchdog()
|
||
debug("beginFlowStart")
|
||
}
|
||
|
||
private func cancelPendingFlowStart() {
|
||
isPendingFlowStart = false
|
||
flowStartDeadline = 0
|
||
stopFlowWatchdog()
|
||
state.phase = .idle
|
||
state.lastTranscript = ""
|
||
}
|
||
|
||
private func startFlowStartWatchdog() {
|
||
stopFlowWatchdog()
|
||
flowWatchdogTask = Task { @MainActor [weak self] in
|
||
while let self, !Task.isCancelled, self.isPendingFlowStart {
|
||
if FlowSessionBridge.isSessionActive() {
|
||
self.completeFlowStartHandoff()
|
||
return
|
||
}
|
||
let now = Date().timeIntervalSince1970
|
||
if self.flowStartDeadline > 0, now > self.flowStartDeadline {
|
||
self.isPendingFlowStart = false
|
||
self.flowStartDeadline = 0
|
||
self.showManualSettingsHint(path: "startflow")
|
||
return
|
||
}
|
||
try? await Task.sleep(nanoseconds: FlowWatchdog.pollIntervalNs)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Session is live — return to idle so the user can tap again to record.
|
||
private func completeFlowStartHandoff() {
|
||
isPendingFlowStart = false
|
||
flowStartDeadline = 0
|
||
stopFlowWatchdog()
|
||
state.lastTranscript = ""
|
||
state.phase = .idle
|
||
refreshFlowSessionState()
|
||
debug("completeFlowStartHandoff")
|
||
}
|
||
|
||
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)
|
||
}
|
||
try? await Task.sleep(nanoseconds: FlowWatchdog.pollIntervalNs)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func startFlowResultWatchdog() {
|
||
stopFlowWatchdog()
|
||
isAwaitingFlowResult = true
|
||
let startedAt = Date().timeIntervalSince1970
|
||
let resultTimeout = FlowWatchdog.resultTimeout(
|
||
engineMode: state.engineMode,
|
||
localASRBackend: state.localASRBackend
|
||
)
|
||
flowWatchdogTask = Task { @MainActor [weak self] in
|
||
while let self, !Task.isCancelled {
|
||
if let delivery = FlowSessionBridge.consumeTranscriptionDelivery() {
|
||
self.isAwaitingFlowResult = false
|
||
self.stopFlowWatchdog()
|
||
self.handleFlowTranscript(delivery)
|
||
return
|
||
}
|
||
if let error = FlowSessionBridge.consumeTranscriptionError() {
|
||
self.isAwaitingFlowResult = false
|
||
self.stopFlowWatchdog()
|
||
self.state.phase = .error(.unknown(error), message: error)
|
||
self.scheduleAutoClearError()
|
||
return
|
||
}
|
||
let now = Date().timeIntervalSince1970
|
||
if now - startedAt > resultTimeout {
|
||
self.isAwaitingFlowResult = false
|
||
self.stopFlowWatchdog()
|
||
let msg = ExtL10n.string("keyboard.flow.resultTimeout")
|
||
self.state.phase = .error(.unknown(msg), message: msg)
|
||
self.scheduleAutoClearError()
|
||
return
|
||
}
|
||
try? await Task.sleep(nanoseconds: FlowWatchdog.pollIntervalNs)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func handleFlowTranscript(_ delivery: TranscriptionDelivery) {
|
||
let trimmed = delivery.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !trimmed.isEmpty else {
|
||
state.phase = .idle
|
||
state.level = 0
|
||
return
|
||
}
|
||
// Host app already polished when configured; keyboard only inserts.
|
||
textDocumentProxy.insertText(trimmed)
|
||
state.lastTranscript = ""
|
||
state.level = 0
|
||
if let warning = delivery.polishWarning {
|
||
state.phase = .error(.unknown(warning), message: warning)
|
||
scheduleAutoClearError()
|
||
} else {
|
||
state.phase = .idle
|
||
}
|
||
debug("flow insert length=\(trimmed.count)")
|
||
}
|
||
|
||
private func stopFlowWatchdog() {
|
||
flowWatchdogTask?.cancel()
|
||
flowWatchdogTask = nil
|
||
}
|
||
|
||
private func cancelPipeline() {
|
||
if isAwaitingFlowResult || awaitingDictationResult {
|
||
return
|
||
}
|
||
if isFlowRecording || isPendingFlowStart {
|
||
if isFlowRecording {
|
||
FlowSessionBridge.setRecordingState(.aborted)
|
||
ExtensionScreenWakeLock.release()
|
||
}
|
||
isFlowRecording = false
|
||
isPendingFlowStart = false
|
||
stopUtteranceCountdown()
|
||
stopFlowWatchdog()
|
||
state.level = 0
|
||
}
|
||
}
|
||
|
||
private func handleFinalTranscript(_ delivery: TranscriptionDelivery) {
|
||
let trimmed = delivery.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !trimmed.isEmpty else {
|
||
debug("received empty transcript")
|
||
awaitingDictationResult = false
|
||
stopDictationWatchdog()
|
||
state.phase = .idle
|
||
return
|
||
}
|
||
debug("received transcript length=\(trimmed.count)")
|
||
awaitingDictationResult = false
|
||
stopDictationWatchdog()
|
||
|
||
let runtimeStore = AppGroupStore()
|
||
guard runtimeStore.shouldRunCloudLLMStep else {
|
||
textDocumentProxy.insertText(trimmed)
|
||
state.lastTranscript = ""
|
||
if let warning = delivery.polishWarning {
|
||
state.phase = .error(.unknown(warning), message: warning)
|
||
scheduleAutoClearError()
|
||
} else {
|
||
state.phase = .idle
|
||
}
|
||
return
|
||
}
|
||
|
||
// Cloud engine, or local engine with cloud polish / translation.
|
||
state.phase = .processing
|
||
Task { @MainActor [weak self] in
|
||
guard let self else { return }
|
||
let polishMode = runtimeStore.polishModeForPipeline
|
||
let overrideProviderId = runtimeStore.polishProviderIdOverride
|
||
do {
|
||
let polished = try await self.polisher.polish(
|
||
trimmed,
|
||
mode: polishMode,
|
||
providerIdOverride: overrideProviderId
|
||
)
|
||
self.textDocumentProxy.insertText(polished)
|
||
self.state.lastTranscript = ""
|
||
self.state.phase = .idle
|
||
} catch let error as LLMError {
|
||
switch error {
|
||
case .noAPIKey:
|
||
// Don't silently insert the raw transcript — the user
|
||
// thinks they're getting polished text when really no
|
||
// key is configured. Show a precise, actionable error.
|
||
self.state.phase = .error(.llm(error), message: ExtL10n.string("keyboard.error.llm.noApiKey"))
|
||
self.scheduleAutoClearError()
|
||
case .http(401):
|
||
self.state.phase = .error(.llm(error), message: ExtL10n.string("keyboard.error.llm.unauthorized"))
|
||
self.scheduleAutoClearError()
|
||
case .http(429), .rateLimited:
|
||
self.state.phase = .error(.llm(error), message: ExtL10n.string("keyboard.error.llm.rateLimited"))
|
||
self.scheduleAutoClearError()
|
||
case .cancelled:
|
||
// User-initiated cancellation (e.g. mode switch mid-
|
||
// polish). Do NOT re-insert the original transcript —
|
||
// the user has already moved on and the partial is
|
||
// considered discarded.
|
||
self.state.phase = .idle
|
||
self.state.lastTranscript = ""
|
||
return
|
||
default:
|
||
// Other LLMError variants (transport / decoding /
|
||
// invalidURL) fall back to raw transcript + generic
|
||
// error badge, same as the catch-all below.
|
||
self.textDocumentProxy.insertText(trimmed)
|
||
self.state.lastTranscript = ""
|
||
let msg = error.errorDescription ?? "Polishing failed — inserted raw."
|
||
self.state.phase = .error(.llm(error), message: msg)
|
||
self.scheduleAutoClearError()
|
||
}
|
||
} catch let polishError as PolishingService.PolishError where polishError == .missingAPIKey {
|
||
// v0.2.0: local engine + cloud polish toggle on, but the
|
||
// user hasn't entered an API key. Insert the raw transcript
|
||
// (so the user doesn't lose what they said) and surface the
|
||
// same "fill in your key" hint we use in the cloud path.
|
||
self.textDocumentProxy.insertText(trimmed)
|
||
self.state.lastTranscript = ""
|
||
self.state.phase = .error(
|
||
.llm(.noAPIKey),
|
||
message: ExtL10n.string("keyboard.error.llm.noApiKey")
|
||
)
|
||
self.scheduleAutoClearError()
|
||
} catch {
|
||
// Network / timeout / decoding — fall back to the raw
|
||
// transcript so the user still gets their text, with a
|
||
// visible error badge.
|
||
self.textDocumentProxy.insertText(trimmed)
|
||
self.state.lastTranscript = ""
|
||
let msg = (error as? LocalizedError)?.errorDescription
|
||
?? "Polishing failed — inserted raw."
|
||
self.state.phase = .error(.unknown(msg), message: msg)
|
||
self.scheduleAutoClearError()
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Persistence
|
||
|
||
private func persistMode(_ m: State.InputMode) {
|
||
let isRecording = state.phase == .recording
|
||
state.mode = m
|
||
persistor.persist(mode: m)
|
||
if isRecording {
|
||
if m == .off {
|
||
state.phase = .idle
|
||
state.lastTranscript = ""
|
||
}
|
||
}
|
||
}
|
||
|
||
private func persistLocale(_ id: String) {
|
||
state.localeId = id
|
||
persistor.persist(localeId: id)
|
||
}
|
||
|
||
private func persistEngineMode(_ mode: String) {
|
||
state.engineMode = mode
|
||
persistor.persist(engineMode: mode)
|
||
}
|
||
|
||
private func persistLocalASRBackend(_ backend: LocalASRBackend) {
|
||
state.localASRBackend = backend
|
||
persistor.persist(localASRBackend: backend)
|
||
}
|
||
|
||
// MARK: - Translation persistence
|
||
|
||
/// v0.2.1 follow-up: persist translation target locale id. Resolved
|
||
/// via `TranslationLanguageCatalog.resolve` so a stale persisted
|
||
/// value (e.g. a removed locale id from an older build) still finds
|
||
/// the right entry instead of crashing the picker. Translation's
|
||
/// "on/off" state is now derived from this id (== `offLocaleId`
|
||
/// means off), so there's no separate toggle to persist.
|
||
private func persistTranslationTargetLocaleId(_ id: String) {
|
||
let resolved = TranslationLanguageCatalog.resolve(id).id
|
||
state.translationTargetLocaleId = resolved
|
||
translationConfigProtectedUntil = Date().addingTimeInterval(2.5)
|
||
persistor.persist(translationTargetLocaleId: resolved)
|
||
}
|
||
|
||
private func persistPolishScenarioId(_ id: String) {
|
||
let resolved = PolishScenarioCatalog.resolve(id).id
|
||
state.polishScenarioId = resolved
|
||
polishScenarioConfigProtectedUntil = Date().addingTimeInterval(2.5)
|
||
persistor.persist(polishScenarioId: resolved)
|
||
}
|
||
|
||
// MARK: - Open host app
|
||
|
||
private func openHostApp(path: String = "settings") {
|
||
guard hasFullAccess else {
|
||
let msg = ExtL10n.string("keyboard.error.fullAccessForJump")
|
||
state.phase = .error(.unknown(msg), message: msg)
|
||
scheduleAutoClearError()
|
||
return
|
||
}
|
||
guard let url = URL(string: "osgkeyboard://\(path)") else {
|
||
handleHostAppOpenResult(path: path, success: false)
|
||
return
|
||
}
|
||
HostAppLauncher.open(url: url, from: self) { [weak self] success in
|
||
self?.handleHostAppOpenResult(path: path, success: success)
|
||
}
|
||
}
|
||
|
||
private func handleHostAppOpenResult(path: String, success: Bool) {
|
||
debug("openHostApp path=\(path) success=\(success)")
|
||
guard !success else { return }
|
||
|
||
// Flow start: auto-jump often fails in WeChat/Safari — keep polling
|
||
// so a manually opened host app can still satisfy the session check.
|
||
if path == "startflow", isPendingFlowStart {
|
||
state.lastTranscript = ExtL10n.string("keyboard.flow.manualOpenHost")
|
||
return
|
||
}
|
||
|
||
if path == "dictate" {
|
||
awaitingDictationResult = false
|
||
stopDictationWatchdog()
|
||
}
|
||
showManualSettingsHint(path: path)
|
||
}
|
||
|
||
private func consumePendingDictationResultIfNeeded() {
|
||
guard let delivery = DictationBridge.consumePendingDelivery() else { return }
|
||
debug("consumePendingDictationResultIfNeeded success")
|
||
handleFinalTranscript(delivery)
|
||
}
|
||
|
||
private func refreshDictationProgressStateIfNeeded() {
|
||
guard awaitingDictationResult, case .processing = state.phase else { return }
|
||
let progress = DictationBridge.currentStatus()
|
||
switch progress.status {
|
||
case .requested:
|
||
state.lastTranscript = state.isLocalEngine
|
||
? ExtL10n.string("keyboard.dictation.openingLocal")
|
||
: ExtL10n.string("keyboard.dictation.opening")
|
||
case .recording:
|
||
state.lastTranscript = state.isLocalEngine
|
||
? ExtL10n.string("keyboard.dictation.recordingLocal")
|
||
: ExtL10n.string("keyboard.dictation.recording")
|
||
case .transcribing:
|
||
state.lastTranscript = state.isLocalEngine
|
||
? ExtL10n.string("keyboard.dictation.transcribingLocal")
|
||
: ExtL10n.string("keyboard.dictation.transcribing")
|
||
case .error:
|
||
let msg = progress.message ?? ExtL10n.string("keyboard.dictation.failed")
|
||
debug("host returned error: \(msg)")
|
||
awaitingDictationResult = false
|
||
stopDictationWatchdog()
|
||
state.phase = .error(.unknown(msg), message: msg)
|
||
scheduleAutoClearError()
|
||
case .cancelled:
|
||
debug("host cancelled")
|
||
awaitingDictationResult = false
|
||
stopDictationWatchdog()
|
||
state.phase = .idle
|
||
case .done, .idle:
|
||
break
|
||
}
|
||
// Host app can be killed or leave without callback. If status does not
|
||
// advance for too long, fail fast with an actionable retry message.
|
||
let now = Date().timeIntervalSince1970
|
||
let lastProgressAt = progress.updatedAt > 0 ? progress.updatedAt : dictationRequestStartedAt
|
||
if now - lastProgressAt > DictationWatchdog.timeout {
|
||
let timeoutMessage = ExtL10n.string("keyboard.dictation.resultTimeout")
|
||
debug("dictation timeout after \(Int(now - lastProgressAt))s")
|
||
awaitingDictationResult = false
|
||
stopDictationWatchdog()
|
||
DictationBridge.clear()
|
||
state.phase = .error(.unknown(timeoutMessage), message: timeoutMessage)
|
||
scheduleAutoClearError()
|
||
}
|
||
}
|
||
|
||
private func showManualSettingsHint(path: String = "settings") {
|
||
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 if state.isLocalEngine {
|
||
msg = ExtL10n.string("keyboard.error.manualOpenDictateLocal")
|
||
} else {
|
||
msg = ExtL10n.string("keyboard.error.manualOpenDictate")
|
||
}
|
||
state.phase = .error(.unknown(msg), message: msg)
|
||
scheduleAutoClearError()
|
||
}
|
||
|
||
private func startDictationWatchdog() {
|
||
stopDictationWatchdog()
|
||
dictationWatchdogTask = Task { @MainActor [weak self] in
|
||
while let self, !Task.isCancelled, self.awaitingDictationResult {
|
||
self.consumePendingDictationResultIfNeeded()
|
||
self.refreshDictationProgressStateIfNeeded()
|
||
try? await Task.sleep(nanoseconds: DictationWatchdog.pollIntervalNs)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func stopDictationWatchdog() {
|
||
dictationWatchdogTask?.cancel()
|
||
dictationWatchdogTask = nil
|
||
}
|
||
|
||
private func scheduleAutoClearError() {
|
||
Task { @MainActor [weak self] in
|
||
try? await Task.sleep(nanoseconds: 2_400_000_000)
|
||
guard let self else { return }
|
||
// Only transient errors auto-clear. `.denied` is sticky: the
|
||
// user needs the message long enough to read it AND decide
|
||
// whether to tap "去设置" or tap the mic to retry. They
|
||
// dismiss it implicitly by doing either of those things.
|
||
switch self.state.phase {
|
||
case .error:
|
||
self.state.phase = .idle
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
private func debug(_ message: String) {
|
||
#if DEBUG
|
||
print("🎙️[KeyboardVC] \(message)")
|
||
#endif
|
||
}
|
||
}
|