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