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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user