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
@@ -32,10 +32,16 @@ public struct AppGroupConfiguration: Sendable, Equatable {
public static let personalDictionary = "config.personalDictionary.v1"
/// When true, the main app mirrors the personal dictionary via iCloud KVS.
public static let personalDictionaryICloudSyncEnabled = "config.personalDictionary.iCloudSyncEnabled"
/// When true, the main app mirrors user settings via iCloud KVS.
public static let settingsICloudSyncEnabled = "config.settings.iCloudSyncEnabled"
/// Wall-clock stamp of the last settings blob applied from iCloud KVS.
public static let settingsCloudUpdatedAt = "config.settings.cloudUpdatedAt"
/// When true, the host app auto-returns to the source app after a cold-start handoff.
public static let flowSkipAppSwitch = "config.flowSkipAppSwitch"
/// Raw `FlowInactivityDuration` value; session expires after this idle window.
public static let flowInactivityDuration = "config.flowInactivityDuration"
/// Diagnostic switch: when false, local ASR skips the custom language model.
public static let localASRCustomLanguageModelEnabled = "config.localASR.customLanguageModelEnabled"
}
// MARK: - Stored fields
@@ -57,10 +63,14 @@ public struct AppGroupConfiguration: Sendable, Equatable {
public var personalDictionary: PersonalDictionary
/// Opt-in iCloud KVS sync for the personal dictionary (main app only).
public var personalDictionaryICloudSyncEnabled: Bool
/// Opt-in iCloud KVS sync for user settings (main app only).
public var settingsICloudSyncEnabled: Bool
/// Auto-return to the host app after `startflow` cold start (default on).
public var flowSkipAppSwitch: Bool
/// Idle timeout before the Flow session ends; resets on each utterance.
public var flowInactivityDuration: FlowInactivityDuration
/// Whether local `SpeechAnalyzer` should attach the prepared custom language model.
public var localASRCustomLanguageModelEnabled: Bool
// MARK: - Derived
@@ -164,6 +174,12 @@ public struct AppGroupConfiguration: Sendable, Equatable {
}
return defaults.bool(forKey: Keys.personalDictionaryICloudSyncEnabled)
}(),
settingsICloudSyncEnabled: {
if defaults.object(forKey: Keys.settingsICloudSyncEnabled) == nil {
return true
}
return defaults.bool(forKey: Keys.settingsICloudSyncEnabled)
}(),
flowSkipAppSwitch: {
if defaults.object(forKey: Keys.flowSkipAppSwitch) == nil {
return true
@@ -172,7 +188,13 @@ public struct AppGroupConfiguration: Sendable, Equatable {
}(),
flowInactivityDuration: FlowInactivityDuration.fromStored(
defaults.string(forKey: Keys.flowInactivityDuration)
)
),
localASRCustomLanguageModelEnabled: {
if defaults.object(forKey: Keys.localASRCustomLanguageModelEnabled) == nil {
return true
}
return defaults.bool(forKey: Keys.localASRCustomLanguageModelEnabled)
}()
)
let preset = LLMProvider.provider(id: config.providerId)
@@ -222,7 +244,9 @@ public struct AppGroupConfiguration: Sendable, Equatable {
defaults.set(polishIntensity.rawValue, forKey: Keys.polishIntensity)
defaults.set(flowSkipAppSwitch, forKey: Keys.flowSkipAppSwitch)
defaults.set(flowInactivityDuration.rawValue, forKey: Keys.flowInactivityDuration)
defaults.set(localASRCustomLanguageModelEnabled, forKey: Keys.localASRCustomLanguageModelEnabled)
defaults.set(personalDictionaryICloudSyncEnabled, forKey: Keys.personalDictionaryICloudSyncEnabled)
defaults.set(settingsICloudSyncEnabled, forKey: Keys.settingsICloudSyncEnabled)
Self.encodePersonalDictionary(personalDictionary, to: defaults)
}
@@ -78,6 +78,11 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
guard !isApplyingConfiguration,
hasCompletedOnboarding != configuration.hasCompletedOnboarding else { return }
configuration.hasCompletedOnboarding = hasCompletedOnboarding
// Mirror to the reboot-durable Keychain marker so a device restart
// can never resurrect the onboarding flow (or lose a replay reset).
let newValue = hasCompletedOnboarding
OSGLog.config.info("[onboarding] didSet → \(newValue, privacy: .public), mirroring to Keychain")
Keychain.setOnboardingCompleted(hasCompletedOnboarding)
if hasCompletedOnboarding {
configuration.onboardingPage = 0
onboardingPage = 0
@@ -190,6 +195,19 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
}
}
/// Diagnostic switch: disable to isolate whether the custom language model
/// is causing local SpeechAnalyzer to return empty results.
@Published public var localASRCustomLanguageModelEnabled: Bool {
didSet {
guard !isApplyingConfiguration,
localASRCustomLanguageModelEnabled != configuration.localASRCustomLanguageModelEnabled else {
return
}
configuration.localASRCustomLanguageModelEnabled = localASRCustomLanguageModelEnabled
persistConfiguration()
}
}
public var isConfigured: Bool {
// Local engine uses on-device ASR + built-in DeepSeek polish and
// does not need a user API key. Cloud needs base URL, key, and model.
@@ -221,6 +239,28 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
self.defaults = resolvedDefaults
self.configuration = AppGroupConfiguration.load(fromAvailable: resolvedDefaults)
// Onboarding completion must survive a device reboot. App Group
// UserDefaults can transiently read empty right after boot, which would
// falsely re-show onboarding. Trust the durable Keychain marker when the
// App Group value looks unset, and backfill it once the App Group value
// is confirmed true (covers users onboarded before this safeguard).
let appGroupOnboarding = configuration.hasCompletedOnboarding
let keychainOnboarding = Keychain.hasCompletedOnboarding()
// Distinguish "key absent" (nil plist not loaded / data-protection race)
// from "key present == false" (something actually wrote false).
let rawKeyPresent = resolvedDefaults.object(forKey: AppGroupConfiguration.Keys.hasCompletedOnboarding) != nil
OSGLog.config.info(
"[onboarding] init: appGroup=\(appGroupOnboarding, privacy: .public) (keyPresent=\(rawKeyPresent, privacy: .public)), keychain=\(keychainOnboarding, privacy: .public)"
)
if appGroupOnboarding {
Keychain.setOnboardingCompleted(true)
} else if keychainOnboarding {
configuration.hasCompletedOnboarding = true
OSGLog.config.info("[onboarding] init: App Group read false but Keychain true → restored to true")
}
let finalOnboarding = configuration.hasCompletedOnboarding
OSGLog.config.info("[onboarding] init: final=\(finalOnboarding, privacy: .public)")
isApplyingConfiguration = true
providerId = configuration.providerId
baseURL = configuration.baseURL
@@ -239,6 +279,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
polishIntensity = configuration.polishIntensity
flowSkipAppSwitch = configuration.flowSkipAppSwitch
flowInactivityDuration = configuration.flowInactivityDuration
localASRCustomLanguageModelEnabled = configuration.localASRCustomLanguageModelEnabled
isApplyingConfiguration = false
}
@@ -255,6 +296,55 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
if postConfigChanged {
AppGroupConfigDarwin.postConfigChanged()
}
scheduleSettingsCloudPushIfEnabled()
}
/// Re-read App Group defaults after a cloud pull updates the cache.
public func reloadFromPersistedStorage() {
var fresh = AppGroupConfiguration.load(fromAvailable: defaults)
// Keep the reboot-durable onboarding marker authoritative across cloud
// pulls, matching the resilience applied at init.
let freshOnboarding = fresh.hasCompletedOnboarding
let keychainOnboarding = Keychain.hasCompletedOnboarding()
OSGLog.config.info(
"[onboarding] reload: appGroup=\(freshOnboarding, privacy: .public), keychain=\(keychainOnboarding, privacy: .public)"
)
if freshOnboarding {
Keychain.setOnboardingCompleted(true)
} else if keychainOnboarding {
fresh.hasCompletedOnboarding = true
OSGLog.config.info("[onboarding] reload: App Group read false but Keychain true → restored to true")
}
isApplyingConfiguration = true
configuration = fresh
providerId = fresh.providerId
baseURL = fresh.baseURL
model = fresh.model
modeId = fresh.modeId
localeId = fresh.localeId
engineMode = fresh.engineMode
hasCompletedOnboarding = fresh.hasCompletedOnboarding
onboardingPage = fresh.onboardingPage
hasAcknowledgedCloudSharing = fresh.hasAcknowledgedCloudSharing
uiLanguage = fresh.uiLanguage
translationTargetLocaleId = fresh.translationTargetLocaleId
handednessPreference = fresh.handednessPreference
cursorDragNavigationEnabled = fresh.cursorDragNavigationEnabled
polishIntensity = fresh.polishIntensity
flowSkipAppSwitch = fresh.flowSkipAppSwitch
flowInactivityDuration = fresh.flowInactivityDuration
localASRCustomLanguageModelEnabled = fresh.localASRCustomLanguageModelEnabled
isSyncingProviderAPIKey = true
apiKey = fresh.apiKey
isSyncingProviderAPIKey = false
isApplyingConfiguration = false
}
private func scheduleSettingsCloudPushIfEnabled() {
guard configuration.settingsICloudSyncEnabled else { return }
Task { @MainActor in
try? await SettingsCloudSync.shared.pushLocalIfEnabled()
}
}
public func apply(preset: LLMProvider) {
@@ -284,11 +374,13 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
apiKey = ""
model = preset.defaultModel
handednessPreference = .left
localASRCustomLanguageModelEnabled = true
hasAcknowledgedCloudSharing = false
configuration.providerId = preset.id
configuration.baseURL = preset.defaultBaseURL
configuration.model = preset.defaultModel
configuration.handednessPreference = .left
configuration.localASRCustomLanguageModelEnabled = true
configuration.hasAcknowledgedCloudSharing = false
isApplyingConfiguration = false
persistConfiguration()
@@ -0,0 +1,107 @@
// SyncedAppSettings.swift
// OSGKeyboard · Shared
//
// User-facing app settings mirrored through iCloud KVS. Excludes
// device-local state (onboarding progress, detected app context,
// personal dictionary blob, and API keys in Keychain).
import Foundation
public struct SyncedAppSettings: Codable, Sendable, Equatable {
public var updatedAt: Date
public var providerId: String
public var baseURL: String
public var model: String
public var modeId: String
public var localeId: String
public var engineMode: String
public var hasAcknowledgedCloudSharing: Bool
public var uiLanguage: AppUILanguage
public var translationTargetLocaleId: String
public var handednessPreference: HandednessPreference
public var cursorDragNavigationEnabled: Bool
public var polishIntensity: PolishIntensity
public var flowSkipAppSwitch: Bool
public var flowInactivityDuration: FlowInactivityDuration
public init(
updatedAt: Date = Date(),
providerId: String,
baseURL: String,
model: String,
modeId: String,
localeId: String,
engineMode: String,
hasAcknowledgedCloudSharing: Bool,
uiLanguage: AppUILanguage,
translationTargetLocaleId: String,
handednessPreference: HandednessPreference,
cursorDragNavigationEnabled: Bool,
polishIntensity: PolishIntensity,
flowSkipAppSwitch: Bool,
flowInactivityDuration: FlowInactivityDuration
) {
self.updatedAt = updatedAt
self.providerId = providerId
self.baseURL = baseURL
self.model = model
self.modeId = modeId
self.localeId = localeId
self.engineMode = engineMode
self.hasAcknowledgedCloudSharing = hasAcknowledgedCloudSharing
self.uiLanguage = uiLanguage
self.translationTargetLocaleId = translationTargetLocaleId
self.handednessPreference = handednessPreference
self.cursorDragNavigationEnabled = cursorDragNavigationEnabled
self.polishIntensity = polishIntensity
self.flowSkipAppSwitch = flowSkipAppSwitch
self.flowInactivityDuration = flowInactivityDuration
}
}
public extension SyncedAppSettings {
/// Build a cloud payload from the current App Group configuration.
static func from(configuration: AppGroupConfiguration, updatedAt: Date = Date()) -> SyncedAppSettings {
SyncedAppSettings(
updatedAt: updatedAt,
providerId: configuration.providerId,
baseURL: configuration.baseURL,
model: configuration.model,
modeId: configuration.modeId,
localeId: configuration.localeId,
engineMode: configuration.engineMode,
hasAcknowledgedCloudSharing: configuration.hasAcknowledgedCloudSharing,
uiLanguage: configuration.uiLanguage,
translationTargetLocaleId: configuration.translationTargetLocaleId,
handednessPreference: configuration.handednessPreference,
cursorDragNavigationEnabled: configuration.cursorDragNavigationEnabled,
polishIntensity: configuration.polishIntensity,
flowSkipAppSwitch: configuration.flowSkipAppSwitch,
flowInactivityDuration: configuration.flowInactivityDuration
)
}
/// Apply syncable fields onto a configuration, preserving device-local
/// fields such as onboarding progress and the personal dictionary.
func applying(to configuration: inout AppGroupConfiguration) {
configuration.providerId = providerId
configuration.baseURL = baseURL
configuration.model = model
configuration.modeId = modeId
configuration.localeId = localeId
configuration.engineMode = engineMode
configuration.hasAcknowledgedCloudSharing = hasAcknowledgedCloudSharing
configuration.uiLanguage = uiLanguage
configuration.translationTargetLocaleId = translationTargetLocaleId
configuration.handednessPreference = handednessPreference
configuration.cursorDragNavigationEnabled = cursorDragNavigationEnabled
configuration.polishIntensity = polishIntensity
configuration.flowSkipAppSwitch = flowSkipAppSwitch
configuration.flowInactivityDuration = flowInactivityDuration
}
/// Last-write-wins merge for whole settings blobs.
static func merge(local: SyncedAppSettings, remote: SyncedAppSettings) -> SyncedAppSettings {
remote.updatedAt >= local.updatedAt ? remote : local
}
}