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:
@@ -72,7 +72,10 @@ final class KeyboardConfigSync {
|
||||
|
||||
func syncOnboardingStateFromAppGroup() {
|
||||
let store = AppGroupStore()
|
||||
state.hasCompletedOnboarding = store.hasCompletedOnboarding
|
||||
// Fall back to the reboot-durable Keychain marker so a device restart
|
||||
// does not resurrect the in-keyboard onboarding overlay when the App
|
||||
// Group value transiently reads empty.
|
||||
state.hasCompletedOnboarding = store.hasCompletedOnboarding || Keychain.hasCompletedOnboarding()
|
||||
state.onboardingPage = store.onboardingPage
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ final class KeyboardFlowCoordinator {
|
||||
private var wasFlowSessionActive = false
|
||||
private var flowSessionMonitorTask: Task<Void, Never>?
|
||||
private var isAwaitingFlowResult = false
|
||||
private var lastFlowAutoStartAttempt: TimeInterval = 0
|
||||
private static let flowAutoStartCooldown: TimeInterval = 20
|
||||
|
||||
init(
|
||||
state: KeyboardState,
|
||||
@@ -87,10 +89,16 @@ final class KeyboardFlowCoordinator {
|
||||
refreshFlowPartialIfNeeded()
|
||||
consumePendingFlowDeliveryIfNeeded()
|
||||
|
||||
let active = FlowSessionBridge.isSessionActive()
|
||||
state.flowSessionActive = active
|
||||
recoverFromDeadHostIfNeeded()
|
||||
|
||||
if wasFlowSessionActive && !active && !isFlowRecording && !isPendingFlowStart {
|
||||
if FlowSessionBridge.clearIfHostStale() {
|
||||
debug("cleared zombie Flow session from App Group")
|
||||
}
|
||||
|
||||
let reachable = FlowSessionBridge.isHostReachable()
|
||||
state.flowSessionActive = reachable
|
||||
|
||||
if wasFlowSessionActive && !reachable && !isFlowRecording && !isPendingFlowStart {
|
||||
switch state.phase {
|
||||
case .recording, .processing:
|
||||
break
|
||||
@@ -98,7 +106,9 @@ final class KeyboardFlowCoordinator {
|
||||
showFlowSessionExpiredHint()
|
||||
}
|
||||
}
|
||||
wasFlowSessionActive = active
|
||||
wasFlowSessionActive = reachable
|
||||
|
||||
maybeAutoStartFlowSession()
|
||||
}
|
||||
|
||||
func toggleRecording() {
|
||||
@@ -135,7 +145,13 @@ final class KeyboardFlowCoordinator {
|
||||
|
||||
detectAndStoreAppContext()
|
||||
|
||||
if FlowSessionBridge.isSessionActive() {
|
||||
let reachable = FlowSessionBridge.isHostReachable()
|
||||
debug(
|
||||
"pressBegan hostReachable=\(reachable) " +
|
||||
"staleness=\(FlowSessionBridge.heartbeatStaleness().map { String(format: "%.1f", $0) } ?? "nil") " +
|
||||
"container=\(AppGroup.containerPathForDiagnostics)"
|
||||
)
|
||||
if reachable {
|
||||
startFlowRecording()
|
||||
} else {
|
||||
beginFlowStart()
|
||||
@@ -153,6 +169,7 @@ final class KeyboardFlowCoordinator {
|
||||
stopUtteranceCountdown()
|
||||
ExtensionScreenWakeLock.release()
|
||||
FlowSessionBridge.setRecordingState(.stopped)
|
||||
debug("pressEnded wrote .stopped (readback=\(FlowSessionBridge.recordingState().rawValue))")
|
||||
state.phase = .processing
|
||||
state.lastTranscript = ExtL10n.string("keyboard.flow.transcribing")
|
||||
startFlowResultWatchdog()
|
||||
@@ -225,11 +242,63 @@ final class KeyboardFlowCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
if isPendingFlowStart, FlowSessionBridge.isSessionActive() {
|
||||
if isPendingFlowStart, FlowSessionBridge.isHostReachable() {
|
||||
completeFlowStartHandoff()
|
||||
}
|
||||
}
|
||||
|
||||
/// When the host process died mid-utterance, abort local recording / waiting
|
||||
/// so the user is not stuck until the long result watchdog fires.
|
||||
private func recoverFromDeadHostIfNeeded() {
|
||||
guard FlowSessionBridge.isHostStale() else { return }
|
||||
|
||||
if isFlowRecording {
|
||||
isFlowRecording = false
|
||||
stopUtteranceCountdown()
|
||||
ExtensionScreenWakeLock.release()
|
||||
FlowSessionBridge.setRecordingState(.aborted)
|
||||
stopFlowWatchdog()
|
||||
state.level = 0
|
||||
state.phase = .idle
|
||||
state.lastTranscript = ""
|
||||
debug("aborted recording — host heartbeat zombie")
|
||||
return
|
||||
}
|
||||
|
||||
if isAwaitingFlowResult {
|
||||
failHostDisconnected()
|
||||
}
|
||||
}
|
||||
|
||||
/// Restores the pre-ABCD behaviour: when the keyboard appears and the host
|
||||
/// is not reachable, automatically jump to the main app to start Flow.
|
||||
private func maybeAutoStartFlowSession() {
|
||||
guard !FlowSessionBridge.isHostReachable() 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 failHostDisconnected() {
|
||||
isAwaitingFlowResult = false
|
||||
isFlowRecording = false
|
||||
isPendingFlowStart = false
|
||||
stopUtteranceCountdown()
|
||||
ExtensionScreenWakeLock.release()
|
||||
FlowSessionBridge.setRecordingState(.aborted)
|
||||
stopFlowWatchdog()
|
||||
state.level = 0
|
||||
let message = ExtL10n.string("keyboard.flow.hostDisconnected")
|
||||
state.phase = .error(.flowSessionExpired, message: message)
|
||||
scheduleAutoClearError()
|
||||
debug("host disconnected while awaiting Flow result")
|
||||
}
|
||||
|
||||
private func showFlowSessionExpiredHint() {
|
||||
let message = ExtL10n.string("keyboard.flow.sessionExpired")
|
||||
state.phase = .error(.flowSessionExpired, message: message)
|
||||
@@ -252,6 +321,10 @@ final class KeyboardFlowCoordinator {
|
||||
}
|
||||
|
||||
private func startFlowRecording() {
|
||||
guard FlowSessionBridge.isHostReachable() else {
|
||||
beginFlowStart()
|
||||
return
|
||||
}
|
||||
isPendingFlowStart = false
|
||||
flowStartDeadline = 0
|
||||
stopFlowWatchdog()
|
||||
@@ -266,7 +339,9 @@ final class KeyboardFlowCoordinator {
|
||||
}
|
||||
startUtteranceCountdown()
|
||||
startFlowLevelWatchdog()
|
||||
debug("startFlowRecording")
|
||||
// Read back in-process to confirm the write landed before we rely on
|
||||
// the host polling it out cross-process.
|
||||
debug("startFlowRecording wrote .recording (readback=\(FlowSessionBridge.recordingState().rawValue))")
|
||||
}
|
||||
|
||||
private func startUtteranceCountdown() {
|
||||
@@ -305,7 +380,7 @@ final class KeyboardFlowCoordinator {
|
||||
stopFlowWatchdog()
|
||||
flowWatchdogTask = Task { @MainActor [weak self] in
|
||||
while let self, !Task.isCancelled, self.isPendingFlowStart {
|
||||
if FlowSessionBridge.isSessionActive() {
|
||||
if FlowSessionBridge.isHostReachable() {
|
||||
self.completeFlowStartHandoff()
|
||||
return
|
||||
}
|
||||
@@ -362,17 +437,20 @@ final class KeyboardFlowCoordinator {
|
||||
isAwaitingFlowResult = true
|
||||
let startedAt = Date().timeIntervalSince1970
|
||||
let resultTimeout = FlowWatchdog.resultTimeout(engineMode: state.engineMode)
|
||||
debug("resultWatchdog started timeout=\(Int(resultTimeout))s engine=\(state.engineMode)")
|
||||
flowWatchdogTask = Task { @MainActor [weak self] in
|
||||
while let self, !Task.isCancelled {
|
||||
if let delivery = FlowSessionBridge.consumeTranscriptionDelivery() {
|
||||
self.isAwaitingFlowResult = false
|
||||
self.stopFlowWatchdog()
|
||||
self.debug("resultWatchdog consumed delivery len=\(delivery.text.count)")
|
||||
self.textInserter.handleFlowTranscript(delivery)
|
||||
return
|
||||
}
|
||||
if let error = FlowSessionBridge.consumeTranscriptionError() {
|
||||
self.isAwaitingFlowResult = false
|
||||
self.stopFlowWatchdog()
|
||||
self.debug("resultWatchdog consumed error kind=\(error.kind.rawValue)")
|
||||
self.state.phase = .error(
|
||||
.fromFlowTranscription(error),
|
||||
message: error.message
|
||||
@@ -382,9 +460,22 @@ final class KeyboardFlowCoordinator {
|
||||
}
|
||||
self.refreshFlowPartialIfNeeded()
|
||||
let now = Date().timeIntervalSince1970
|
||||
let staleness = FlowSessionBridge.heartbeatStaleness() ?? .infinity
|
||||
if staleness > FlowSessionKeys.heartbeatZombieInterval {
|
||||
self.debug("resultWatchdog: host heartbeat zombie (staleness=\(String(format: "%.1f", staleness))s)")
|
||||
self.failHostDisconnected()
|
||||
return
|
||||
}
|
||||
if !FlowSessionBridge.isHostReachable(),
|
||||
now - startedAt > FlowSessionKeys.keyboardHostDisconnectFailFast {
|
||||
self.debug("resultWatchdog: host unreachable after \(String(format: "%.1f", now - startedAt))s")
|
||||
self.failHostDisconnected()
|
||||
return
|
||||
}
|
||||
if now - startedAt > resultTimeout {
|
||||
self.isAwaitingFlowResult = false
|
||||
self.stopFlowWatchdog()
|
||||
self.debug("resultWatchdog TIMEOUT after \(Int(resultTimeout))s — no result from host")
|
||||
let msg = ExtL10n.string("keyboard.flow.resultTimeout")
|
||||
self.state.phase = .error(.flowResultTimeout, message: msg)
|
||||
self.scheduleAutoClearError()
|
||||
|
||||
Reference in New Issue
Block a user