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
+182 -49
View File
@@ -1,9 +1,10 @@
// MacSettingsView.swift
// OSGKeyboard · Mac
//
// Settings built on the native grouped `Form` the same container macOS
// System Settings uses. This gives system-accurate cards, dividers, insets
// and right-aligned controls for free, on both light and dark.
// Settings uses native grouped `Form` for correct control layout (Picker /
// Toggle / LabeledContent). Title and Form share the same plain
// `pageHorizontalInset` padding (Form scroll margins are zeroed first) so
// card chrome lines up with the page title.
import SwiftUI
#if os(macOS)
@@ -20,6 +21,7 @@ struct MacSettingsView: View {
private var hasCompletedMacOnboarding = true
@State private var accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted
@State private var showProviderPicker = false
@State private var showAsrProviderPicker = false
private var lang: AppUILanguage { viewModel.config.uiLanguage }
private let recognitionLocales: [(id: String, key: String, fallback: String)] = [
@@ -33,23 +35,43 @@ struct MacSettingsView: View {
var body: some View {
NavigationStack {
Form {
generalSection
recognitionSection
if viewModel.config.engineMode == "cloud" {
providerSection
.transition(.opacity)
VStack(spacing: 0) {
MacPageHeader(
title: MacL10n.string("mac.section.settings", language: lang),
subtitle: MacL10n.string("mac.page.settings.subtitle", language: lang)
)
Form {
generalSection
recognitionSection
polishProviderSection
if viewModel.config.engineMode == "cloud" {
asrProviderSection
.transition(.opacity)
}
if viewModel.config.engineMode == "local" {
MacLocalASRModelSettingsView(viewModel: viewModel)
.transition(.opacity)
}
inputSection
legalSection
}
if viewModel.config.engineMode == "local" {
MacLocalASRModelSettingsView(viewModel: viewModel)
.transition(.opacity)
}
inputSection
legalSection
.formStyle(.grouped)
// Zero Form's own scroll margins, then inset via padding so the
// section cards line up with MacPageHeader (contentMargins alone
// does not match plain padding on macOS).
//
// grouped Form adds its own built-in section inset on top of our
// padding, so cards sat ~`groupedFormSectionInset` wider than the
// History page. Subtract that inset here so the card OUTER edge
// lands on `pageHorizontalInset` (40pt), matching History and the
// page title's left edge.
.contentMargins(.horizontal, 0, for: .scrollContent)
.padding(.horizontal, MacMetrics.pageHorizontalInset - MacMetrics.groupedFormSectionInset)
.tint(palette.accent)
.scrollContentBackground(.hidden)
.background(palette.background)
}
.formStyle(.grouped)
.tint(palette.accent)
.scrollContentBackground(.hidden)
.background(palette.background)
}
.onAppear { refreshAccessibilityState() }
@@ -82,27 +104,21 @@ struct MacSettingsView: View {
}
}
// MARK: - Cloud provider
// MARK: - Polish LLM
private var providerSection: some View {
Section(MacL10n.string("mac.settings.cloudProvider", language: lang)) {
LabeledContent(MacL10n.string("mac.settings.service", language: lang)) {
Button {
showProviderPicker = true
} label: {
HStack(spacing: 6) {
providerLogo(currentProvider.id)
Text(currentProvider.name)
.foregroundStyle(palette.textPrimary)
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(palette.textTertiary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.popover(isPresented: $showProviderPicker, arrowEdge: .bottom) {
providerPickerList
private var polishProviderSection: some View {
Section(MacL10n.string("mac.settings.polishProvider", language: lang)) {
providerPickerRow(
title: MacL10n.string("mac.settings.service", language: lang),
provider: currentPolishProvider,
isPresented: $showProviderPicker
) {
providerPickerList(
providers: viewModel.polishSelectableProviders,
selectedId: viewModel.config.providerId
) { provider in
viewModel.selectProvider(provider)
showProviderPicker = false
}
}
@@ -117,6 +133,17 @@ struct MacSettingsView: View {
Text(MacL10n.string("mac.settings.apiKey", language: lang))
}
LabeledContent {
TextField(text: $viewModel.config.baseURL, prompt: Text(verbatim: "")) {
Text(MacL10n.string("mac.settings.baseURL", language: lang))
}
.labelsHidden()
.macFieldStyle()
.frame(maxWidth: MacMetrics.controlWidth)
} label: {
Text(MacL10n.string("mac.settings.baseURL", language: lang))
}
LabeledContent {
TextField(text: $viewModel.config.model, prompt: Text(verbatim: "")) {
Text(MacL10n.string("mac.settings.model", language: lang))
@@ -130,6 +157,86 @@ struct MacSettingsView: View {
}
}
// MARK: - Cloud ASR
private var asrProviderSection: some View {
Section(MacL10n.string("mac.settings.asrProvider", language: lang)) {
providerPickerRow(
title: MacL10n.string("mac.settings.asrService", language: lang),
provider: currentAsrProvider,
isPresented: $showAsrProviderPicker
) {
providerPickerList(
providers: viewModel.asrSelectableProviders,
selectedId: viewModel.config.asrProviderId
) { provider in
viewModel.selectAsrProvider(provider)
showAsrProviderPicker = false
}
}
LabeledContent {
SecureField(text: $viewModel.config.asrApiKey, prompt: Text(verbatim: "sk-…")) {
Text(MacL10n.string("mac.settings.apiKey", language: lang))
}
.labelsHidden()
.macFieldStyle()
.frame(maxWidth: MacMetrics.controlWidth)
} label: {
Text(MacL10n.string("mac.settings.asrApiKey", language: lang))
}
if CloudASRModelCatalog.strategy(for: viewModel.config.asrProviderId) == .prompt {
LabeledContent {
TextField(text: $viewModel.config.asrBaseURL, prompt: Text(verbatim: "")) {
Text(MacL10n.string("mac.settings.baseURL", language: lang))
}
.labelsHidden()
.macFieldStyle()
.frame(maxWidth: MacMetrics.controlWidth)
} label: {
Text(MacL10n.string("mac.settings.baseURL", language: lang))
}
}
LabeledContent {
TextField(text: $viewModel.config.asrModel, prompt: Text(verbatim: "")) {
Text(MacL10n.string("mac.settings.asrModel", language: lang))
}
.labelsHidden()
.macFieldStyle()
.frame(maxWidth: MacMetrics.controlWidth)
} label: {
Text(MacL10n.string("mac.settings.asrModel", language: lang))
}
}
}
private func providerPickerRow<Content: View>(
title: String,
provider: LLMProvider,
isPresented: Binding<Bool>,
@ViewBuilder picker: @escaping () -> Content
) -> some View {
LabeledContent(title) {
Button {
isPresented.wrappedValue = true
} label: {
HStack(spacing: 6) {
providerLogo(provider.id)
Text(provider.name)
.foregroundStyle(palette.textPrimary)
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(palette.textTertiary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.popover(isPresented: isPresented, arrowEdge: .bottom, content: picker)
}
}
// MARK: - Recognition method
private var recognitionSection: some View {
@@ -161,6 +268,19 @@ struct MacSettingsView: View {
)
}
Picker(selection: hotkeyTriggerBinding) {
ForEach(MacHotkeyTrigger.allCases) { trigger in
Text(MacL10n.string(trigger.labelKey, language: lang))
.tag(trigger.rawValue)
}
} label: {
rowLabel(
MacL10n.string("mac.settings.hotkeyTrigger", language: lang),
subtitle: MacL10n.string("mac.settings.hotkeyTriggerDesc", language: lang)
)
}
.disabled(!viewModel.hotkeyEnabled)
Toggle(isOn: autoPasteBinding) {
rowLabel(
MacL10n.string("mac.settings.autoPaste", language: lang),
@@ -258,28 +378,34 @@ struct MacSettingsView: View {
.buttonStyle(.plain)
}
private var currentProvider: LLMProvider {
viewModel.selectableProviders.first { $0.id == viewModel.config.providerId }
?? viewModel.selectableProviders.first
private var currentPolishProvider: LLMProvider {
viewModel.polishSelectableProviders.first { $0.id == viewModel.config.providerId }
?? viewModel.polishSelectableProviders.first
?? LLMProvider.presets[0]
}
/// Custom dropdown list shown in a popover. SwiftUI's `Menu` label / items
/// silently drop bundled (non-SF-Symbol) images on macOS, so we render the
/// brand marks in a plain view stack instead.
private var providerPickerList: some View {
private var currentAsrProvider: LLMProvider {
viewModel.asrSelectableProviders.first { $0.id == viewModel.config.asrProviderId }
?? viewModel.asrSelectableProviders.first
?? LLMProvider.presets[0]
}
private func providerPickerList(
providers: [LLMProvider],
selectedId: String,
onSelect: @escaping (LLMProvider) -> Void
) -> some View {
VStack(spacing: 0) {
ForEach(viewModel.selectableProviders) { provider in
ForEach(providers) { provider in
Button {
viewModel.selectProvider(provider)
showProviderPicker = false
onSelect(provider)
} label: {
HStack(spacing: Spacing.sm) {
providerLogo(provider.id)
Text(provider.name)
.foregroundStyle(palette.textPrimary)
Spacer(minLength: Spacing.md)
if provider.id == currentProvider.id {
if provider.id == selectedId {
Image(systemName: "checkmark")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(palette.accent)
@@ -359,6 +485,13 @@ struct MacSettingsView: View {
)
}
private var hotkeyTriggerBinding: Binding<String> {
Binding(
get: { viewModel.hotkeyTrigger.rawValue },
set: { viewModel.setHotkeyTrigger(MacHotkeyTrigger(rawValue: $0) ?? .rightOption) }
)
}
private var autoPasteBinding: Binding<Bool> {
Binding(
get: { viewModel.autoPasteEnabled },