feat: iCloud settings sync and cold-start return redesign

- Add iCloud key-value settings sync (engine/language/polish/Flow prefs);
  API keys stay on-device. New "Sync settings via iCloud" toggle.
- Redesign cold-start handoff: bottom-bar left-to-right swipe guidance,
  auto-dismiss on app switch, tap-anywhere to close, retained return link.
- Harden keyboard->app handoff with host-disconnected hint.
- Include prior Unreleased ASR fixes (route-change crash, fallback warning,
  multi-utterance recognition, local ASR diagnostics).

Release 0.5.0 (build 18).
This commit is contained in:
Rocky
2026-07-07 17:56:01 +08:00
parent bf844caa7f
commit 128aab1b02
42 changed files with 1818 additions and 337 deletions
+95 -15
View File
@@ -49,6 +49,8 @@ final class FlowSessionManager: ObservableObject {
private var expiryTask: Task<Void, Never>?
private var levelTask: Task<Void, Never>?
private var startTask: Task<Void, Never>?
/// Last recording state the poll loop observed logs only on transition.
private var lastObservedRecordingState: FlowSessionKeys.RecordingState = .idle
private var isUtteranceRecording = false
/// True from `stopped` until the result/error is written back to App Group.
private var isUtteranceProcessing = false
@@ -86,6 +88,8 @@ final class FlowSessionManager: ObservableObject {
isColdStartHandoff = true
}
reconcilePersistedFlowStateBeforeStart()
if isActive {
extendSession(duration: duration)
if coldStart {
@@ -105,6 +109,33 @@ final class FlowSessionManager: ObservableObject {
}
}
/// Clears App Group Flow state left behind when the host process was killed
/// or the device rebooted while the session flag was still set.
private func reconcilePersistedFlowStateBeforeStart() {
if FlowSessionBridge.isHostStale() {
if isActive {
endSession()
} else {
FlowSessionBridge.clearFlowState()
FlowLiveActivityController.endSession()
}
debug("reconciled zombie persisted Flow state")
return
}
guard !isActive else { return }
let orphaned = FlowSessionBridge.recordingState()
switch orphaned {
case .recording, .stopped, .processing:
FlowSessionBridge.setRecordingState(.idle)
FlowSessionBridge.clearPendingTranscription()
debug("cleared orphaned keyboard recording state: \(orphaned.rawValue)")
case .idle, .aborted:
break
}
}
/// Auto-start (or renew) the Flow session on every app foreground when
/// permissions allow the "always auto-open, no off switch" policy. Also
/// clears any orphaned Live Activity a previously force-quit process left
@@ -196,6 +227,9 @@ final class FlowSessionManager: ObservableObject {
writeHeartbeatIfActive()
case .background:
setAppForeground(false)
if coldStartContext != nil {
dismissColdStartOverlay()
}
beginBackgroundKeepAlive()
@unknown default:
break
@@ -328,16 +362,13 @@ final class FlowSessionManager: ObservableObject {
return
}
coldStartContext = FlowColdStartContext(
hostEntry: hostEntry,
showReturnAlert: hostEntry != nil
)
coldStartContext = FlowColdStartContext(hostEntry: hostEntry)
}
private func bindSessionASRIfNeeded(force: Bool = false) {
let engineMode = store.engineMode
if !force,
let sessionASR,
sessionASR != nil,
sessionASREngineMode == engineMode {
return
}
@@ -374,6 +405,11 @@ final class FlowSessionManager: ObservableObject {
private func startPolling() {
pollingTask?.cancel()
lastObservedRecordingState = FlowSessionBridge.recordingState()
FlowDiagnostics.log(
"polling started: initialRecordingState=\(lastObservedRecordingState.rawValue) " +
"container=\(AppGroup.containerPathForDiagnostics)"
)
pollingTask = Task { @MainActor [weak self] in
while !Task.isCancelled {
self?.handleKeyboardSignal()
@@ -383,7 +419,17 @@ final class FlowSessionManager: ObservableObject {
}
private func handleKeyboardSignal() {
switch FlowSessionBridge.recordingState() {
let signal = FlowSessionBridge.recordingState()
if signal != lastObservedRecordingState {
// The single most important cross-process signal: proves whether the
// host actually SEES the keyboard's recording state writes.
FlowDiagnostics.log(
"poll observed recordingState \(lastObservedRecordingState.rawValue)\(signal.rawValue) " +
"[rec=\(isUtteranceRecording) proc=\(isUtteranceProcessing) fg=\(isAppForeground)]"
)
lastObservedRecordingState = signal
}
switch signal {
case .recording:
guard !isUtteranceRecording, !isUtteranceProcessing else { return }
beginUtterance()
@@ -431,7 +477,9 @@ final class FlowSessionManager: ObservableObject {
FlowLiveActivityController.update(phase: .recording)
FlowDiagnostics.log(
"beginUtterance engine=\(store.engineMode) " +
"asrType=\(type(of: asr)) pipelined=true max=\(Int(FlowSessionKeys.maxUtteranceDuration))s"
"asrType=\(type(of: asr)) pipelined=true " +
"localCustomLM=\(store.localASRCustomLanguageModelEnabled) " +
"max=\(Int(FlowSessionKeys.maxUtteranceDuration))s"
)
asrTask = Task.detached(priority: .userInitiated) { [weak manager = self] in
@@ -638,12 +686,18 @@ final class FlowSessionManager: ObservableObject {
// so the keyboard can show the "fill in your key" hint
// inline rather than a generic failure message. The raw
// transcript is still delivered no data loss.
let warning = Self.warningFromPolishError(error, engineMode: engineMode) ?? chunkNote
let fallback = Self.makeFallbackDelivery(
rawText: text,
error: error,
engineMode: engineMode,
chunkWarning: chunkNote
)
FlowDiagnostics.log(
"polish failed after \(String(format: "%.1f", Date().timeIntervalSince(polishStarted)))s: " +
"\(error.localizedDescription)"
)
FlowSessionBridge.storeTranscriptionResult(text, polishWarning: warning)
delivered = fallback.text
FlowSessionBridge.storeTranscriptionResult(fallback.text, polishWarning: fallback.polishWarning)
}
SpeechHistoryStore.shared.recordUtterance(
@@ -684,15 +738,41 @@ final class FlowSessionManager: ObservableObject {
/// v0.2.0: surface the local-mode cloud-polish error path with a
/// localised hint ("please fill in your DeepSeek key in Settings")
/// rather than letting the keyboard show a generic network error.
static func makeFallbackDelivery(
rawText: String,
error: Error,
engineMode: String,
chunkWarning: String?
) -> TranscriptionDelivery {
let fallbackText = TranscriptPostProcessor.cleanRawASRFallback(rawText)
let warning = warningFromPolishError(error, engineMode: engineMode)
?? polishDegradedWarning()
?? chunkWarning
return TranscriptionDelivery(text: fallbackText, polishWarning: warning)
}
private static func warningFromPolishError(_ error: Error, engineMode: String) -> String? {
guard let polishError = error as? PolishingService.PolishError,
polishError == .missingAPIKey else {
return nil
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:
return polishDegradedWarning()
case .noTranscript:
return nil
}
}
if engineMode == "local" {
return AppL10n.string("flow.warning.localPolishUnavailable")
if error is LLMError {
return polishDegradedWarning()
}
return AppL10n.string("flow.warning.cloudPolishMissingKey")
return nil
}
private static func polishDegradedWarning() -> String? {
SharedL10n.string("flow.warning.polishDegraded")
}
private func asrWaitTimeout() -> TimeInterval {