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
@@ -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)