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:
@@ -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 5–60 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? {
|
||||
|
||||
@@ -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(©.providerId, value: configuration.providerId)
|
||||
patch(©.baseURL, value: configuration.baseURL)
|
||||
patch(©.model, value: configuration.model)
|
||||
patch(©.asrProviderId, value: configuration.asrProviderId)
|
||||
patch(©.asrBaseURL, value: configuration.asrBaseURL)
|
||||
patch(©.asrModel, value: configuration.asrModel)
|
||||
patch(©.modeId, value: configuration.modeId)
|
||||
patch(©.localeId, value: configuration.localeId)
|
||||
patch(©.engineMode, value: configuration.engineMode)
|
||||
@@ -221,6 +323,9 @@ public extension SyncedAppSettingsV2 {
|
||||
touch(©.providerId, value: configuration.providerId)
|
||||
touch(©.baseURL, value: configuration.baseURL)
|
||||
touch(©.model, value: configuration.model)
|
||||
touch(©.asrProviderId, value: configuration.asrProviderId)
|
||||
touch(©.asrBaseURL, value: configuration.asrBaseURL)
|
||||
touch(©.asrModel, value: configuration.asrModel)
|
||||
touch(©.modeId, value: configuration.modeId)
|
||||
touch(©.localeId, value: configuration.localeId)
|
||||
touch(©.engineMode, value: configuration.engineMode)
|
||||
|
||||
@@ -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? {
|
||||
|
||||
Reference in New Issue
Block a user