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