feat: harden Flow cold-start/force-quit and polish macOS dictation UX

Fix cold-start overlay recursion that overflowed the main-thread stack when
recording began while the ready overlay was still up; also remove temporary
on-screen Flow DEBUG panels after the orange-mic investigation, and land the
macOS overlay/catalog/layout polish plus related Flow recovery hardening.
This commit is contained in:
Rocky
2026-07-10 12:39:41 +08:00
parent dcb66a9849
commit cdf833935a
104 changed files with 5794 additions and 853 deletions
@@ -16,6 +16,10 @@ public struct AppGroupConfiguration: Sendable, Equatable {
/// Legacy plaintext slot migrated to Keychain on first read.
public static let apiKeyLegacy = "config.apiKey"
public static let model = "config.model"
/// Cloud ASR provider independent from polish `providerId`.
public static let asrProviderId = "config.asrProviderId"
public static let asrBaseURL = "config.asrBaseURL"
public static let asrModel = "config.asrModel"
public static let modeId = "config.modeId"
public static let localeId = "config.localeId"
public static let engineMode = "config.engineMode"
@@ -51,6 +55,10 @@ public struct AppGroupConfiguration: Sendable, Equatable {
public var providerId: String
public var baseURL: String
public var model: String
/// Cloud-engine speech-to-text provider (OpenLess-style split from polish).
public var asrProviderId: String
public var asrBaseURL: String
public var asrModel: String
public var modeId: String
public var localeId: String
public var engineMode: String
@@ -95,18 +103,32 @@ public struct AppGroupConfiguration: Sendable, Equatable {
: .polish
}
/// Local engine pins the LLM step to DeepSeek; cloud uses the user's provider.
public var polishProviderIdOverride: String? {
engineMode == "local" ? "deepseek" : nil
}
/// Polish LLM provider. Local engine no longer pins DeepSeek user picks in Settings.
public var polishProviderIdOverride: String? { nil }
public var isCloudAPIKeyMissingForVoiceInput: Bool {
public var isCloudLLMKeyMissing: Bool {
guard engineMode == "cloud" else { return false }
return apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
/// API key lives in the Keychain (cross-process, encrypted at rest).
/// When settings iCloud sync is on, reads synchronizable Keychain items first.
public var isCloudASRKeyMissing: Bool {
guard engineMode == "cloud" else { return false }
return asrApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
public var isPolishKeyMissing: Bool {
if !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return false
}
return !PreconfiguredKeys.isDeepseekConfigured
}
public var isCloudAPIKeyMissingForVoiceInput: Bool {
guard engineMode == "cloud" else { return false }
return isCloudASRKeyMissing || isCloudLLMKeyMissing
}
/// Polish LLM uses `providerId` + Keychain `provider.<id>`.
public var apiKey: String {
Self.resolveAPIKey(
defaults: nil,
@@ -115,6 +137,15 @@ public struct AppGroupConfiguration: Sendable, Equatable {
)
}
/// Cloud ASR uses `asrProviderId` + Keychain `asr.<id>` (falls back to legacy `provider.<id>`).
public var asrApiKey: String {
Self.resolveASRAPIKey(
defaults: nil,
providerId: asrProviderId,
preferICloudSync: settingsICloudSyncEnabled
)
}
public func makeClient() -> LLMClient {
OpenAICompatibleClient(
baseURL: baseURL,
@@ -123,6 +154,20 @@ public struct AppGroupConfiguration: Sendable, Equatable {
)
}
/// Resolved cloud ASR model user override or catalog default.
public var resolvedASRModel: String {
let trimmed = asrModel.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { return trimmed }
return CloudASRModelCatalog.defaultModel(for: asrProviderId)
}
/// Resolved cloud ASR base URL for prompt-style providers.
public var resolvedASRBaseURL: String {
let trimmed = asrBaseURL.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { return trimmed }
return LLMProvider.provider(id: asrProviderId).defaultBaseURL
}
// MARK: - Detected app context
public func detectedAppContext(from defaults: UserDefaults) -> (context: AppContext, observedAt: Date)? {
@@ -152,9 +197,18 @@ public struct AppGroupConfiguration: Sendable, Equatable {
providerId: defaults.string(forKey: Keys.providerId) ?? "openai",
baseURL: "",
model: "",
asrProviderId: defaults.string(forKey: Keys.asrProviderId) ?? "",
asrBaseURL: "",
asrModel: "",
modeId: defaults.string(forKey: Keys.modeId) ?? "polish",
localeId: defaults.string(forKey: Keys.localeId) ?? "auto",
engineMode: defaults.string(forKey: Keys.engineMode) ?? "cloud",
// Privacy-critical default: `local` keeps raw audio on-device
// (SpeechAnalyzer). The `cloud` engine uploads recorded audio to
// the user's configured ASR provider and must stay an explicit,
// acknowledged opt-in (see `hasAcknowledgedCloudSharing`) a
// cloud default would contradict every privacy claim the app
// makes in its docs, App Store listing, and permission prompts.
engineMode: defaults.string(forKey: Keys.engineMode) ?? "local",
hasCompletedOnboarding: defaults.bool(forKey: Keys.hasCompletedOnboarding),
onboardingPage: {
let saved = defaults.integer(forKey: Keys.onboardingPage)
@@ -212,6 +266,19 @@ public struct AppGroupConfiguration: Sendable, Equatable {
config.model = defaults.string(forKey: Keys.model) ?? preset.defaultModel
}
if config.asrProviderId.isEmpty {
config.asrProviderId = config.providerId
defaults.set(config.asrProviderId, forKey: Keys.asrProviderId)
}
let asrPreset = LLMProvider.provider(id: config.asrProviderId)
if config.asrBaseURL.isEmpty {
config.asrBaseURL = defaults.string(forKey: Keys.asrBaseURL) ?? asrPreset.defaultBaseURL
}
if config.asrModel.isEmpty {
config.asrModel = defaults.string(forKey: Keys.asrModel)
?? CloudASRModelCatalog.defaultModel(for: config.asrProviderId)
}
// One-shot legacy migration: plaintext apiKey in UserDefaults Keychain.
_ = resolveAPIKey(
defaults: defaults,
@@ -219,6 +286,26 @@ public struct AppGroupConfiguration: Sendable, Equatable {
preferICloudSync: config.settingsICloudSyncEnabled
)
// One-shot default migration for installs that predate an explicit
// stored value. The privacy-safe defaults ("local", 30 min TTL) are
// for NEW installs only an existing user who ran on the old
// defaults must keep their behavior, both because silently changing
// engines under someone is wrong, and because iCloud settings sync
// would stamp the flip as a fresh "edit" and propagate it to every
// other device, overriding choices made there. Persisting the
// resolved value makes the decision stable and sync-invisible.
let isExistingInstall = defaults.bool(forKey: Keys.hasCompletedOnboarding)
if defaults.string(forKey: Keys.engineMode) == nil {
let resolved = isExistingInstall ? "cloud" : "local"
config.engineMode = resolved
defaults.set(resolved, forKey: Keys.engineMode)
}
if defaults.string(forKey: Keys.flowInactivityDuration) == nil {
let resolved: FlowInactivityDuration = isExistingInstall ? .twelveHours : .default
config.flowInactivityDuration = resolved
defaults.set(resolved.rawValue, forKey: Keys.flowInactivityDuration)
}
// Cloud no longer exposes off/transcribe; migrate legacy values.
if config.engineMode == "cloud", config.modeId != "polish" {
config.modeId = "polish"
@@ -234,6 +321,15 @@ public struct AppGroupConfiguration: Sendable, Equatable {
defaults.set(openAI.defaultBaseURL, forKey: Keys.baseURL)
defaults.set(openAI.defaultModel, forKey: Keys.model)
}
if config.engineMode == "cloud", config.asrProviderId == "deepseek" {
let openAI = LLMProvider.provider(id: "openai")
config.asrProviderId = openAI.id
config.asrBaseURL = openAI.defaultBaseURL
config.asrModel = CloudASRModelCatalog.defaultModel(for: openAI.id)
defaults.set(openAI.id, forKey: Keys.asrProviderId)
defaults.set(openAI.defaultBaseURL, forKey: Keys.asrBaseURL)
defaults.set(openAI.defaultModel, forKey: Keys.asrModel)
}
return config
}
@@ -242,6 +338,9 @@ public struct AppGroupConfiguration: Sendable, Equatable {
defaults.set(providerId, forKey: Keys.providerId)
defaults.set(baseURL, forKey: Keys.baseURL)
defaults.set(model, forKey: Keys.model)
defaults.set(asrProviderId, forKey: Keys.asrProviderId)
defaults.set(asrBaseURL, forKey: Keys.asrBaseURL)
defaults.set(asrModel, forKey: Keys.asrModel)
defaults.set(modeId, forKey: Keys.modeId)
defaults.set(localeId, forKey: Keys.localeId)
defaults.set(engineMode, forKey: Keys.engineMode)
@@ -328,4 +427,16 @@ public struct AppGroupConfiguration: Sendable, Equatable {
}
return ""
}
static func resolveASRAPIKey(
defaults: UserDefaults?,
providerId: String,
preferICloudSync: Bool = false
) -> String {
if let stored = Keychain.asrApiKey(for: providerId, preferICloudSync: preferICloudSync), !stored.isEmpty {
return stored
}
// Pre-split installs: one shared key under `provider.<id>`.
return resolveAPIKey(defaults: defaults, providerId: providerId, preferICloudSync: preferICloudSync)
}
}
@@ -0,0 +1,12 @@
// CloudProviderRole.swift
// OSGKeyboard · Shared
//
// Distinguishes cloud ASR credentials from polish LLM credentials
// (OpenLess-style split).
import Foundation
public enum CloudProviderRole: String, Sendable, Equatable {
case asr
case polish
}
@@ -15,7 +15,12 @@ public enum FlowInactivityDuration: String, CaseIterable, Identifiable, Sendable
public var id: String { rawValue }
public static let `default`: FlowInactivityDuration = .twelveHours
/// 30 minutes, not hours: competitors cap sessions at 560 min for a
/// reason a very long TTL keeps advertising "session active" long after
/// the host process is likely suspended or dead, amplifying every stale-
/// state bug into hours of confusing UI. Users can still opt into longer
/// windows explicitly.
public static let `default`: FlowInactivityDuration = .thirtyMinutes
public var timeInterval: TimeInterval {
switch self {
@@ -108,4 +108,11 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable {
public static var userSelectablePresets: [LLMProvider] {
presets.filter(\.isUserSelectable)
}
/// Cloud ASR presets (excludes providers without a cloud transcription API).
public static var asrSelectablePresets: [LLMProvider] {
userSelectablePresets.filter {
CloudASRModelCatalog.strategy(for: $0.id) != .localFallback
}
}
}
@@ -75,6 +75,9 @@ public struct LocalASRModelDefinition: Codable, Sendable, Equatable, Identifiabl
public let recommendedLocales: [String]
public let supportsHotwords: Bool
public let hotwordMode: LocalASRHotwordMode
/// Optional localization key for a short quality/speed badge
/// (e.g. `mac.localASR.badge.fastest`).
public let badgeKey: String?
public let installKind: LocalASRInstallKind
public let installRelativePath: String?
public let archiveBaseName: String?
@@ -9,7 +9,16 @@ import Foundation
extension PersonalDictionary {
public static let kvsKeyV2 = "personalDictionary.v2"
public static let legacyKVSKey = "personalDictionary.v1"
public static let tombstoneRetention: TimeInterval = 90 * 24 * 60 * 60
/// Tombstones guard against deleted entries "resurrecting" when a
/// long-offline device rejoins and re-merges them. A short wall-clock
/// retention re-opened that window after only 90 days; a year keeps the
/// window closed for any realistically dormant device while staying tiny
/// on the wire (a tombstone is ~60 bytes of JSON), and the count cap
/// bounds the worst case regardless of clock.
public static let tombstoneRetention: TimeInterval = 365 * 24 * 60 * 60
/// Hard cap independent of wall clock the oldest tombstones are
/// dropped first once exceeded.
public static let maxTombstones = 500
/// Merges two dictionary snapshots for cross-device sync.
///
@@ -100,11 +109,19 @@ extension PersonalDictionary {
clearedAt: Date?
) -> [UUID: Date] {
let cutoff = Date().addingTimeInterval(-tombstoneRetention)
return tombstones.filter { _, deletedAt in
var kept = tombstones.filter { _, deletedAt in
if deletedAt < cutoff { return false }
if let clearedAt, deletedAt <= clearedAt { return false }
return true
}
// Enforce the count cap that makes the 365-day retention safe on the
// KVS byte budget: keep the NEWEST tombstones (dropping an old one
// early only re-opens the resurrection window for that one entry).
if kept.count > maxTombstones {
let newest = kept.sorted { $0.value > $1.value }.prefix(maxTombstones)
kept = Dictionary(uniqueKeysWithValues: newest.map { ($0.key, $0.value) })
}
return kept
}
private static func later(of lhs: Date?, and rhs: Date?) -> Date? {
+104 -11
View File
@@ -53,6 +53,44 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
persistConfiguration()
}
}
@Published public var asrProviderId: String {
didSet {
guard !isApplyingConfiguration, asrProviderId != configuration.asrProviderId else { return }
configuration.asrProviderId = asrProviderId
isSyncingASRProviderAPIKey = true
asrApiKey = configuration.asrApiKey
isSyncingASRProviderAPIKey = false
persistConfiguration()
}
}
@Published public var asrBaseURL: String {
didSet {
guard !isApplyingConfiguration, asrBaseURL != configuration.asrBaseURL else { return }
configuration.asrBaseURL = asrBaseURL
persistConfiguration()
}
}
@Published public var asrApiKey: String {
didSet {
guard oldValue != asrApiKey, !isSyncingASRProviderAPIKey else { return }
do {
try Keychain.setASRAPIKey(
asrApiKey,
for: asrProviderId,
useICloudSync: configuration.settingsICloudSyncEnabled
)
} catch {
OSGLog.config.warning("ASR Keychain write failed: \(error.localizedDescription, privacy: .public)")
}
}
}
@Published public var asrModel: String {
didSet {
guard !isApplyingConfiguration, asrModel != configuration.asrModel else { return }
configuration.asrModel = asrModel
persistConfiguration()
}
}
@Published public var modeId: String {
didSet {
guard !isApplyingConfiguration, modeId != configuration.modeId else { return }
@@ -67,8 +105,8 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
persistConfiguration()
}
}
/// "local" on-device ASR + built-in DeepSeek polish.
/// "cloud" provider cloud ASR (with personal dictionary) + user's cloud LLM polish.
/// "local" on-device ASR + user's LLM polish (or built-in DeepSeek).
/// "cloud" user's cloud ASR + user's cloud LLM polish (independent picks).
@Published public var engineMode: String {
didSet {
guard !isApplyingConfiguration, engineMode != configuration.engineMode else { return }
@@ -213,25 +251,38 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
}
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.
if isLocalEngine { return true }
return !baseURL.isEmpty && !apiKey.isEmpty && !model.isEmpty
if isLocalEngine {
return isPolishConfigured
}
return isASRConfigured && isPolishConfigured
}
public var isPolishConfigured: Bool {
if !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return !baseURL.isEmpty && !model.isEmpty
}
return PreconfiguredKeys.isDeepseekConfigured
}
public var isASRConfigured: Bool {
guard !isLocalEngine else { return true }
return !asrApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& (!asrBaseURL.isEmpty || CloudASRModelCatalog.strategy(for: asrProviderId) != .prompt)
}
/// On-device ASR only; no cloud API required.
public var isLocalEngine: Bool { configuration.isLocalEngine }
/// Local engine always polishes via the built-in DeepSeek path.
public var shouldPolishLocalTranscript: Bool { isLocalEngine }
/// Cloud engine uses `providerId`. Local engine pins DeepSeek.
public var localModeProviderId: String { "deepseek" }
/// Built-in DeepSeek path when the user has not supplied their own LLM key.
public var localModeProviderId: String {
apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "deepseek" : providerId
}
private let defaults: UserDefaults
private var configuration: AppGroupConfiguration
private var isApplyingConfiguration = false
private var isSyncingProviderAPIKey = false
private var isSyncingASRProviderAPIKey = false
public init(defaults: UserDefaults? = nil) {
guard let resolvedDefaults = defaults ?? AppGroup.defaultsIfAvailable else {
@@ -270,6 +321,9 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
baseURL = configuration.baseURL
apiKey = configuration.apiKey
model = configuration.model
asrProviderId = configuration.asrProviderId
asrBaseURL = configuration.asrBaseURL
asrModel = configuration.asrModel
modeId = configuration.modeId
localeId = configuration.localeId
engineMode = configuration.engineMode
@@ -284,6 +338,12 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
flowSkipAppSwitch = configuration.flowSkipAppSwitch
flowInactivityDuration = configuration.flowInactivityDuration
localASRCustomLanguageModelEnabled = configuration.localASRCustomLanguageModelEnabled
isSyncingProviderAPIKey = true
apiKey = configuration.apiKey
isSyncingProviderAPIKey = false
isSyncingASRProviderAPIKey = true
asrApiKey = configuration.asrApiKey
isSyncingASRProviderAPIKey = false
isApplyingConfiguration = false
}
@@ -293,6 +353,9 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
if engineMode == "cloud", providerId == "deepseek" {
apply(preset: LLMProvider.provider(id: "openai"))
}
if engineMode == "cloud", asrProviderId == "deepseek" {
applyAsr(preset: LLMProvider.provider(id: "openai"))
}
}
private func persistConfiguration(postConfigChanged: Bool = false) {
@@ -324,6 +387,9 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
providerId = fresh.providerId
baseURL = fresh.baseURL
model = fresh.model
asrProviderId = fresh.asrProviderId
asrBaseURL = fresh.asrBaseURL
asrModel = fresh.asrModel
modeId = fresh.modeId
localeId = fresh.localeId
engineMode = fresh.engineMode
@@ -341,6 +407,9 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
isSyncingProviderAPIKey = true
apiKey = fresh.apiKey
isSyncingProviderAPIKey = false
isSyncingASRProviderAPIKey = true
asrApiKey = fresh.asrApiKey
isSyncingASRProviderAPIKey = false
isApplyingConfiguration = false
}
@@ -370,6 +439,23 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
persistConfiguration()
}
public func applyAsr(preset: LLMProvider) {
isApplyingConfiguration = true
asrProviderId = preset.id
if !preset.defaultBaseURL.isEmpty {
asrBaseURL = preset.defaultBaseURL
}
asrModel = CloudASRModelCatalog.defaultModel(for: preset.id)
configuration.asrProviderId = asrProviderId
configuration.asrBaseURL = asrBaseURL
configuration.asrModel = asrModel
isSyncingASRProviderAPIKey = true
asrApiKey = configuration.asrApiKey
isSyncingASRProviderAPIKey = false
isApplyingConfiguration = false
persistConfiguration()
}
public func reset() {
isApplyingConfiguration = true
let preset = LLMProvider.provider(id: "openai")
@@ -377,12 +463,19 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
baseURL = preset.defaultBaseURL
apiKey = ""
model = preset.defaultModel
asrProviderId = preset.id
asrBaseURL = preset.defaultBaseURL
asrModel = CloudASRModelCatalog.defaultModel(for: preset.id)
asrApiKey = ""
handednessPreference = .left
localASRCustomLanguageModelEnabled = true
hasAcknowledgedCloudSharing = false
configuration.providerId = preset.id
configuration.baseURL = preset.defaultBaseURL
configuration.model = preset.defaultModel
configuration.asrProviderId = preset.id
configuration.asrBaseURL = preset.defaultBaseURL
configuration.asrModel = CloudASRModelCatalog.defaultModel(for: preset.id)
configuration.handednessPreference = .left
configuration.localASRCustomLanguageModelEnabled = true
configuration.hasAcknowledgedCloudSharing = false
@@ -14,6 +14,10 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
public var providerId: SyncedField<String>
public var baseURL: SyncedField<String>
public var model: SyncedField<String>
/// Cloud ASR provider independent from polish `providerId`.
public var asrProviderId: SyncedField<String>
public var asrBaseURL: SyncedField<String>
public var asrModel: SyncedField<String>
public var modeId: SyncedField<String>
public var localeId: SyncedField<String>
public var engineMode: SyncedField<String>
@@ -31,6 +35,9 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
providerId: SyncedField<String>,
baseURL: SyncedField<String>,
model: SyncedField<String>,
asrProviderId: SyncedField<String>,
asrBaseURL: SyncedField<String>,
asrModel: SyncedField<String>,
modeId: SyncedField<String>,
localeId: SyncedField<String>,
engineMode: SyncedField<String>,
@@ -47,6 +54,9 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
self.providerId = providerId
self.baseURL = baseURL
self.model = model
self.asrProviderId = asrProviderId
self.asrBaseURL = asrBaseURL
self.asrModel = asrModel
self.modeId = modeId
self.localeId = localeId
self.engineMode = engineMode
@@ -60,12 +70,88 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
self.flowInactivityDuration = flowInactivityDuration
}
private enum CodingKeys: String, CodingKey {
case schemaVersion
case providerId
case baseURL
case model
case asrProviderId
case asrBaseURL
case asrModel
case modeId
case localeId
case engineMode
case hasAcknowledgedCloudSharing
case uiLanguage
case translationTargetLocaleId
case handednessPreference
case cursorDragNavigationEnabled
case polishIntensity
case flowSkipAppSwitch
case flowInactivityDuration
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
schemaVersion = try container.decode(Int.self, forKey: .schemaVersion)
providerId = try container.decode(SyncedField<String>.self, forKey: .providerId)
baseURL = try container.decode(SyncedField<String>.self, forKey: .baseURL)
model = try container.decode(SyncedField<String>.self, forKey: .model)
modeId = try container.decode(SyncedField<String>.self, forKey: .modeId)
localeId = try container.decode(SyncedField<String>.self, forKey: .localeId)
engineMode = try container.decode(SyncedField<String>.self, forKey: .engineMode)
hasAcknowledgedCloudSharing = try container.decode(SyncedField<Bool>.self, forKey: .hasAcknowledgedCloudSharing)
uiLanguage = try container.decode(SyncedField<AppUILanguage>.self, forKey: .uiLanguage)
translationTargetLocaleId = try container.decode(
SyncedField<String>.self,
forKey: .translationTargetLocaleId
)
handednessPreference = try container.decode(
SyncedField<HandednessPreference>.self,
forKey: .handednessPreference
)
cursorDragNavigationEnabled = try container.decode(
SyncedField<Bool>.self,
forKey: .cursorDragNavigationEnabled
)
polishIntensity = try container.decode(SyncedField<PolishIntensity>.self, forKey: .polishIntensity)
flowSkipAppSwitch = try container.decode(SyncedField<Bool>.self, forKey: .flowSkipAppSwitch)
flowInactivityDuration = try container.decode(
SyncedField<FlowInactivityDuration>.self,
forKey: .flowInactivityDuration
)
if let asrProvider = try container.decodeIfPresent(SyncedField<String>.self, forKey: .asrProviderId) {
asrProviderId = asrProvider
} else {
asrProviderId = providerId
}
if let asrURL = try container.decodeIfPresent(SyncedField<String>.self, forKey: .asrBaseURL) {
asrBaseURL = asrURL
} else {
asrBaseURL = baseURL
}
if let asrModelField = try container.decodeIfPresent(SyncedField<String>.self, forKey: .asrModel) {
asrModel = asrModelField
} else {
let fallbackModel = CloudASRModelCatalog.defaultModel(for: providerId.value)
asrModel = SyncedField(
value: fallbackModel,
updatedAt: model.updatedAt,
deviceID: model.deviceID
)
}
}
/// Monotonic stamp used for `settingsCloudUpdatedAt` bookkeeping.
public var latestUpdatedAt: Date {
[
providerId.updatedAt,
baseURL.updatedAt,
model.updatedAt,
asrProviderId.updatedAt,
asrBaseURL.updatedAt,
asrModel.updatedAt,
modeId.updatedAt,
localeId.updatedAt,
engineMode.updatedAt,
@@ -99,6 +185,9 @@ public extension SyncedAppSettingsV2 {
providerId: field(configuration.providerId),
baseURL: field(configuration.baseURL),
model: field(configuration.model),
asrProviderId: field(configuration.asrProviderId),
asrBaseURL: field(configuration.asrBaseURL),
asrModel: field(configuration.asrModel),
modeId: field(configuration.modeId),
localeId: field(configuration.localeId),
engineMode: field(configuration.engineMode),
@@ -119,10 +208,14 @@ public extension SyncedAppSettingsV2 {
func field<T>(_ value: T) -> SyncedField<T> {
SyncedField(value: value, updatedAt: stamp, deviceID: deviceID)
}
let provider = field(legacy.providerId)
return SyncedAppSettingsV2(
providerId: field(legacy.providerId),
providerId: provider,
baseURL: field(legacy.baseURL),
model: field(legacy.model),
asrProviderId: provider,
asrBaseURL: field(legacy.baseURL),
asrModel: field(CloudASRModelCatalog.defaultModel(for: legacy.providerId)),
modeId: field(legacy.modeId),
localeId: field(legacy.localeId),
engineMode: field(legacy.engineMode),
@@ -142,6 +235,9 @@ public extension SyncedAppSettingsV2 {
providerId: .merge(local: local.providerId, remote: remote.providerId),
baseURL: .merge(local: local.baseURL, remote: remote.baseURL),
model: .merge(local: local.model, remote: remote.model),
asrProviderId: .merge(local: local.asrProviderId, remote: remote.asrProviderId),
asrBaseURL: .merge(local: local.asrBaseURL, remote: remote.asrBaseURL),
asrModel: .merge(local: local.asrModel, remote: remote.asrModel),
modeId: .merge(local: local.modeId, remote: remote.modeId),
localeId: .merge(local: local.localeId, remote: remote.localeId),
engineMode: .merge(local: local.engineMode, remote: remote.engineMode),
@@ -172,6 +268,9 @@ public extension SyncedAppSettingsV2 {
configuration.providerId = providerId.value
configuration.baseURL = baseURL.value
configuration.model = model.value
configuration.asrProviderId = asrProviderId.value
configuration.asrBaseURL = asrBaseURL.value
configuration.asrModel = asrModel.value
configuration.modeId = modeId.value
configuration.localeId = localeId.value
configuration.engineMode = engineMode.value
@@ -195,6 +294,9 @@ public extension SyncedAppSettingsV2 {
patch(&copy.providerId, value: configuration.providerId)
patch(&copy.baseURL, value: configuration.baseURL)
patch(&copy.model, value: configuration.model)
patch(&copy.asrProviderId, value: configuration.asrProviderId)
patch(&copy.asrBaseURL, value: configuration.asrBaseURL)
patch(&copy.asrModel, value: configuration.asrModel)
patch(&copy.modeId, value: configuration.modeId)
patch(&copy.localeId, value: configuration.localeId)
patch(&copy.engineMode, value: configuration.engineMode)
@@ -221,6 +323,9 @@ public extension SyncedAppSettingsV2 {
touch(&copy.providerId, value: configuration.providerId)
touch(&copy.baseURL, value: configuration.baseURL)
touch(&copy.model, value: configuration.model)
touch(&copy.asrProviderId, value: configuration.asrProviderId)
touch(&copy.asrBaseURL, value: configuration.asrBaseURL)
touch(&copy.asrModel, value: configuration.asrModel)
touch(&copy.modeId, value: configuration.modeId)
touch(&copy.localeId, value: configuration.localeId)
touch(&copy.engineMode, value: configuration.engineMode)
+28 -3
View File
@@ -16,11 +16,36 @@ public struct SyncedField<T: Codable & Equatable & Sendable>: Codable, Equatable
self.deviceID = deviceID
}
/// A remote timestamp may be at most this far in OUR future before we
/// stop trusting it. Wall-clock LWW breaks down when one device's clock
/// runs fast: its edits would win every merge forever, silently
/// discarding later edits from correct-clock devices. Anything beyond
/// this skew is a broken clock, not a newer edit.
public static var maxTrustedFutureSkew: TimeInterval { 6 * 60 * 60 }
/// Pick the field with the newer `updatedAt`; ties break lexicographically on `deviceID`.
///
/// Broken-clock containment: comparing with clamped stamps alone is not
/// enough a far-future stamp stored in the winner would keep beating
/// every later genuine edit (whose stamps are merely "now") until that
/// wall-clock date actually arrived. So when the winner carries an
/// untrusted future stamp, the stamp itself is REWRITTEN to "now" in the
/// merged result: from then on any real edit, made later, outranks it.
public static func merge(local: SyncedField<T>, remote: SyncedField<T>) -> SyncedField<T> {
if remote.updatedAt > local.updatedAt { return remote }
if local.updatedAt > remote.updatedAt { return local }
return remote.deviceID >= local.deviceID ? remote : local
let now = Date()
let horizon = now.addingTimeInterval(maxTrustedFutureSkew)
let remoteAt = remote.updatedAt > horizon ? now : remote.updatedAt
let localAt = local.updatedAt > horizon ? now : local.updatedAt
let winner: SyncedField<T>
if remoteAt > localAt {
winner = remote
} else if localAt > remoteAt {
winner = local
} else {
winner = remote.deviceID >= local.deviceID ? remote : local
}
guard winner.updatedAt > horizon else { return winner }
return SyncedField(value: winner.value, updatedAt: now, deviceID: winner.deviceID)
}
public static func make(value: T, deviceID: String) -> SyncedField<T> {
@@ -12,7 +12,16 @@ public struct SyncedSpeechHistory: Codable, Equatable, Sendable {
public static let legacyKVSKey = "speechHistory.v1"
public static let maxEntries = 300
/// Tombstones older than this window may be pruned during merge.
public static let tombstoneRetention: TimeInterval = 90 * 24 * 60 * 60
/// Tombstones guard against deleted entries "resurrecting" when a
/// long-offline device rejoins and re-merges them. A short wall-clock
/// retention re-opened that window after only 90 days; a year keeps the
/// window closed for any realistically dormant device while staying tiny
/// on the wire (a tombstone is ~60 bytes of JSON), and the count cap
/// bounds the worst case regardless of clock.
public static let tombstoneRetention: TimeInterval = 365 * 24 * 60 * 60
/// Hard cap independent of wall clock the oldest tombstones are
/// dropped first once exceeded.
public static let maxTombstones = 500
public var schemaVersion: Int
public var updatedAt: Date
@@ -107,15 +116,19 @@ public struct SyncedSpeechHistory: Codable, Equatable, Sendable {
clearedAt: Date?
) -> [UUID: Date] {
let cutoff = Date().addingTimeInterval(-tombstoneRetention)
return tombstones.filter { _, deletedAt in
if deletedAt < cutoff {
return false
}
if let clearedAt, deletedAt <= clearedAt {
return false
}
var kept = tombstones.filter { _, deletedAt in
if deletedAt < cutoff { return false }
if let clearedAt, deletedAt <= clearedAt { return false }
return true
}
// Enforce the count cap that makes the 365-day retention safe on the
// KVS byte budget: keep the NEWEST tombstones (dropping an old one
// early only re-opens the resurrection window for that one entry).
if kept.count > maxTombstones {
let newest = kept.sorted { $0.value > $1.value }.prefix(maxTombstones)
kept = Dictionary(uniqueKeysWithValues: newest.map { ($0.key, $0.value) })
}
return kept
}
private static func later(of lhs: Date?, and rhs: Date?) -> Date? {