feat: harden Flow cold-start/force-quit and polish macOS dictation UX

Fix cold-start overlay recursion that overflowed the main-thread stack when
recording began while the ready overlay was still up; also remove temporary
on-screen Flow DEBUG panels after the orange-mic investigation, and land the
macOS overlay/catalog/layout polish plus related Flow recovery hardening.
This commit is contained in:
Rocky
2026-07-10 12:39:41 +08:00
parent dcb66a9849
commit cdf833935a
104 changed files with 5794 additions and 853 deletions
@@ -0,0 +1,25 @@
// ASRChunkTranscribing.swift
// OSGKeyboard · Shared
//
// Minimal ASR surface for pipelined utterance chunking. Keeps
// `ChunkedUtterancePipeline` independent of iOS-only `SpeechAnalyzer`.
import Foundation
public enum ASRChunkResult: Sendable, Equatable {
case success(String)
case failure(String)
case cancelled
}
/// One-shot chunk transcription used by `ChunkedUtterancePipeline`.
public protocol ASRChunkTranscribing: Sendable {
func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult
func cancel()
func resetForNewUtterance()
}
extension ASRChunkTranscribing {
public func cancel() {}
public func resetForNewUtterance() {}
}
+1 -7
View File
@@ -30,7 +30,7 @@ extension AVAudioPCMBuffer: @unchecked @retroactive Sendable {}
// MARK: - Protocol
public protocol ASRService: Sendable {
public protocol ASRService: ASRChunkTranscribing, Sendable {
/// Start a transcription session. The returned stream emits `.partial`
/// updates and exactly one `.final` (or `.error`) before finishing.
/// `SpeechAnalyzer` is always fully on-device, so there is no
@@ -54,12 +54,6 @@ public protocol ASRService: Sendable {
func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult
}
public enum ASRChunkResult: Sendable, Equatable {
case success(String)
case failure(String)
case cancelled
}
extension ASRService {
public func resetForNewUtterance() {}
@@ -53,6 +53,10 @@ public struct AppGroupStore: @unchecked Sendable {
public var baseURL: String { configuration.baseURL }
public var apiKey: String { configuration.apiKey }
public var model: String { configuration.model }
public var asrProviderId: String { configuration.asrProviderId }
public var asrBaseURL: String { configuration.resolvedASRBaseURL }
public var asrApiKey: String { configuration.asrApiKey }
public var asrModel: String { configuration.resolvedASRModel }
public var modeId: String { configuration.modeId }
public var localeId: String { configuration.localeId }
public var engineMode: String { configuration.engineMode }
@@ -91,6 +95,12 @@ public struct AppGroupStore: @unchecked Sendable {
config.baseURL = openAI.defaultBaseURL
config.model = openAI.defaultModel
}
if mode == "cloud", config.asrProviderId == "deepseek" {
let openAI = LLMProvider.provider(id: "openai")
config.asrProviderId = openAI.id
config.asrBaseURL = openAI.defaultBaseURL
config.asrModel = CloudASRModelCatalog.defaultModel(for: openAI.id)
}
}
AppGroupConfigDarwin.postConfigChanged()
}
@@ -67,13 +67,13 @@ private actor ChunkWorkQueue {
}
public actor ChunkedUtterancePipeline {
private let asr: ASRService
private let asr: any ASRChunkTranscribing
private let locale: Locale
private let config: FlowUtteranceChunkConfig
private var cancelled = false
public init(
asr: ASRService,
asr: any ASRChunkTranscribing,
locale: Locale,
config: FlowUtteranceChunkConfig = .flowDefault
) {
@@ -17,31 +17,35 @@ public protocol CloudASRTranscribing: Sendable {
public enum CloudASRClientFactory {
public static func make(store: any ConfigurationStore, session: URLSession = .shared) -> CloudASRTranscribing {
let strategy = CloudASRModelCatalog.strategy(for: store.providerId)
let providerId = store.asrProviderId
let strategy = CloudASRModelCatalog.strategy(for: providerId)
let asrModel = store.asrModel.isEmpty
? CloudASRModelCatalog.defaultModel(for: providerId)
: store.asrModel
switch strategy {
case .zhipuHotwords:
return ZhipuCloudASRClient(
apiKey: store.apiKey,
model: CloudASRModelCatalog.defaultModel(for: store.providerId),
apiKey: store.asrApiKey,
model: asrModel,
session: session
)
case .alibabaVocabulary:
return AlibabaFunASRClient(
apiKey: store.apiKey,
model: CloudASRModelCatalog.defaultModel(for: store.providerId),
apiKey: store.asrApiKey,
model: asrModel,
persistence: store.cloudASRPersistence,
session: session
)
case .prompt:
return PromptCloudASRClient(
providerId: store.providerId,
baseURL: store.baseURL,
apiKey: store.apiKey,
model: CloudASRModelCatalog.defaultModel(for: store.providerId),
providerId: providerId,
baseURL: store.asrBaseURL,
apiKey: store.asrApiKey,
model: asrModel,
session: session
)
case .localFallback:
return UnsupportedCloudASRClient(providerId: store.providerId)
return UnsupportedCloudASRClient(providerId: providerId)
}
}
}
@@ -133,7 +133,7 @@ public final class CloudASRService: ASRService, @unchecked Sendable {
}
private func bindClientIfNeeded() {
let providerId = store.providerId
let providerId = store.asrProviderId
let strategy = CloudASRModelCatalog.strategy(for: providerId)
lock.withLock {
guard boundProviderId != providerId else { return }
@@ -70,7 +70,9 @@ public final class CustomLanguageModelManager: @unchecked Sendable {
/// Fire-and-forget preparation for the host app. Safe to call repeatedly.
/// Retries after exponential backoff when a prior attempt failed.
public func prepareInBackgroundIfNeeded() {
#if os(iOS)
guard AppGroup.isAvailable else { return }
#endif
let shouldStart = lock.withLock { () -> Bool in
if case .preparing = state { return false }
@@ -167,8 +169,8 @@ public final class CustomLanguageModelManager: @unchecked Sendable {
throw PrepareError.missingPreparedArtifacts
}
AppGroup.defaultsIfAvailable?.set(fingerprint, forKey: Storage.fingerprintKey)
AppGroup.defaultsIfAvailable?.set(Date().timeIntervalSince1970, forKey: Storage.preparedAtKey)
Self.persistenceDefaults.set(fingerprint, forKey: Storage.fingerprintKey)
Self.persistenceDefaults.set(Date().timeIntervalSince1970, forKey: Storage.preparedAtKey)
Self.clearRetryState()
lock.withLock {
@@ -180,8 +182,9 @@ public final class CustomLanguageModelManager: @unchecked Sendable {
return configuration
}
// MARK: - DictationTranscriber factory
// MARK: - DictationTranscriber factory (iOS host app)
#if os(iOS)
public static func makeDictationTranscriber(
locale: Locale,
lmConfiguration: SFSpeechLanguageModel.Configuration?
@@ -202,6 +205,34 @@ public final class CustomLanguageModelManager: @unchecked Sendable {
attributeOptions: preset.attributeOptions
)
}
#endif
// MARK: - Legacy Speech request (macOS Apple Speech fallback)
/// Up to 100 short phrases for `SFSpeechRecognitionRequest.contextualStrings`.
public static func contextualStringsForRecognition(
bias: LocalASRBiasPayload?,
maxCount: Int = 100
) -> [String] {
guard let bias, !bias.hardHotwords.isEmpty else { return [] }
return Array(bias.hardHotwords.prefix(max(1, maxCount)))
}
/// Applies bundled CLM + optional contextual strings to a legacy on-device request.
public static func applyCustomLanguageModel(
to request: SFSpeechURLRecognitionRequest,
locale: Locale,
bias: LocalASRBiasPayload?
) {
request.requiresOnDeviceRecognition = true
if let configuration = shared.configurationForTranscription(locale: locale) {
request.customizedLanguageModel = configuration
}
let phrases = contextualStringsForRecognition(bias: bias)
if !phrases.isEmpty {
request.contextualStrings = phrases
}
}
// MARK: - Bundle / disk helpers
@@ -238,14 +269,28 @@ public final class CustomLanguageModelManager: @unchecked Sendable {
}
static func preparedDirectoryURL() -> URL? {
guard let container = FileManager.default.containerURL(
if let container = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: AppGroup.identifier
) else {
) {
let directory = container.appendingPathComponent(Storage.subdirectory, isDirectory: true)
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
return directory
}
#if os(macOS)
guard let appSupport = FileManager.default.urls(
for: .applicationSupportDirectory,
in: .userDomainMask
).first else {
return nil
}
let directory = container.appendingPathComponent(Storage.subdirectory, isDirectory: true)
let directory = appSupport
.appendingPathComponent("OSGKeyboard", isDirectory: true)
.appendingPathComponent(Storage.subdirectory, isDirectory: true)
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
return directory
#else
return nil
#endif
}
static func loadCachedConfigurationFromDisk() -> SFSpeechLanguageModel.Configuration? {
@@ -279,7 +324,7 @@ public final class CustomLanguageModelManager: @unchecked Sendable {
}
private static func storedFingerprint() -> String? {
AppGroup.defaultsIfAvailable?.string(forKey: Storage.fingerprintKey)
persistenceDefaults.string(forKey: Storage.fingerprintKey)
}
private static func removeItemIfExists(at url: URL) throws {
@@ -310,26 +355,28 @@ public final class CustomLanguageModelManager: @unchecked Sendable {
// MARK: - Retry / backoff
private static var persistenceDefaults: UserDefaults {
AppGroup.defaultsIfAvailable ?? .standard
}
private static func storedAttemptCount() -> Int {
AppGroup.defaultsIfAvailable?.integer(forKey: Storage.attemptCountKey) ?? 0
persistenceDefaults.integer(forKey: Storage.attemptCountKey)
}
private static func storedLastFailureAt() -> TimeInterval? {
let value = AppGroup.defaultsIfAvailable?.double(forKey: Storage.lastFailureAtKey) ?? 0
let value = persistenceDefaults.double(forKey: Storage.lastFailureAtKey)
return value > 0 ? value : nil
}
private static func recordFailure() {
guard let defaults = AppGroup.defaultsIfAvailable else { return }
let nextAttempt = storedAttemptCount() + 1
defaults.set(nextAttempt, forKey: Storage.attemptCountKey)
defaults.set(Date().timeIntervalSince1970, forKey: Storage.lastFailureAtKey)
persistenceDefaults.set(nextAttempt, forKey: Storage.attemptCountKey)
persistenceDefaults.set(Date().timeIntervalSince1970, forKey: Storage.lastFailureAtKey)
}
private static func clearRetryState() {
guard let defaults = AppGroup.defaultsIfAvailable else { return }
defaults.removeObject(forKey: Storage.attemptCountKey)
defaults.removeObject(forKey: Storage.lastFailureAtKey)
persistenceDefaults.removeObject(forKey: Storage.attemptCountKey)
persistenceDefaults.removeObject(forKey: Storage.lastFailureAtKey)
}
/// Returns false when retry budget is exhausted or backoff has not elapsed.
@@ -159,28 +159,78 @@ private final class FlowAudioProofStore: @unchecked Sendable {
/// incoming buffer's format actually changes, so downsampling to the ASR target
/// rate is always valid regardless of route churn.
private final class AdaptiveDownsampler: @unchecked Sendable {
// `AVAudioConverter` / `AVAudioFormat` are not `Sendable`, so the state and
// the returned converter are guarded manually via the unchecked lock APIs.
private let lock = OSAllocatedUnfairLock<(converter: AVAudioConverter, source: AVAudioFormat)?>(uncheckedState: nil)
// `AVAudioConverter` / `AVAudioFormat` / `AVAudioPCMBuffer` are not
// `Sendable`, so the state is guarded manually via the unchecked lock
// APIs. The scratch output buffer is REUSED across tap callbacks
// allocating on the realtime audio thread risks priority inversion, and
// taps on one bus are serialized, so a single scratch is safe as long as
// callers copy its contents out before returning (AudioBufferSnapshot
// does exactly that).
private struct State {
var converter: AVAudioConverter
var source: AVAudioFormat
var scratch: AVAudioPCMBuffer
}
private let lock = OSAllocatedUnfairLock<State?>(uncheckedState: nil)
let targetFormat: AVAudioFormat
/// Frame headroom for the reusable output buffer. Taps deliver 4096
/// input frames; output frames = input × (16k / hardwareRate), which
/// exceeds input only for sub-16 kHz hardware (rare telephony routes),
/// so 2× the tap size covers every realistic ratio.
private static let scratchCapacity: AVAudioFrameCount = 8_192
init(targetFormat: AVAudioFormat) {
self.targetFormat = targetFormat
}
/// Returns a converter valid for `sourceFormat`, rebuilding it lazily when
/// the hardware route (and thus the buffer format) changes.
func converter(for sourceFormat: AVAudioFormat) -> AVAudioConverter? {
lock.withLockUnchecked { state in
if let state, state.source == sourceFormat {
return state.converter
/// Downsamples `buffer` into the reusable scratch buffer and returns it,
/// rebuilding the converter lazily when the hardware route (and thus the
/// source format) changes. The returned buffer is only valid until the
/// next call copy its samples out synchronously.
func convertReusingScratch(_ buffer: AVAudioPCMBuffer) -> AVAudioPCMBuffer? {
let sourceFormat = buffer.format
guard sourceFormat.sampleRate > 0 else { return nil }
return lock.withLockUnchecked { state -> AVAudioPCMBuffer? in
if state == nil || state!.source != sourceFormat {
guard let converter = AVAudioConverter(from: sourceFormat, to: targetFormat),
let scratch = AVAudioPCMBuffer(
pcmFormat: targetFormat,
frameCapacity: Self.scratchCapacity
) else {
state = nil
return nil
}
state = State(converter: converter, source: sourceFormat, scratch: scratch)
}
guard let converter = AVAudioConverter(from: sourceFormat, to: targetFormat) else {
state = nil
return nil
guard let current = state else { return nil }
let wanted = AVAudioFrameCount(
Double(buffer.frameLength) * targetFormat.sampleRate / sourceFormat.sampleRate
)
guard wanted > 0, wanted <= current.scratch.frameCapacity else { return nil }
current.scratch.frameLength = 0
// ONE-SHOT input: the converter keeps pulling until the output
// buffer's frameCapacity is full, and the scratch is deliberately
// oversized feeding the same tap buffer on every pull would
// duplicate the audio ~6× (stuttering ASR input). After the
// single feed we report "ran dry", so the expected status is
// `.inputRanDry` (output not full), not `.haveData`.
var provided = false
var error: NSError?
let status = current.converter.convert(to: current.scratch, error: &error) { _, outStatus in
if provided {
outStatus.pointee = .noDataNow
return nil
}
provided = true
outStatus.pointee = .haveData
return buffer
}
state = (converter, sourceFormat)
return converter
guard status != .error, error == nil, current.scratch.frameLength > 0 else { return nil }
return current.scratch
}
}
}
@@ -240,6 +290,10 @@ public final class FlowContinuousCapture {
private var didInstallTap = false
private var isRunning = false
private var isRebuilding = false
private var interrupted = false
/// When the engine last (re)activated a freshly started engine has
/// produced no frames yet and must not be misclassified as a zombie.
private var lastActivationAt = Date.distantPast
private var routeObserver: NSObjectProtocol?
private var interruptionObserver: NSObjectProtocol?
@@ -250,6 +304,11 @@ public final class FlowContinuousCapture {
public var running: Bool { isRunning }
/// True between interruption `.began` and `.ended` (phone call, Siri).
/// While set, `setActive(true)` is guaranteed to fail owners should
/// wait for `.ended` (which rebuilds the engine) instead of retrying.
public var isInterrupted: Bool { interrupted }
/// True when the capture session flag, tap, and audio engine are all live.
public var engineIsLive: Bool {
isRunning && didInstallTap && audioEngine.isRunning
@@ -264,9 +323,33 @@ public final class FlowContinuousCapture {
/// Called on the main actor when `engineIsLive` may have changed.
public var onEngineLiveChanged: ((Bool) -> Void)?
/// Called on the main actor when the system interrupted capture (phone
/// call, Siri). The session owner should fail any mic-open utterance
/// audio frames stop arriving, so continuing to "record" only captures
/// a silence gap the user cannot see.
public var onInterruptionBegan: (() -> Void)?
/// Configure `.playAndRecord`, install a permanent input tap, start the engine.
///
/// Idempotent: "already running and healthy" is a warm-start fast path,
/// while "already running but producing no audio" is a zombie state
/// (force-quit relaunch, failed cold start, mediaserverd reset) that is
/// torn down and rebuilt in place. It must never be a silent no-op
/// a `guard !isRunning` early-return here turned every cold-start retry
/// into a guaranteed audio-proof timeout.
public func start() throws {
guard !isRunning else { return }
if isRunning {
let startedMomentsAgo = Date().timeIntervalSince(lastActivationAt) < 2
if engineIsLive && (engineHasRecentAudio(maxAge: 2) || startedMomentsAgo) {
// Healthy warm engine or one so fresh it simply hasn't
// produced its first frame yet (interleaved start attempts
// land here; rebuilding a 100 ms-old engine only multiplies
// audio-session churn in the fragile post-relaunch window).
return
}
log.info("start(): zombie engine detected (running but no live audio) — forcing rebuild")
stop()
}
audioProofStore.reset()
try activateEngine()
isRunning = true
@@ -353,6 +436,7 @@ public final class FlowContinuousCapture {
} catch {
throw StartError.engineStartFailed(error.localizedDescription)
}
lastActivationAt = Date()
}
/// Tear down the engine and release the audio session.
@@ -371,6 +455,7 @@ public final class FlowContinuousCapture {
audioEngine.stop()
}
isRunning = false
interrupted = false
audioProofStore.reset()
downsampler = nil
targetFormat = nil
@@ -384,6 +469,13 @@ public final class FlowContinuousCapture {
/// Re-activate capture after returning from background without
/// reinstalling the tap (iOS may deactivate the audio session).
///
/// Doubles as the interruption-recovery probe: `setActive(true)` FAILS
/// while a call/Siri interruption is live and succeeds once it ends, so a
/// successful reassert proves the interruption is over. iOS does not
/// guarantee delivery of `.ended` (commonly dropped when the app was
/// suspended during the call), so this is the only reliable way to clear
/// the `interrupted` latch in that case.
@discardableResult
public func reassertIfRunning() -> Bool {
guard isRunning else { return false }
@@ -395,6 +487,7 @@ public final class FlowContinuousCapture {
options: [.defaultToSpeaker, .allowBluetoothHFP, .mixWithOthers]
)
try session.setActive(true, options: .notifyOthersOnDeactivation)
interrupted = false
if !audioEngine.isRunning {
try audioEngine.start()
}
@@ -415,7 +508,14 @@ public final class FlowContinuousCapture {
if engineHasRecentAudio(maxAge: recentFrameMaxAge) {
return true
}
try? await Task.sleep(nanoseconds: 50_000_000)
do {
try await Task.sleep(nanoseconds: 50_000_000)
} catch {
// Cancelled bail out instead of busy-spinning the main
// actor for the rest of the window (a cancelled Task.sleep
// returns immediately, starving concurrent start attempts).
return false
}
}
return engineHasRecentAudio(maxAge: recentFrameMaxAge)
}
@@ -497,8 +597,11 @@ public final class FlowContinuousCapture {
switch type {
case .began:
log.info("Audio interruption began")
interrupted = true
notifyEngineLiveChanged()
onInterruptionBegan?()
case .ended:
interrupted = false
guard isRunning else { return }
let shouldResume: Bool
if let optionsRaw {
@@ -627,26 +730,12 @@ public final class FlowContinuousCapture {
audioProofStore.markFrameReceived()
levelStore.update(from: buffer, barCount: FlowCaptureConstants.levelBarCount)
// Derive the converter from the *live* buffer format so a mid-session
// route change (e.g. 48 kHz 24 kHz) is handled transparently.
let sourceFormat = buffer.format
let targetFormat = downsampler.targetFormat
guard sourceFormat.sampleRate > 0,
let converter = downsampler.converter(for: sourceFormat) else { return }
let outFrames = AVAudioFrameCount(
Double(buffer.frameLength) * targetFormat.sampleRate / sourceFormat.sampleRate
)
guard outFrames > 0,
let outBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: outFrames)
else { return }
var error: NSError?
let status = converter.convert(to: outBuffer, error: &error) { _, outStatus in
outStatus.pointee = .haveData
return buffer
}
guard status == .haveData, error == nil, outBuffer.frameLength > 0 else { return }
// The downsampler derives its converter from the *live* buffer
// format (mid-session route changes handled transparently) and
// returns a REUSED scratch buffer no per-callback allocation
// on the realtime thread. The snapshot below copies the samples
// out before the next tap callback can overwrite the scratch.
guard let outBuffer = downsampler.convertReusingScratch(buffer) else { return }
let snapshot = AudioBufferSnapshot(buffer: outBuffer)
guard !snapshot.samples.isEmpty else { return }
@@ -130,6 +130,12 @@ public struct FlowReadySnapshot: Codable, Equatable, Sendable {
public let localeId: String
public let busyUtteranceId: UUID?
public let sessionExpiresAt: TimeInterval?
/// Host process generation that wrote this snapshot. A snapshot whose
/// generation no longer matches `FlowSessionKeys.hostGeneration` was
/// written by a dead process and is void immediately no need to wait
/// out the heartbeat-zombie window. Optional for wire compatibility with
/// snapshots written before this field existed.
public let hostGeneration: String?
public init(
protocolVersion: Int = 1,
@@ -142,7 +148,8 @@ public struct FlowReadySnapshot: Codable, Equatable, Sendable {
engineMode: String,
localeId: String,
busyUtteranceId: UUID? = nil,
sessionExpiresAt: TimeInterval? = nil
sessionExpiresAt: TimeInterval? = nil,
hostGeneration: String? = nil
) {
self.protocolVersion = protocolVersion
self.sessionId = sessionId
@@ -155,6 +162,7 @@ public struct FlowReadySnapshot: Codable, Equatable, Sendable {
self.localeId = localeId
self.busyUtteranceId = busyUtteranceId
self.sessionExpiresAt = sessionExpiresAt
self.hostGeneration = hostGeneration
}
}
@@ -266,13 +274,27 @@ public enum FlowSessionBridge {
store.set(readyAt, forKey: FlowSessionKeys.flowHostReadyAt)
}
} else {
// Keep the not-ready payload. The keyboard needs `reason`
// (recording / processing / waitingForAudioProof / ) to tell
// "host is busy" apart from "host is still starting". Deleting
// the payload here forced every mid-utterance ready=false into
// a permanent orange `preparingSession` state.
clearHostReady(defaults: store, notify: false)
store.removeObject(forKey: FlowSessionKeys.flowReadyPayload)
}
if let expires = snapshot.sessionExpiresAt {
store.set(expires, forKey: FlowSessionKeys.flowSessionExpires)
}
store.set(snapshot.heartbeatAt, forKey: FlowSessionKeys.flowHeartbeat)
// Only a genuinely live host ready, or actively serving an
// utterance may refresh the heartbeat here. A host stuck in a
// failed cold start would otherwise keep "reviving" itself on every
// engine-state flap, flickering the keyboard between reachable and
// dead and postponing zombie-state cleanup indefinitely.
let provesHostAlive = snapshot.ready
|| snapshot.reason == .recording
|| snapshot.reason == .processing
if provesHostAlive {
store.set(snapshot.heartbeatAt, forKey: FlowSessionKeys.flowHeartbeat)
}
flush(store)
FlowSessionDarwin.postHostReadyChanged()
}
@@ -309,7 +331,8 @@ public enum FlowSessionBridge {
heartbeatAt: now,
engineMode: AppGroupConfiguration.load(fromAvailable: store).engineMode,
localeId: AppGroupConfiguration.load(fromAvailable: store).localeId,
sessionExpiresAt: expires
sessionExpiresAt: expires,
hostGeneration: store.string(forKey: FlowSessionKeys.hostGeneration)
)
if let data = encode(snapshot) {
store.set(data, forKey: FlowSessionKeys.flowReadyPayload)
@@ -418,6 +441,54 @@ public enum FlowSessionBridge {
return staleness <= FlowSessionKeys.heartbeatStaleInterval
}
// MARK: - Host process generation
/// Host app: rotate the per-process generation token. Call exactly once,
/// as early as possible in the host launch path. Returns the previous
/// generation (nil on first-ever launch) so the caller can log it.
///
/// Rationale: `applicationWillTerminate` is best-effort it never runs
/// when a *suspended* app is force-quit (the common case after a failed
/// cold start). Instead of anchoring cleanup on a termination callback
/// that may not fire, each launch proves the previous process is dead and
/// voids whatever session state it left behind.
@discardableResult
public static func rotateHostGeneration(defaults: UserDefaults? = nil) -> String? {
let store = resolvedDefaults(defaults)
let previous = store.string(forKey: FlowSessionKeys.hostGeneration)
store.set(UUID().uuidString, forKey: FlowSessionKeys.hostGeneration)
flush(store)
return previous
}
public static func currentHostGeneration(defaults: UserDefaults? = nil) -> String? {
let store = resolvedDefaults(defaults)
return store.string(forKey: FlowSessionKeys.hostGeneration)
}
/// Host launch reconciliation: clear every piece of persisted session
/// state a previous (dead) generation left behind. Unlike
/// `clearFlowState()` this keeps `pendingHostBundleId` on a keyboard
/// `startflow` cold launch the scene delegate stores the host bundle id
/// *before* the SwiftUI hierarchy (and thus the session manager) exists,
/// and wiping it here would break the return-to-host affordance.
public static func clearFlowStateOnHostLaunch(defaults: UserDefaults? = nil) {
let store = resolvedDefaults(defaults)
store.set(false, forKey: FlowSessionKeys.flowSessionActive)
store.removeObject(forKey: FlowSessionKeys.flowSessionExpires)
store.removeObject(forKey: FlowSessionKeys.flowHeartbeat)
store.removeObject(forKey: FlowSessionKeys.keyboardRecordingState)
store.removeObject(forKey: FlowSessionKeys.flowCommandPayload)
store.removeObject(forKey: FlowSessionKeys.flowResultPayload)
store.removeObject(forKey: FlowSessionKeys.flowAckPayload)
store.removeObject(forKey: FlowSessionKeys.flowReadyPayload)
clearTranscription(defaults: store)
store.removeObject(forKey: FlowSessionKeys.audioLevels)
store.removeObject(forKey: FlowSessionKeys.lastActivityAt)
clearHostReady(defaults: store, notify: false)
flush(store)
}
// MARK: - Host ready contract (host app keyboard)
/// Host app: publish whether Flow can accept a new utterance right now.
@@ -446,6 +517,13 @@ public enum FlowSessionBridge {
let store = resolvedDefaults(defaults)
if let snapshot = readySnapshot(defaults: store) {
guard snapshot.ready else { return false }
// Snapshot written by a dead host generation void immediately,
// without waiting out the heartbeat-zombie window.
if let snapshotGeneration = snapshot.hostGeneration,
let currentGeneration = store.string(forKey: FlowSessionKeys.hostGeneration),
snapshotGeneration != currentGeneration {
return false
}
guard isHostReachable(defaults: store) else { return false }
if let readyAt = snapshot.readyAt {
let skew = abs(snapshot.heartbeatAt - readyAt)
@@ -33,6 +33,11 @@ public enum FlowSessionKeys {
public static let pendingHostBundleId = "flow.pendingHostBundleId"
/// Wall-clock timestamp of the last utterance completion or session start.
public static let lastActivityAt = "flow.lastActivityAt"
/// One-shot token rotated by every host-process launch. State written by
/// a previous generation is void by definition a fresh launch proves the
/// previous process is dead, whether or not its `applicationWillTerminate`
/// cleanup ever ran (it does NOT run when a suspended app is force-quit).
public static let hostGeneration = "flow.hostGeneration.v1"
/// Heartbeat older than this host is not actively reachable for recording.
public static let heartbeatStaleInterval: TimeInterval = 3
@@ -59,14 +64,23 @@ public enum FlowSessionKeys {
public static let localASRWaitTimeout: TimeInterval = 120
public static let cloudASRWaitTimeout: TimeInterval = 120
/// Keyboard watchdog after the user stops recording (not utterance max length).
/// Must cover worst-case post-stop backlog: remaining SpeechAnalyzer chunks
/// plus cloud LLM polish (see `PolishingService.effectiveTimeout` cap).
/// Hard cap on a single LLM polish request. `PolishingService`'s scaled
/// per-request timeout clamps to this value, so it participates in the
/// keyboard-watchdog budget below.
public static let maxPolishTimeout: TimeInterval = 120
/// Extra slack for result serialization, cross-process propagation, and
/// the host's own polling cadence.
public static let resultDeliveryMargin: TimeInterval = 20
/// Keyboard watchdog after the user stops recording (not utterance max
/// length). Derived from the host-side budget so it always outlasts the
/// host's worst case (ASR drain wait + polish cap + margin) hand-tuned
/// constants drifted below the real host maximum, making the keyboard
/// report a timeout for transcriptions that were still going to succeed.
public static func keyboardResultTimeout(engineMode: String) -> TimeInterval {
if engineMode == "local" {
return 180
}
return 240
let asrWait = engineMode == "local" ? localASRWaitTimeout : cloudASRWaitTimeout
return asrWait + maxPolishTimeout + resultDeliveryMargin
}
public enum RecordingState: String, Sendable, Equatable {
@@ -39,20 +39,48 @@ public final class AppCloudSync {
?? SpeechHistoryCloudSync(kvs: kvs, makeStore: makeStore, historyDefaults: historyDefaults)
}
/// Serializes external-change pulls: KVS posts change notifications in
/// bursts (one per key at times), and overlapping pull-merge-apply runs
/// can interleave their read/write phases. `wantsAnotherPull` coalesces
/// every burst into at most one trailing re-pull.
private var isPulling = false
private var wantsAnotherPull = false
public func startObservingExternalChanges() {
guard externalChangeObserver == nil else { return }
externalChangeObserver = NotificationCenter.default.addObserver(
forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
object: nil,
queue: .main
) { [weak self] _ in
) { [weak self] note in
guard let self else { return }
// Distinguish WHY the store changed. `.accountChange` means the
// user switched iCloud accounts the incoming values belong to a
// DIFFERENT account and must not be merged into this one's data
// (deleted-entry resurrection, foreign history, wrong settings).
let reason = note.userInfo?[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int
if reason == NSUbiquitousKeyValueStoreAccountChange {
return
}
Task { @MainActor in
await self.pullAllIfEnabled()
await self.pullAllCoalesced()
}
}
}
private func pullAllCoalesced() async {
guard !isPulling else {
wantsAnotherPull = true
return
}
isPulling = true
defer { isPulling = false }
repeat {
wantsAnotherPull = false
await pullAllIfEnabled()
} while wantsAnotherPull
}
public func stopObservingExternalChanges() {
if let externalChangeObserver {
NotificationCenter.default.removeObserver(externalChangeObserver)
@@ -79,18 +107,27 @@ public final class AppCloudSync {
}
/// Low-risk manual sync: pull remote changes, merge, then push local state.
/// Each push runs independently one payload failing must not abort the
/// others (a too-large history would otherwise also kill the dictionary
/// push). The first error is rethrown after every push has been tried.
public func syncNow() async throws {
let store = makeStore()
await pullAllIfEnabled()
var firstError: Error?
func attempt(_ body: () async throws -> Void) async {
do { try await body() } catch { if firstError == nil { firstError = error } }
}
if store.settingsICloudSyncEnabled {
try await settingsSync.pushLocalIfEnabled()
try await usageStatisticsSync.pushLocalIfEnabled()
try await speechHistorySync.pushLocalIfEnabled()
await attempt { try await settingsSync.pushLocalIfEnabled() }
await attempt { try await usageStatisticsSync.pushLocalIfEnabled() }
await attempt { try await speechHistorySync.pushLocalIfEnabled() }
}
if store.personalDictionaryICloudSyncEnabled {
try await dictionarySync.pushLocalIfEnabled(store.personalDictionary)
await attempt { try await dictionarySync.pushLocalIfEnabled(store.personalDictionary) }
}
if let firstError { throw firstError }
}
public var settingsSyncService: SettingsCloudSync { settingsSync }
@@ -24,8 +24,12 @@ public final class SpeechHistoryCloudSync {
public static let kvsKey = SyncedSpeechHistory.kvsKey
public static let legacyKVSKey = SyncedSpeechHistory.legacyKVSKey
/// Stay below the ~1 MB per-key KVS limit.
public static let maxPayloadBytes = 900_000
/// The 1 MB iCloud KVS quota is for the WHOLE store, not per key.
/// History and the personal dictionary must fit together (plus settings
/// and usage stats) once the store exceeds 1 MB, KVS rejects writes
/// for ALL keys with `QuotaViolation` and every sync silently stops.
/// Budget: ~400 KB history + ~400 KB dictionary + headroom for the rest.
public static let maxPayloadBytes = 400_000
private let kvs: UbiquitousKeyValueStoreing
private let makeStore: () -> AppGroupStore
@@ -50,8 +54,16 @@ public final class SpeechHistoryCloudSync {
public func pushLocalIfEnabled() async throws {
let store = makeStore()
guard store.settingsICloudSyncEnabled else { return }
let local = SpeechHistoryStorage.load(from: historyDefaults())
try push(local)
// Read-merge-write: pushing the local view verbatim would overwrite
// entries another device added since our last pull (KVS is
// last-writer-wins with no server-side merge).
let defaults = historyDefaults()
let local = SpeechHistoryStorage.load(from: defaults)
let merged = loadRemote().map { SyncedSpeechHistory.merge(local: local, remote: $0) } ?? local
if merged != local {
apply(merged, to: defaults, postNotification: true)
}
try push(merged)
}
/// Called when settings sync is first enabled to union local + remote history.
@@ -81,11 +93,34 @@ public final class SpeechHistoryCloudSync {
}
public func push(_ history: SyncedSpeechHistory) throws {
let data = try encode(history)
let data = try encodeFittingBudget(history)
kvs.set(data, forKey: Self.kvsKey)
_ = kvs.synchronize()
}
/// Encode, dropping the oldest entries until the payload fits the KVS
/// budget. Without this, a history that once fit under the old 900 KB
/// cap (300 long dictations easily exceed 400 KB) would make EVERY push
/// throw forever automatic pushes are fire-and-forget, so sync would
/// just silently die with no way back short of clearing all history.
/// Only the *uploaded* copy is trimmed; local history keeps its full
/// 300 entries.
func encodeFittingBudget(_ history: SyncedSpeechHistory) throws -> Data {
var payload = history
while true {
do {
return try encode(payload)
} catch SpeechHistoryCloudSyncError.payloadTooLarge {
guard payload.entries.count > 1 else { throw SpeechHistoryCloudSyncError.payloadTooLarge(byteCount: 0) }
// Drop the oldest ~10% per pass; entries are kept
// newest-first by the store, so trim from the tail.
let sorted = payload.entries.sorted { $0.createdAt > $1.createdAt }
let keep = max(1, sorted.count - max(1, sorted.count / 10))
payload.entries = Array(sorted.prefix(keep))
}
}
}
public func loadRemote() -> SyncedSpeechHistory? {
if let data = kvs.data(forKey: Self.kvsKey) {
return try? decode(data)
@@ -45,8 +45,16 @@ public final class UsageStatisticsCloudSync {
public func pushLocalIfEnabled() async throws {
let store = makeStore()
guard store.settingsICloudSyncEnabled else { return }
// Read-merge-write: this fires after every utterance, so pushing the
// local view verbatim would clobber counter slices another device
// advanced since our last pull (KVS is last-writer-wins). The
// G-Counter merge makes the push commutative instead.
let local = SyncedUsageStatisticsStorage.load(from: store.defaults)
try push(local)
let merged = loadRemote().map { SyncedUsageStatisticsV2.merge(local: local, remote: $0) } ?? local
if merged != local {
apply(merged, to: store.defaults, postNotification: true)
}
try push(merged)
}
/// Called when settings sync is first enabled to union local + remote totals.
+45 -2
View File
@@ -91,8 +91,11 @@ public final class KeyboardState: ObservableObject {
@Published public var micDisabled: Bool = false
/// One-line helper shown above the mic while `micDisabled == true`.
@Published public var micDisabledHint: String = ""
/// "local" on-device ASR only. "cloud" ASR + LLM polish.
@Published public var engineMode: String = "cloud"
/// "local" on-device ASR only. "cloud" cloud ASR + LLM polish.
/// Boot value must match the privacy-safe app default (`local`) so the
/// keyboard never assumes the audio-uploading engine before the App
/// Group config has been read.
@Published public var engineMode: String = "local"
/// v0.2.1 follow-up: derived translation is on iff a target
/// locale has been selected (mirrors `ProviderConfig.translationEnabled`
/// so the chip / pipeline read the same source of truth).
@@ -148,6 +151,46 @@ public final class KeyboardState: ObservableObject {
case openSettings
}
// MARK: - Temporary Flow debug (remove after orange-mic investigation)
/// Mirrored from `KeyboardFlowCoordinator` for the on-screen debug panel.
@Published public var debugPendingFlowStart: Bool = false
@Published public var debugFlowRecording: Bool = false
@Published public var debugAwaitingFlowResult: Bool = false
@Published public var debugHasFullAccess: Bool = false
/// Snapshot for the keyboard debug panel.
public func makeFlowDebugRows(hasFullAccess: Bool) -> [FlowDebugRow] {
debugHasFullAccess = hasFullAccess
let micLabel: String = {
switch micVoiceAvailability {
case .ready: return "ready"
case .recording: return "recording"
case .processing: return "processing"
case .unavailable(let reason):
switch reason {
case .hostNotReady: return "unavailable(hostNotReady)"
case .preparingSession: return "unavailable(preparingSession)"
case .noFullAccess: return "unavailable(noFullAccess)"
case .appGroupUnavailable: return "unavailable(appGroupUnavailable)"
case .missingAPIKey: return "unavailable(missingAPIKey)"
}
}
}()
let localRows: [FlowDebugRow] = [
FlowDebugRow("mic", micLabel),
FlowDebugRow("phase", String(describing: phase)),
FlowDebugRow("pendingStart", debugPendingFlowStart ? "1" : "0"),
FlowDebugRow("kb.recording", debugFlowRecording ? "1" : "0"),
FlowDebugRow("kb.awaiting", debugAwaitingFlowResult ? "1" : "0"),
FlowDebugRow("fullAccess", hasFullAccess ? "1" : "0"),
FlowDebugRow("micDisabled", micDisabled ? "1" : "0"),
FlowDebugRow("flowSessionPub", flowSessionActive ? "1" : "0"),
FlowDebugRow("engine", engineMode)
]
return localRows + FlowDebugAppGroupSnapshot.rows()
}
// Action hooks injected by the view controller at install time.
public var beginRecording: () -> Void = {}
public var endRecording: () -> Void = {}
+192 -8
View File
@@ -22,12 +22,29 @@ public enum Keychain: @unchecked Sendable {
private static let legacyAccount = "current"
private static let defaultProviderId = "openai"
private static func account(for providerId: String) -> String {
private static func normalizedProviderId(_ providerId: String) -> String {
let trimmed = providerId.trimmingCharacters(in: .whitespacesAndNewlines)
let normalized = trimmed.isEmpty ? defaultProviderId : trimmed.lowercased()
return "provider.\(normalized)"
return trimmed.isEmpty ? defaultProviderId : trimmed.lowercased()
}
/// LLM polish credentials (`provider.<id>`).
private static func account(for providerId: String) -> String {
"provider.\(normalizedProviderId(providerId))"
}
/// Cloud ASR credentials (`asr.<id>`), independent from polish keys.
private static func asrAccount(for providerId: String) -> String {
"asr.\(normalizedProviderId(providerId))"
}
// NOTE on kSecAttrAccessGroup: we deliberately rely on the DEFAULT
// access group (the first entry in each target's keychain-access-groups,
// which project.yml pins to `$(AppIdentifierPrefix)com.osgkeyboard.shared`
// for every target). Setting the attribute explicitly would require the
// team-prefixed string at runtime, which is not portably available
// without injecting TeamID through the build system. If a SECOND access
// group is ever added to any target, revisit this reordered groups
// would silently change which store these queries hit.
private static func baseQuery(providerId: String, synchronizable: Bool) -> [String: Any] {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
@@ -43,6 +60,130 @@ public enum Keychain: @unchecked Sendable {
// MARK: - Read
// MARK: - ASR keys
public static func asrApiKey(for providerId: String, preferICloudSync: Bool = false) -> String? {
if preferICloudSync, let synced = readASRKey(providerId: providerId, synchronizable: true) {
return synced
}
if let local = readASRKey(providerId: providerId, synchronizable: false) {
return local
}
if preferICloudSync {
return readASRKey(providerId: providerId, synchronizable: true)
}
return nil
}
public static func asrApiKeyOutcome(
for providerId: String,
preferICloudSync: Bool = false
) -> ReadOutcome {
let first = readASRKeyOutcome(providerId: providerId, synchronizable: preferICloudSync)
if case .found = first { return first }
let second = readASRKeyOutcome(providerId: providerId, synchronizable: !preferICloudSync)
if case .found = second { return second }
if case .unavailable = first { return first }
if case .unavailable = second { return second }
return .notFound
}
public static func setASRAPIKey(_ key: String, for providerId: String, useICloudSync: Bool = false) throws {
if key.isEmpty {
try deleteASRAPIKey(for: providerId, useICloudSync: useICloudSync)
return
}
if useICloudSync {
try writeASRKey(key, providerId: providerId, synchronizable: true)
try? deleteASRKey(providerId: providerId, synchronizable: false)
} else {
try writeASRKey(key, providerId: providerId, synchronizable: false)
}
}
public static func deleteASRAPIKey(for providerId: String, useICloudSync: Bool = false) throws {
try deleteASRKey(providerId: providerId, synchronizable: false)
if useICloudSync {
try deleteASRKey(providerId: providerId, synchronizable: true)
}
}
private static func readASRKey(providerId: String, synchronizable: Bool) -> String? {
if case .found(let value) = readASRKeyOutcome(providerId: providerId, synchronizable: synchronizable) {
return value
}
return nil
}
private static func readASRKeyOutcome(providerId: String, synchronizable: Bool) -> ReadOutcome {
var query = baseASRQuery(providerId: providerId, synchronizable: synchronizable)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
switch status {
case errSecSuccess:
guard let data = result as? Data,
let str = String(data: data, encoding: .utf8) else {
return .notFound
}
return .found(str)
case errSecItemNotFound:
// Pre-split installs stored one key under `provider.<id>` for both stages.
return readKeyOutcome(providerId: providerId, synchronizable: synchronizable)
default:
#if DEBUG
print("⚠️ [OSGKeyboard] ASR Keychain read returned OSStatus \(status); reporting unavailable.")
#endif
return .unavailable(status)
}
}
private static func baseASRQuery(providerId: String, synchronizable: Bool) -> [String: Any] {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: asrAccount(for: providerId),
kSecAttrSynchronizable as String: synchronizable ? kCFBooleanTrue! : kCFBooleanFalse!,
]
#if os(macOS)
query[kSecUseDataProtectionKeychain as String] = true
#endif
return query
}
private static func writeASRKey(_ key: String, providerId: String, synchronizable: Bool) throws {
let data = Data(key.utf8)
var baseQuery = baseASRQuery(providerId: providerId, synchronizable: synchronizable)
let updateAttrs: [String: Any] = [kSecValueData as String: data]
let updateStatus = SecItemUpdate(baseQuery as CFDictionary, updateAttrs as CFDictionary)
switch updateStatus {
case errSecSuccess:
return
case errSecItemNotFound:
baseQuery[kSecValueData as String] = data
baseQuery[kSecAttrAccessible as String] = synchronizable
? kSecAttrAccessibleAfterFirstUnlock
: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let addStatus = SecItemAdd(baseQuery as CFDictionary, nil)
if addStatus != errSecSuccess {
throw KeychainError.unexpectedStatus(addStatus)
}
default:
throw KeychainError.unexpectedStatus(updateStatus)
}
}
private static func deleteASRKey(providerId: String, synchronizable: Bool) throws {
let query = baseASRQuery(providerId: providerId, synchronizable: synchronizable)
let status = SecItemDelete(query as CFDictionary)
if status != errSecSuccess, status != errSecItemNotFound {
throw KeychainError.unexpectedStatus(status)
}
}
// MARK: - LLM keys
public static func apiKey(for providerId: String, preferICloudSync: Bool = false) -> String? {
if preferICloudSync, let synced = readKey(providerId: providerId, synchronizable: true) {
return synced
@@ -60,7 +201,43 @@ public enum Keychain: @unchecked Sendable {
apiKey(for: defaultProviderId)
}
/// Distinguishes "no key stored" from "keychain temporarily unreadable".
public enum ReadOutcome: Equatable {
case found(String)
case notFound
/// The keychain could not be read (e.g. `errSecInteractionNotAllowed`
/// while the device is locked before first unlock). NOT the same as
/// "no key configured" telling the user to re-enter their key in
/// this state would be wrong; the read succeeds once unlocked.
case unavailable(OSStatus)
}
/// Like `apiKey(for:)`, but reports WHY a key was not returned so
/// callers can distinguish a missing key (user action needed) from a
/// transiently locked keychain (retry later).
public static func apiKeyOutcome(
for providerId: String,
preferICloudSync: Bool = false
) -> ReadOutcome {
let first = readKeyOutcome(providerId: providerId, synchronizable: preferICloudSync)
if case .found = first { return first }
let second = readKeyOutcome(providerId: providerId, synchronizable: !preferICloudSync)
if case .found = second { return second }
// Neither store had it: surface "unavailable" when either read was
// blocked, since the key may well exist behind the lock.
if case .unavailable = first { return first }
if case .unavailable = second { return second }
return .notFound
}
private static func readKey(providerId: String, synchronizable: Bool) -> String? {
if case .found(let value) = readKeyOutcome(providerId: providerId, synchronizable: synchronizable) {
return value
}
return nil
}
private static func readKeyOutcome(providerId: String, synchronizable: Bool) -> ReadOutcome {
var query = baseQuery(providerId: providerId, synchronizable: synchronizable)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
@@ -70,16 +247,16 @@ public enum Keychain: @unchecked Sendable {
case errSecSuccess:
guard let data = result as? Data,
let str = String(data: data, encoding: .utf8) else {
return nil
return .notFound
}
return str
return .found(str)
case errSecItemNotFound:
return nil
return .notFound
default:
#if DEBUG
print("⚠️ [OSGKeyboard] Keychain read returned OSStatus \(status); treating as no key.")
print("⚠️ [OSGKeyboard] Keychain read returned OSStatus \(status); reporting unavailable.")
#endif
return nil
return .unavailable(status)
}
}
@@ -193,6 +370,13 @@ public enum Keychain: @unchecked Sendable {
try? writeKey(local, providerId: provider.id, synchronizable: true)
try? deleteKey(providerId: provider.id, synchronizable: false)
}
for provider in LLMProvider.asrSelectablePresets {
guard let local = readASRKey(providerId: provider.id, synchronizable: false), !local.isEmpty else {
continue
}
try? writeASRKey(local, providerId: provider.id, synchronizable: true)
try? deleteASRKey(providerId: provider.id, synchronizable: false)
}
}
// MARK: - Onboarding completion (reboot-durable flag)
@@ -26,8 +26,12 @@ public final class PersonalDictionaryCloudSync {
public static let kvsKey = PersonalDictionary.kvsKeyV2
public static let legacyKVSKey = PersonalDictionary.legacyKVSKey
/// Stay below the ~1 MB per-key KVS limit.
public static let maxPayloadBytes = 900_000
/// The 1 MB iCloud KVS quota covers the WHOLE store, not one key
/// this payload shares it with speech history, settings, and usage
/// stats. Exceeding the total quota makes KVS reject writes for ALL
/// keys (`QuotaViolation`), silently stopping every sync.
/// Budget: ~400 KB dictionary + ~400 KB history + headroom.
public static let maxPayloadBytes = 400_000
private let kvs: UbiquitousKeyValueStoreing
private let makeStore: () -> AppGroupStore
@@ -75,7 +79,14 @@ public final class PersonalDictionaryCloudSync {
public func pushLocalIfEnabled(_ dictionary: PersonalDictionary) async throws {
let store = makeStore()
guard store.personalDictionaryICloudSyncEnabled else { return }
try push(dictionary)
// Read-merge-write: KVS is last-writer-wins; uploading the local
// view verbatim would drop entries another device added since our
// last pull. Tombstones in `merge` keep deletions intact.
let merged = loadRemote().map { PersonalDictionary.merge(local: dictionary, remote: $0) } ?? dictionary
if merged != dictionary {
store.setPersonalDictionary(merged)
}
try push(merged)
}
/// Enable sync: merge local + remote, persist locally, then upload.
@@ -10,8 +10,8 @@
// English dictation while halving the network round-trip.
//
// Engine matrix:
// - `engineMode == "cloud"` provider cloud ASR + user's cloud LLM
// - `engineMode == "local"` on-device ASR + built-in DeepSeek
// - `engineMode == "cloud"` user's cloud ASR + user's cloud LLM (independent)
// - `engineMode == "local"` on-device ASR + user's LLM (or built-in DeepSeek)
// - Ultra-short, structure-free utterances skip the LLM entirely
// - Cloud without API key raw + `.missingAPIKey` warning
// - Local without build key raw + `.missingAPIKey` warning
@@ -37,6 +37,10 @@ public actor PolishingService {
/// Local engine DeepSeek step: `PreconfiguredKeys.deepseek` is
/// still the repo placeholder, or cloud engine Keychain is empty.
case missingAPIKey
/// The keychain was unreadable (device locked before first unlock)
/// the key likely EXISTS; treat as transient, never as "please
/// re-enter your API key".
case keychainLocked
}
/// v0.2.1: what the LLM should do with the raw transcript. The
@@ -95,8 +99,13 @@ public actor PolishingService {
return TranscriptPostProcessor.localClean(trimmed)
}
if store.engineMode == "cloud", injectedClient == nil {
guard !store.apiKey.isEmpty else {
if injectedClient == nil {
let providerId = Self.resolvedProviderId(store: store, providerIdOverride: providerIdOverride)
let hasPolishKey = Self.hasPolishAPIKey(store: store, providerId: providerId)
guard hasPolishKey else {
if case .unavailable = Keychain.apiKeyOutcome(for: providerId, preferICloudSync: true) {
throw PolishError.keychainLocked
}
throw PolishError.missingAPIKey
}
}
@@ -150,10 +159,14 @@ public actor PolishingService {
)
let apiKey: String
if effectiveProviderId == "deepseek" {
guard PreconfiguredKeys.isDeepseekConfigured else {
let userKey = store.apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
if !userKey.isEmpty {
apiKey = userKey
} else if PreconfiguredKeys.isDeepseekConfigured {
apiKey = PreconfiguredKeys.deepseek
} else {
throw PolishError.missingAPIKey
}
apiKey = PreconfiguredKeys.deepseek
} else {
apiKey = store.apiKey
}
@@ -356,7 +369,11 @@ public actor PolishingService {
/// (unpolished, unsegmented) ASR text.
internal func effectiveTimeout(for text: String) -> TimeInterval {
let scaled = timeout + (Double(text.count) / 100.0) * 10.0
return min(max(scaled, timeout), 120)
// The cap participates in the keyboard-watchdog budget see
// `FlowSessionKeys.keyboardResultTimeout`. Raising it here without
// going through that constant would silently break the invariant
// "keyboard timeout > host worst case".
return min(max(scaled, timeout), FlowSessionKeys.maxPolishTimeout)
}
internal static func resolvedProviderId(
@@ -366,11 +383,25 @@ public actor PolishingService {
if let providerIdOverride {
return providerIdOverride
}
if store.engineMode == "local" {
let id = store.providerId
// Local installs without a user LLM key keep using the built-in DeepSeek path.
if store.engineMode == "local",
id != "deepseek",
store.apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
PreconfiguredKeys.isDeepseekConfigured {
return "deepseek"
}
let id = store.providerId
return id == "deepseek" ? "openai" : id
return id == "deepseek" && store.engineMode == "cloud" ? "openai" : id
}
internal static func hasPolishAPIKey(store: any ConfigurationStore, providerId: String) -> Bool {
if !store.apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return true
}
if providerId == "deepseek", PreconfiguredKeys.isDeepseekConfigured {
return true
}
return false
}
internal static func resolveLLMEndpoint(
@@ -396,6 +427,8 @@ extension PolishingService.PolishError: LocalizedError {
return "LLM polish timed out."
case .missingAPIKey:
return "Missing API key (cloud: Settings API key; local: build configuration)."
case .keychainLocked:
return "API key unavailable while the device is locked — will work after unlock."
}
}
}
@@ -34,6 +34,7 @@ public final class SpeechHistoryStore: ObservableObject {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
rebaseOnPersistedStateBeforeMutation()
let entry = SpeechHistoryEntry(text: trimmed, engineMode: engineMode)
payload.entries.insert(entry, at: 0)
payload.trimEntries()
@@ -42,6 +43,7 @@ public final class SpeechHistoryStore: ObservableObject {
}
public func delete(id: UUID) {
rebaseOnPersistedStateBeforeMutation()
guard payload.entries.contains(where: { $0.id == id }) else { return }
payload.deletedEntryIDs[id] = Date()
payload.entries.removeAll { $0.id == id }
@@ -51,12 +53,24 @@ public final class SpeechHistoryStore: ObservableObject {
}
public func clearAll() {
rebaseOnPersistedStateBeforeMutation()
payload.recordClearAll()
payload.updatedAt = Date()
payload.pruneTombstonesIfNeeded()
applyPayload(postCloudPush: true)
}
/// Cloud pulls write the merged history to disk but only *schedule* the
/// in-memory reload (the notification observer hops through a Task).
/// Mutating a stale snapshot and saving it wholesale would erase whatever
/// that merge just brought in always rebase on the persisted state
/// before mutating.
private func rebaseOnPersistedStateBeforeMutation() {
let disk = SpeechHistoryStorage.load(from: defaults)
guard disk != payload else { return }
payload = SyncedSpeechHistory.merge(local: payload, remote: disk)
}
public func snapshot() -> SyncedSpeechHistory {
payload
}
@@ -0,0 +1,47 @@
// TranscriptionPolishFallback.swift
// OSGKeyboard · Shared
//
// Shared polish-failure handling: conservative raw ASR cleanup plus
// bilingual user-visible warnings (iOS Flow + macOS dictation).
import Foundation
public enum TranscriptionPolishFallback: Sendable {
public static func makeDelivery(
rawText: String,
error: Error,
engineMode: String,
chunkWarning: String?
) -> TranscriptionDelivery {
let fallbackText = TranscriptPostProcessor.cleanRawASRFallback(rawText)
let warning = warning(for: error, engineMode: engineMode)
?? degradedWarning()
?? chunkWarning
return TranscriptionDelivery(text: fallbackText, polishWarning: warning)
}
public static func warning(for error: Error, engineMode: String) -> String? {
if let polishError = error as? PolishingService.PolishError {
switch polishError {
case .missingAPIKey:
if engineMode == "local" {
return SharedL10n.string("flow.warning.localPolishUnavailable")
}
return SharedL10n.string("flow.warning.cloudPolishMissingKey")
case .timeout, .keychainLocked:
return degradedWarning()
case .noTranscript:
return nil
}
}
if error is LLMError {
return degradedWarning()
}
return nil
}
public static func degradedWarning() -> String? {
SharedL10n.string("flow.warning.polishDegraded")
}
}